mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
068aff27b4
- chat.php: Add Tier 0.9a (gmail_triage), Tier 0.9b (remote_exec) detection; refactor arc submit into arcSubmitJob() helper; natural-language triggers for email triage (check my email, triage inbox) and remote exec (restart X on Y, run X on Y, get logs from X on Y) - arc.php: Add triage and triage_action endpoints (read/update email_triage table) - index.html: Add COMMS tab with triage card UI (filter bar, category badges, draft reply viewer, copy/dismiss actions); loadComms() with 8s polling; onArcJobStarted() routes gmail_triage jobs to COMMS tab - admin/index.php: Add GMAIL TRIAGE section under COMMUNICATIONS nav; triage_list/ triage_action/triage_run PHP actions; loadTriage() JS with full table + draft modal; triageRunNow() submits gmail_triage job to Arc Reactor
2391 lines
139 KiB
PHP
2391 lines
139 KiB
PHP
<?php
|
||
require_once __DIR__ . '/../../api/config.php';
|
||
require_once __DIR__ . '/../../api/lib/db.php';
|
||
|
||
session_name('jarvis_admin');
|
||
session_start();
|
||
|
||
// ── AUTH HELPERS ──────────────────────────────────────────────────────────────
|
||
function loggedIn(): bool { return !empty($_SESSION['admin_user']); }
|
||
function j(mixed $d): never { header('Content-Type: application/json'); echo json_encode($d); exit; }
|
||
function bad(string $msg, int $code = 400): never { http_response_code($code); j(['error' => $msg]); }
|
||
|
||
function self_upsert_device(array $d): void {
|
||
JarvisDB::execute(
|
||
'INSERT INTO network_devices (ip,mac,hostname,status,last_seen) VALUES (?,?,?,"online",NOW())
|
||
ON DUPLICATE KEY UPDATE mac=VALUES(mac), hostname=COALESCE(VALUES(hostname),hostname), status="online", last_seen=NOW()',
|
||
[$d['ip'], $d['mac'], $d['hostname'] ?? $d['vendor']]
|
||
);
|
||
if (!empty($d['vendor'])) {
|
||
JarvisDB::execute('UPDATE network_devices SET device_type=? WHERE ip=? AND (device_type IS NULL OR device_type="")', [$d['vendor'], $d['ip']]);
|
||
}
|
||
}
|
||
|
||
// ── BACKEND API ───────────────────────────────────────────────────────────────
|
||
$action = $_GET['action'] ?? $_POST['action'] ?? '';
|
||
if ($action) {
|
||
// Login doesn't require session
|
||
if ($action === 'login') {
|
||
$u = trim($_POST['username'] ?? '');
|
||
$p = $_POST['password'] ?? '';
|
||
$row = JarvisDB::single('SELECT * FROM users WHERE username = ?', [$u]);
|
||
if ($row && password_verify($p, $row['password_hash'])) {
|
||
$_SESSION['admin_user'] = $row['username'];
|
||
$_SESSION['admin_name'] = $row['display_name'];
|
||
j(['ok' => true, 'name' => $row['display_name']]);
|
||
}
|
||
bad('Invalid credentials', 401);
|
||
}
|
||
if ($action === 'logout') { session_destroy(); j(['ok' => true]); }
|
||
if (!loggedIn()) bad('Not authenticated', 401);
|
||
|
||
switch ($action) {
|
||
|
||
// ── DASHBOARD ─────────────────────────────────────────────────────────
|
||
case 'dashboard':
|
||
$mi = [];
|
||
foreach (file('/proc/meminfo') as $l) {
|
||
[$k,$v] = explode(':', $l, 2) + [null,null];
|
||
if ($k) $mi[trim($k)] = (int)trim($v);
|
||
}
|
||
$mt = $mi['MemTotal'] ?? 0; $mf = $mi['MemAvailable'] ?? 0;
|
||
$up = (int)explode(' ', file_get_contents('/proc/uptime'))[0];
|
||
$la = explode(' ', file_get_contents('/proc/loadavg'));
|
||
$disk = trim(shell_exec("df / | tail -1 | awk '{print $5}'") ?? '');
|
||
j([
|
||
'sys' => [
|
||
'mem_pct' => $mt > 0 ? round(($mt-$mf)/$mt*100,1) : 0,
|
||
'mem_used_mb' => round(($mt-$mf)/1024),
|
||
'mem_total_mb' => round($mt/1024),
|
||
'uptime_s' => $up,
|
||
'load_1m' => (float)$la[0],
|
||
'disk_pct' => $disk,
|
||
],
|
||
'agents' => JarvisDB::single('SELECT COUNT(*) total, SUM(status="online") online FROM registered_agents'),
|
||
'alerts' => JarvisDB::single('SELECT COUNT(*) total, SUM(resolved=0) active FROM alerts'),
|
||
'devices' => JarvisDB::single('SELECT COUNT(*) total, SUM(status="online") online FROM network_devices WHERE alias IS NOT NULL'),
|
||
'facts' => JarvisDB::single('SELECT COUNT(*) total FROM kb_facts'),
|
||
'intents' => JarvisDB::single('SELECT COUNT(*) total, SUM(active=1) active FROM kb_intents'),
|
||
]);
|
||
|
||
// ── AGENTS ───────────────────────────────────────────────────────────
|
||
case 'agents_list':
|
||
$agents = JarvisDB::query('SELECT agent_id, hostname, agent_type, ip_address, status, last_seen, created_at FROM registered_agents ORDER BY status="online" DESC, hostname');
|
||
$metrics = JarvisDB::query(
|
||
"SELECT agent_id,
|
||
ROUND(JSON_EXTRACT(metric_data,'$.cpu_percent'),1) AS cpu_pct,
|
||
ROUND(JSON_EXTRACT(metric_data,'$.memory.percent'),1) AS mem_pct,
|
||
ROUND(JSON_EXTRACT(metric_data,'$.disk[0].percent'),1) AS disk_pct
|
||
FROM agent_metrics
|
||
WHERE metric_type='system' AND recorded_at > DATE_SUB(NOW(), INTERVAL 5 MINUTE)
|
||
GROUP BY agent_id ORDER BY recorded_at DESC"
|
||
);
|
||
$mm = array_column($metrics, null, 'agent_id');
|
||
foreach ($agents as &$a) $a['metrics'] = $mm[$a['agent_id']] ?? null;
|
||
j($agents);
|
||
|
||
case 'agents_delete':
|
||
$id = $_POST['agent_id'] ?? ''; if (!$id) bad('Missing agent_id');
|
||
JarvisDB::execute('DELETE FROM registered_agents WHERE agent_id=?', [$id]);
|
||
JarvisDB::execute('DELETE FROM agent_metrics WHERE agent_id=?', [$id]);
|
||
JarvisDB::execute('DELETE FROM agent_commands WHERE agent_id=?', [$id]);
|
||
j(['ok' => true]);
|
||
|
||
// ── NETWORK ──────────────────────────────────────────────────────────
|
||
case 'network_list':
|
||
j(JarvisDB::query('SELECT id,ip,mac,hostname,alias,device_type,status,last_seen FROM network_devices ORDER BY status="online" DESC, COALESCE(alias,hostname,ip)'));
|
||
|
||
case 'network_scan':
|
||
// Queue shell command to PVE1 agent — it runs jarvis-netscan.sh and pushes results back
|
||
$pve1 = JarvisDB::single('SELECT agent_id FROM registered_agents WHERE ip_address="10.48.200.90" AND status="online" LIMIT 1');
|
||
if (!$pve1) {
|
||
$pve1 = JarvisDB::single('SELECT agent_id FROM registered_agents WHERE hostname LIKE "%pve%" AND status="online" LIMIT 1');
|
||
}
|
||
if ($pve1) {
|
||
JarvisDB::execute(
|
||
'INSERT INTO agent_commands (agent_id, command_type, command_data, status) VALUES (?,?,?,?)',
|
||
[$pve1['agent_id'], 'shell', json_encode(['command'=>'/usr/local/bin/jarvis-netscan.sh','allowed'=>true]), 'pending']
|
||
);
|
||
j(['ok' => true, 'queued' => true, 'note' => 'Scan command sent to PVE1 agent — results in ~40 seconds']);
|
||
} else {
|
||
j(['ok' => false, 'note' => 'PVE1 agent offline — scan will run automatically via cron in < 3 minutes']);
|
||
}
|
||
|
||
case 'network_save':
|
||
$id = (int)($_POST['id'] ?? 0);
|
||
$ip = trim($_POST['ip'] ?? ''); $alias = trim($_POST['alias'] ?? '');
|
||
$type = trim($_POST['device_type'] ?? 'device');
|
||
if (!$ip || !$alias) bad('IP and alias required');
|
||
if ($id) {
|
||
JarvisDB::execute('UPDATE network_devices SET ip=?,alias=?,device_type=? WHERE id=?', [$ip,$alias,$type,$id]);
|
||
} else {
|
||
JarvisDB::execute('INSERT INTO network_devices (ip,alias,device_type,status) VALUES (?,?,?,"unknown") ON DUPLICATE KEY UPDATE alias=?,device_type=?', [$ip,$alias,$type,$alias,$type]);
|
||
}
|
||
j(['ok' => true]);
|
||
|
||
case 'network_delete':
|
||
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
|
||
JarvisDB::execute('DELETE FROM network_devices WHERE id=?', [$id]);
|
||
j(['ok' => true]);
|
||
|
||
case 'network_ping':
|
||
$ip = trim($_POST['ip'] ?? ''); if (!$ip) bad('Missing IP');
|
||
$out = shell_exec('ping -c 2 -W 2 '.escapeshellarg($ip).' 2>/dev/null');
|
||
$alive = $out && (strpos($out,'2 received')!==false || strpos($out,'1 received')!==false);
|
||
$lat = null;
|
||
if ($alive && preg_match('/time=([\d.]+)/', $out, $m)) $lat = (float)$m[1];
|
||
j(['alive'=>$alive,'latency_ms'=>$lat]);
|
||
|
||
// ── ALERTS ───────────────────────────────────────────────────────────
|
||
case 'alerts_list':
|
||
$f = $_GET['filter'] ?? 'all';
|
||
$w = $f === 'active' ? 'WHERE resolved=0' : ($f === 'resolved' ? 'WHERE resolved=1' : '');
|
||
j(JarvisDB::query("SELECT id,alert_type,title,message,severity,resolved,created_at,resolved_at,source_key FROM alerts $w ORDER BY created_at DESC LIMIT 300"));
|
||
|
||
case 'alerts_resolve':
|
||
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
|
||
JarvisDB::execute('UPDATE alerts SET resolved=1,resolved_at=NOW() WHERE id=?', [$id]);
|
||
j(['ok' => true]);
|
||
|
||
case 'alerts_resolve_all':
|
||
JarvisDB::execute('UPDATE alerts SET resolved=1,resolved_at=NOW() WHERE resolved=0');
|
||
j(['ok' => true]);
|
||
|
||
case 'alerts_delete':
|
||
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
|
||
JarvisDB::execute('DELETE FROM alerts WHERE id=?', [$id]);
|
||
j(['ok' => true]);
|
||
|
||
case 'alerts_purge_resolved':
|
||
JarvisDB::execute('DELETE FROM alerts WHERE resolved=1');
|
||
j(['ok' => true]);
|
||
|
||
case 'alerts_save':
|
||
$id = (int)($_POST['id'] ?? 0);
|
||
$t = trim($_POST['title'] ?? ''); if (!$t) bad('Title required');
|
||
$typ = trim($_POST['alert_type'] ?? 'manual');
|
||
$msg = trim($_POST['message'] ?? '');
|
||
$sev = trim($_POST['severity'] ?? 'info');
|
||
if ($id) {
|
||
JarvisDB::execute('UPDATE alerts SET alert_type=?,title=?,message=?,severity=? WHERE id=?', [$typ,$t,$msg,$sev,$id]);
|
||
} else {
|
||
JarvisDB::execute('INSERT INTO alerts (alert_type,title,message,severity,resolved) VALUES (?,?,?,?,0)', [$typ,$t,$msg,$sev]);
|
||
}
|
||
j(['ok' => true]);
|
||
|
||
// ── KB FACTS ─────────────────────────────────────────────────────────
|
||
case 'facts_categories':
|
||
j(JarvisDB::query('SELECT category, COUNT(*) cnt FROM kb_facts GROUP BY category ORDER BY cnt DESC'));
|
||
|
||
case 'facts_list':
|
||
$cat = $_GET['category'] ?? '';
|
||
if ($cat === '__all__') {
|
||
j(JarvisDB::query('SELECT id,category,fact_key,fact_value,host,updated_at FROM kb_facts ORDER BY category,fact_key LIMIT 1000'));
|
||
}
|
||
j(JarvisDB::query('SELECT id,category,fact_key,fact_value,host,updated_at FROM kb_facts WHERE category=? ORDER BY fact_key', [$cat]));
|
||
|
||
case 'facts_save':
|
||
$id = (int)($_POST['id'] ?? 0);
|
||
$cat = trim($_POST['category'] ?? ''); $key = trim($_POST['fact_key'] ?? ''); $val = trim($_POST['fact_value'] ?? '');
|
||
if (!$cat||!$key) bad('Category and key required');
|
||
if ($id) {
|
||
JarvisDB::execute('UPDATE kb_facts SET category=?,fact_key=?,fact_value=? WHERE id=?', [$cat,$key,$val,$id]);
|
||
} else {
|
||
JarvisDB::execute('INSERT INTO kb_facts (category,fact_key,fact_value) VALUES (?,?,?)', [$cat,$key,$val]);
|
||
}
|
||
j(['ok' => true]);
|
||
|
||
case 'facts_delete':
|
||
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
|
||
JarvisDB::execute('DELETE FROM kb_facts WHERE id=?', [$id]);
|
||
j(['ok' => true]);
|
||
|
||
// ── KB INTENTS ───────────────────────────────────────────────────────
|
||
case 'intents_list':
|
||
j(JarvisDB::query('SELECT id,intent_name,pattern,response_template,action_type,priority,active FROM kb_intents ORDER BY priority DESC,intent_name'));
|
||
|
||
case 'intents_save':
|
||
$id = (int)($_POST['id'] ?? 0);
|
||
$name = trim($_POST['intent_name'] ?? ''); $pat = trim($_POST['pattern'] ?? '');
|
||
$resp = trim($_POST['response_template'] ?? '');
|
||
$typ = trim($_POST['action_type'] ?? 'response');
|
||
$pri = (int)($_POST['priority'] ?? 5); $act = (int)($_POST['active'] ?? 1);
|
||
if (!$name||!$pat) bad('Name and pattern required');
|
||
if ($id) {
|
||
JarvisDB::execute('UPDATE kb_intents SET intent_name=?,pattern=?,response_template=?,action_type=?,priority=?,active=? WHERE id=?', [$name,$pat,$resp,$typ,$pri,$act,$id]);
|
||
} else {
|
||
JarvisDB::execute('INSERT INTO kb_intents (intent_name,pattern,response_template,action_type,priority,active) VALUES (?,?,?,?,?,?)', [$name,$pat,$resp,$typ,$pri,$act]);
|
||
}
|
||
j(['ok' => true]);
|
||
|
||
case 'intents_delete':
|
||
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
|
||
JarvisDB::execute('DELETE FROM kb_intents WHERE id=?', [$id]);
|
||
j(['ok' => true]);
|
||
|
||
case 'intents_toggle':
|
||
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
|
||
JarvisDB::execute('UPDATE kb_intents SET active=NOT active WHERE id=?', [$id]);
|
||
j(['ok' => true]);
|
||
|
||
// ── SITES ────────────────────────────────────────────────────────────
|
||
case 'sites_list':
|
||
j(JarvisDB::query("SELECT fact_key,fact_value,updated_at FROM kb_facts WHERE category='sites' ORDER BY fact_key"));
|
||
|
||
// ── HOME ASSISTANT ENTITIES ───────────────────────────────────────────
|
||
case 'ha_list':
|
||
// Read from ha_entities table (real-time pushes from jarvis_agent custom component)
|
||
$domain = $_GET['domain'] ?? '';
|
||
$search = strtolower(trim($_GET['search'] ?? ''));
|
||
$skipDomains = ['sensor','binary_sensor','button','update','select','number',
|
||
'device_tracker','event','image','person','zone','tts','conversation',
|
||
'assist_satellite','input_button'];
|
||
$skipKeywords = ['pre_release','_record','_ftp_','_push_','_hub_ringtone',
|
||
'_siren_on','_email_on','_manual_record','_infrared_',
|
||
'do_not_disturb','matter_server','zerotier','mariadb',
|
||
'spotify_connect','file_editor','ssh_web','uptime_kuma',
|
||
'folding_home','music_assistant','get_hacs','mealie',
|
||
'mosquitto','social_to','esphome_device','motion_detection',
|
||
'front_yard_record','down_hill_record','camera1_record',
|
||
'back_yard_record','nvr_','assist_microphone','cec_scanner'];
|
||
$where = "state NOT IN ('unavailable','unknown')";
|
||
$params = [];
|
||
if ($domain) { $where .= " AND domain=?"; $params[] = $domain; }
|
||
$rows = JarvisDB::query(
|
||
"SELECT entity_id, entity_name name, domain, state, updated_at
|
||
FROM ha_entities WHERE $where ORDER BY domain, entity_name LIMIT 500",
|
||
$params
|
||
) ?? [];
|
||
$all = []; $domains = [];
|
||
foreach ($rows as $e) {
|
||
$dom = $e['domain'];
|
||
if (in_array($dom, $skipDomains)) continue;
|
||
$skip = false;
|
||
if ($dom === 'switch') {
|
||
foreach ($skipKeywords as $kw) {
|
||
if (strpos($e['entity_id'], $kw) !== false) { $skip = true; break; }
|
||
}
|
||
}
|
||
if ($skip) continue;
|
||
if ($search && strpos(strtolower($e['name']??''), $search) === false) continue;
|
||
$all[] = $e;
|
||
$domains[$dom] = true;
|
||
}
|
||
j(['entities'=>$all,'domains'=>array_keys($domains),'total'=>count($all),'ts'=>time()]);
|
||
|
||
case 'ha_toggle':
|
||
$eid = trim($_POST['entity_id'] ?? ''); if (!$eid) bad('Missing entity_id');
|
||
$state = trim($_POST['state'] ?? '');
|
||
if (!defined('HA_URL')||!defined('HA_TOKEN')) bad('HA not configured');
|
||
$domain = explode('.',$eid)[0];
|
||
$svc = match($domain) {
|
||
'light','switch','input_boolean','fan' => ($state==='on'?'turn_off':'turn_on'),
|
||
default => ($state==='on'?'turn_off':'turn_on')
|
||
};
|
||
$ch = curl_init(HA_URL.'/api/services/'.$domain.'/'.$svc);
|
||
curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_POST=>true,
|
||
CURLOPT_HTTPHEADER=>['Authorization: Bearer '.HA_TOKEN,'Content-Type: application/json'],
|
||
CURLOPT_POSTFIELDS=>json_encode(['entity_id'=>$eid]),CURLOPT_TIMEOUT=>8]);
|
||
$res = curl_exec($ch); $code = curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch);
|
||
j(['ok'=>$code<300,'code'=>$code]);
|
||
|
||
// ── NEWS ─────────────────────────────────────────────────────────────
|
||
case 'news_list':
|
||
$cached = JarvisDB::single("SELECT data,UNIX_TIMESTAMP(updated_at) ts FROM api_cache WHERE cache_key='news'");
|
||
$news = $cached ? (json_decode($cached['data'],true)??[]) : [];
|
||
$custom = JarvisDB::query("SELECT id,fact_key title,fact_value url,updated_at FROM kb_facts WHERE category='custom_news' ORDER BY id DESC");
|
||
j(['news'=>$news,'custom'=>$custom,'cache_age'=>$cached?time()-(int)$cached['ts']:null]);
|
||
|
||
case 'news_custom_save':
|
||
$id = (int)($_POST['id']??0);
|
||
$t = trim($_POST['title']??''); if(!$t) bad('Title required');
|
||
$url = trim($_POST['url']??'');
|
||
if($id) {
|
||
JarvisDB::execute('UPDATE kb_facts SET fact_key=?,fact_value=? WHERE id=? AND category="custom_news"',[$t,$url,$id]);
|
||
} else {
|
||
JarvisDB::execute('INSERT INTO kb_facts (category,fact_key,fact_value) VALUES ("custom_news",?,?)',[$t,$url]);
|
||
}
|
||
j(['ok'=>true]);
|
||
|
||
case 'news_custom_delete':
|
||
$id=(int)($_POST['id']??0); if(!$id) bad('Missing id');
|
||
JarvisDB::execute('DELETE FROM kb_facts WHERE id=? AND category="custom_news"',[$id]);
|
||
j(['ok'=>true]);
|
||
|
||
// ── PROXMOX VMs ───────────────────────────────────────────────────────
|
||
case 'vms_list':
|
||
$raw = JarvisDB::single("SELECT data,UNIX_TIMESTAMP(updated_at) ts FROM api_cache WHERE cache_key='proxmox'");
|
||
if (!$raw) j(['vms'=>[],'containers'=>[],'node_info'=>[],'ts'=>null]);
|
||
$pve = json_decode($raw['data'],true) ?? [];
|
||
j(['vms'=>$pve['vms']??[],'containers'=>$pve['containers']??[],'node_info'=>$pve['node_info']??[],'node_status'=>$pve['node_status']??null,'ts'=>$raw['ts']]);
|
||
|
||
// ── USERS ────────────────────────────────────────────────────────────
|
||
case 'email_inbox':
|
||
// Call via server's own IP — REMOTE_ADDR matches JARVIS_IP so auth bypass applies
|
||
$acct = $_GET['account'] ?? 'all';
|
||
$force = !empty($_GET['force']) ? '&force=1' : '';
|
||
$ch = curl_init('https://165.22.1.228/api/email?account=' . $acct . $force);
|
||
curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>25,
|
||
CURLOPT_SSL_VERIFYPEER=>false,CURLOPT_SSL_VERIFYHOST=>false,
|
||
CURLOPT_HTTPHEADER=>['Host: jarvis.orbishosting.com']]);
|
||
$r = curl_exec($ch); $code = curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch);
|
||
if($code===200 && $r) j(json_decode($r,true));
|
||
else j(['error'=>'Email fetch failed (HTTP '.$code.')']);
|
||
|
||
case 'email_action_items':
|
||
$rows = JarvisDB::query("SELECT * FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL ORDER BY received_at DESC LIMIT 100") ?? [];
|
||
j(['action_items'=>$rows]);
|
||
|
||
case 'email_create_task':
|
||
$id=(int)($_POST['id']??0); if(!$id) bad('No id');
|
||
$ea=JarvisDB::single('SELECT * FROM email_actions WHERE id=?',[$id]); if(!$ea) bad('Not found');
|
||
$title=trim($_POST['title']??$ea['suggested_title']);
|
||
$due=trim($_POST['due_date']??$ea['suggested_date']??'');
|
||
$notes="From: {$ea['from_name']} <{$ea['from_email']}>\nSubject: {$ea['subject']}";
|
||
$tid=JarvisDB::insert('INSERT INTO tasks(title,notes,category,priority,due_date)VALUES(?,?,?,?,?)',
|
||
[$title,$notes,'work','normal',$due?:null]);
|
||
JarvisDB::execute('UPDATE email_actions SET task_id=?,dismissed=1 WHERE id=?',[$tid,$id]);
|
||
j(['ok'=>true,'task_id'=>$tid]);
|
||
|
||
case 'email_create_appt':
|
||
$id=(int)($_POST['id']??0); if(!$id) bad('No id');
|
||
$ea=JarvisDB::single('SELECT * FROM email_actions WHERE id=?',[$id]); if(!$ea) bad('Not found');
|
||
$title=trim($_POST['title']??$ea['suggested_title']);
|
||
$start=trim($_POST['start_at']??'');
|
||
if(!$start) $start=($ea['suggested_date']??date('Y-m-d')).' 09:00:00';
|
||
$aid=JarvisDB::insert('INSERT INTO appointments(title,description,category,start_at)VALUES(?,?,?,?)',
|
||
[$title,"From: {$ea['from_name']} <{$ea['from_email']}>",'work',$start]);
|
||
JarvisDB::execute('UPDATE email_actions SET appointment_id=?,dismissed=1 WHERE id=?',[$aid,$id]);
|
||
j(['ok'=>true,'appointment_id'=>$aid]);
|
||
|
||
case 'email_dismiss':
|
||
$id=(int)($_POST['id']??0);
|
||
if($id) JarvisDB::execute('UPDATE email_actions SET dismissed=1 WHERE id=?',[$id]);
|
||
j(['ok'=>true]);
|
||
|
||
case 'task_list':
|
||
$status = trim($_GET['status'] ?? '');
|
||
$category = trim($_GET['category'] ?? '');
|
||
$where = '1=1'; $params = [];
|
||
if ($status) { $where .= ' AND status=?'; $params[] = $status; }
|
||
if ($category) { $where .= ' AND category=?'; $params[] = $category; }
|
||
else if (!$status) { $where .= " AND status NOT IN ('done','cancelled')"; }
|
||
$rows = JarvisDB::query("SELECT * FROM tasks WHERE {$where} ORDER BY FIELD(priority,'urgent','high','normal','low'),due_date ASC,created_at DESC LIMIT 200",$params) ?? [];
|
||
j(['tasks'=>$rows]);
|
||
|
||
case 'task_save':
|
||
$id=$_POST['id']??0; $title=trim($_POST['title']??'');
|
||
$notes=trim($_POST['notes']??''); $cat=$_POST['category']??'personal';
|
||
$pri=$_POST['priority']??'normal'; $stat=$_POST['status']??'pending';
|
||
$due=!empty($_POST['due_date'])?$_POST['due_date']:null;
|
||
$dtime=!empty($_POST['due_time'])?$_POST['due_time']:null;
|
||
if(!$title) bad('Title required');
|
||
if($id){
|
||
JarvisDB::execute('UPDATE tasks SET title=?,notes=?,category=?,priority=?,status=?,due_date=?,due_time=?,updated_at=NOW() WHERE id=?',[$title,$notes,$cat,$pri,$stat,$due,$dtime,$id]);
|
||
j(['ok'=>true,'id'=>(int)$id]);
|
||
} else {
|
||
$newId=JarvisDB::insert('INSERT INTO tasks(title,notes,category,priority,status,due_date,due_time)VALUES(?,?,?,?,?,?,?)',[$title,$notes,$cat,$pri,$stat,$due,$dtime]);
|
||
j(['ok'=>true,'id'=>$newId]);
|
||
}
|
||
|
||
case 'task_done':
|
||
$id=(int)($_POST['id']??0); if(!$id) bad('No id');
|
||
JarvisDB::execute("UPDATE tasks SET status='done',completed_at=NOW() WHERE id=?",[$id]);
|
||
j(['ok'=>true]);
|
||
|
||
case 'task_delete':
|
||
$id=(int)($_POST['id']??0); if(!$id) bad('No id');
|
||
JarvisDB::execute('DELETE FROM tasks WHERE id=?',[$id]);
|
||
j(['ok'=>true]);
|
||
|
||
case 'appt_list':
|
||
$from=$_GET['from']??date('Y-m-d'); $to=$_GET['to']??date('Y-m-d',strtotime('+90 days'));
|
||
$rows=JarvisDB::query("SELECT * FROM appointments WHERE DATE(start_at) BETWEEN ? AND ? ORDER BY start_at ASC LIMIT 200",[$from,$to]) ?? [];
|
||
j(['appointments'=>$rows]);
|
||
|
||
case 'appt_save':
|
||
$id=$_POST['id']??0; $title=trim($_POST['title']??''); $desc=trim($_POST['description']??'');
|
||
$cat=$_POST['category']??'personal'; $loc=trim($_POST['location']??'');
|
||
$all_day=(int)($_POST['all_day']??0); $rem=(int)($_POST['reminder_min']??30);
|
||
$start=trim($_POST['start_at']??''); $end=trim($_POST['end_at']??'');
|
||
if(!$title||!$start) bad('Title and start required');
|
||
$ts=strtotime($start); if(!$ts) bad('Invalid start datetime');
|
||
$startDt=date('Y-m-d H:i:s',$ts);
|
||
$endDt=($end&&strtotime($end))?date('Y-m-d H:i:s',strtotime($end)):null;
|
||
if($id){
|
||
JarvisDB::execute('UPDATE appointments SET title=?,description=?,category=?,start_at=?,end_at=?,location=?,all_day=?,reminder_min=?,alerted=0,updated_at=NOW() WHERE id=?',[$title,$desc,$cat,$startDt,$endDt,$loc,$all_day,$rem,$id]);
|
||
j(['ok'=>true,'id'=>(int)$id]);
|
||
} else {
|
||
$newId=JarvisDB::insert('INSERT INTO appointments(title,description,category,start_at,end_at,location,all_day,reminder_min)VALUES(?,?,?,?,?,?,?,?)',[$title,$desc,$cat,$startDt,$endDt,$loc,$all_day,$rem]);
|
||
j(['ok'=>true,'id'=>$newId]);
|
||
}
|
||
|
||
case 'appt_delete':
|
||
$id=(int)($_POST['id']??0); if(!$id) bad('No id');
|
||
JarvisDB::execute('DELETE FROM appointments WHERE id=?',[$id]);
|
||
j(['ok'=>true]);
|
||
|
||
|
||
case 'cal_feeds_list':
|
||
j(JarvisDB::query("SELECT * FROM calendar_feeds ORDER BY source,name") ?? []);
|
||
|
||
case 'cal_feed_save':
|
||
$id = (int)($_POST['id'] ?? 0);
|
||
$name = trim($_POST['name'] ?? '');
|
||
$source = $_POST['source'] ?? 'ics';
|
||
$ics = trim($_POST['ics_url'] ?? '');
|
||
$user = trim($_POST['username'] ?? '');
|
||
$pass = trim($_POST['password'] ?? '');
|
||
$active = (int)($_POST['active'] ?? 1);
|
||
if (!$name) bad('Name required');
|
||
if ($id) {
|
||
JarvisDB::execute("UPDATE calendar_feeds SET name=?,source=?,ics_url=?,username=?,password=?,active=? WHERE id=?",
|
||
[$name,$source,$ics,$user,$pass,$active,$id]);
|
||
j(['ok'=>true,'id'=>$id]);
|
||
} else {
|
||
$nid = JarvisDB::insert("INSERT INTO calendar_feeds(name,source,ics_url,username,password,active) VALUES(?,?,?,?,?,?)",
|
||
[$name,$source,$ics,$user,$pass,$active]);
|
||
j(['ok'=>true,'id'=>$nid]);
|
||
}
|
||
|
||
case 'cal_feed_delete':
|
||
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('No id');
|
||
JarvisDB::execute("DELETE FROM calendar_feeds WHERE id=?", [$id]);
|
||
j(['ok'=>true]);
|
||
|
||
case 'cal_sync_now':
|
||
if (!class_exists('JarvisDB')) require_once __DIR__ . '/../../api/lib/db.php';
|
||
require_once __DIR__ . '/../../api/endpoints/calendar_sync.php';
|
||
$r = runSync();
|
||
j(['ok'=>true,'results'=>$r]);
|
||
|
||
|
||
// ── ARC REACTOR ──────────────────────────────────────────────────────
|
||
case 'arc_status':
|
||
$ch = curl_init('http://127.0.0.1:7474/status');
|
||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CONNECTTIMEOUT=>3]);
|
||
$raw = curl_exec($ch); $err = curl_error($ch); curl_close($ch);
|
||
if ($err || !$raw) j(['online'=>false, 'error'=>$err ?: 'unreachable']);
|
||
j(json_decode($raw, true) ?: ['online'=>false, 'error'=>'bad response']);
|
||
|
||
case 'arc_jobs':
|
||
$status = $_GET['status'] ?? '';
|
||
$limit = (int)($_GET['limit'] ?? 100);
|
||
$url = 'http://127.0.0.1:7474/jobs?' . http_build_query(array_filter(['status'=>$status,'limit'=>$limit]));
|
||
$ch = curl_init($url);
|
||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]);
|
||
$raw = curl_exec($ch); curl_close($ch);
|
||
j(json_decode($raw, true) ?: []);
|
||
|
||
case 'arc_job_get':
|
||
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
|
||
$ch = curl_init('http://127.0.0.1:7474/job/' . $id);
|
||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]);
|
||
$raw = curl_exec($ch); curl_close($ch);
|
||
j(json_decode($raw, true) ?: ['error'=>'not found']);
|
||
|
||
case 'arc_ping':
|
||
$ch = curl_init('http://127.0.0.1:7474/job');
|
||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_POST=>true,
|
||
CURLOPT_POSTFIELDS=>json_encode(['type'=>'ping','payload'=>[],'priority'=>9,'created_by'=>'admin']),
|
||
CURLOPT_HTTPHEADER=>['Content-Type: application/json']]);
|
||
$raw = curl_exec($ch); curl_close($ch);
|
||
j(json_decode($raw, true) ?: ['error'=>'failed']);
|
||
|
||
case 'arc_purge':
|
||
$ch = curl_init('http://127.0.0.1:7474/jobs/purge');
|
||
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CUSTOMREQUEST=>'DELETE']);
|
||
$raw = curl_exec($ch); curl_close($ch);
|
||
j(json_decode($raw, true) ?: ['ok'=>true]);
|
||
|
||
// ── GMAIL TRIAGE ──────────────────────────────────────────────────────
|
||
case 'triage_list':
|
||
$limit = min((int)($_GET['limit'] ?? 100), 200);
|
||
$filter = $_GET['filter'] ?? 'priority';
|
||
if ($filter === 'urgent') {
|
||
$rows = JarvisDB::query(
|
||
"SELECT * FROM email_triage WHERE action_taken != 'dismissed' AND category = 'urgent' ORDER BY priority DESC, created_at DESC LIMIT ?",
|
||
[$limit]
|
||
);
|
||
} elseif ($filter === 'action') {
|
||
$rows = JarvisDB::query(
|
||
"SELECT * FROM email_triage WHERE action_taken != 'dismissed' AND category IN ('urgent','action','reply','meeting') ORDER BY priority DESC, created_at DESC LIMIT ?",
|
||
[$limit]
|
||
);
|
||
} elseif ($filter === 'priority') {
|
||
$rows = JarvisDB::query(
|
||
"SELECT * FROM email_triage WHERE action_taken != 'dismissed' AND category IN ('urgent','action','reply','meeting') AND priority >= 5 ORDER BY priority DESC, created_at DESC LIMIT ?",
|
||
[$limit]
|
||
);
|
||
} else {
|
||
$rows = JarvisDB::query(
|
||
"SELECT * FROM email_triage ORDER BY priority DESC, created_at DESC LIMIT ?",
|
||
[$limit]
|
||
);
|
||
}
|
||
$counts = JarvisDB::single("SELECT COUNT(*) AS total,
|
||
SUM(category='urgent') AS urgent, SUM(category='action') AS action,
|
||
SUM(category='reply') AS reply, SUM(category='meeting') AS meeting,
|
||
SUM(action_taken='none') AS pending
|
||
FROM email_triage WHERE action_taken != 'dismissed'");
|
||
j(['items' => $rows ?: [], 'counts' => $counts]);
|
||
|
||
case 'triage_action':
|
||
$id = (int)($_GET['id'] ?? $_POST['id'] ?? 0); if (!$id) bad('Missing id');
|
||
$act = $_POST['action'] ?? $_GET['action_val'] ?? 'dismissed';
|
||
$allowed = ['dismissed','replied','done','snoozed'];
|
||
if (!in_array($act, $allowed)) bad('Invalid action');
|
||
JarvisDB::execute("UPDATE email_triage SET action_taken = ? WHERE id = ?", [$act, $id]);
|
||
j(['ok' => true]);
|
||
|
||
case 'triage_run':
|
||
$account = $_GET['account'] ?? 'gmail';
|
||
$maxEmails = (int)($_GET['max'] ?? 25);
|
||
$ch = curl_init('http://127.0.0.1:7474/job');
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_POST => true,
|
||
CURLOPT_POSTFIELDS => json_encode(['type'=>'gmail_triage','payload'=>['account'=>$account,'max_emails'=>$maxEmails,'provider'=>'claude'],'priority'=>7,'created_by'=>'admin']),
|
||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||
]);
|
||
$raw = curl_exec($ch); curl_close($ch);
|
||
j(json_decode($raw, true) ?: ['error' => 'Arc Reactor unreachable']);
|
||
|
||
case 'users_list':
|
||
j(JarvisDB::query('SELECT id,username,display_name,last_seen,created_at FROM users ORDER BY username'));
|
||
|
||
case 'users_save':
|
||
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
|
||
$dn = trim($_POST['display_name'] ?? '');
|
||
$pw = trim($_POST['password'] ?? '');
|
||
if ($pw) {
|
||
JarvisDB::execute('UPDATE users SET display_name=?,password_hash=? WHERE id=?', [$dn, password_hash($pw, PASSWORD_BCRYPT), $id]);
|
||
} else {
|
||
JarvisDB::execute('UPDATE users SET display_name=? WHERE id=?', [$dn, $id]);
|
||
}
|
||
j(['ok' => true]);
|
||
|
||
// ── BACKUPS ───────────────────────────────────────────────────────────
|
||
case 'backups_list':
|
||
$dir = '/var/backups/jarvis';
|
||
$lock = "$dir/backup.lock";
|
||
$log = "$dir/backup.log";
|
||
$running = file_exists($lock) && (time() - filemtime($lock)) < 3600;
|
||
$files = [];
|
||
foreach (glob("$dir/jarvis_backup_*.tar.gz") ?: [] as $f) {
|
||
$files[] = [
|
||
'file' => basename($f),
|
||
'size' => filesize($f),
|
||
'size_mb' => round(filesize($f)/1048576, 1),
|
||
'date' => date('Y-m-d H:i:s', filemtime($f)),
|
||
];
|
||
}
|
||
usort($files, fn($a,$b) => strcmp($b['date'], $a['date']));
|
||
$lastLog = $log && file_exists($log) ? trim(shell_exec("tail -3 " . escapeshellarg($log))) : '';
|
||
j(['running' => $running, 'files' => $files, 'last_log' => $lastLog]);
|
||
|
||
case 'backup_trigger':
|
||
$lock = '/var/backups/jarvis/backup.lock';
|
||
if (file_exists($lock) && (time() - filemtime($lock)) < 3600) {
|
||
j(['ok' => false, 'message' => 'Backup already running']);
|
||
}
|
||
shell_exec('nohup /usr/local/bin/jarvis-backup.sh > /dev/null 2>&1 &');
|
||
sleep(1);
|
||
j(['ok' => true, 'message' => 'Backup started']);
|
||
|
||
case 'backup_download':
|
||
$file = basename($_GET['file'] ?? '');
|
||
if (!preg_match('/^jarvis_backup_[\d_-]+\.tar\.gz$/', $file)) bad('Invalid filename');
|
||
$path = '/var/backups/jarvis/' . $file;
|
||
if (!file_exists($path)) bad('File not found', 404);
|
||
header('Content-Type: application/gzip');
|
||
header('Content-Disposition: attachment; filename="' . $file . '"');
|
||
header('Content-Length: ' . filesize($path));
|
||
header('X-Accel-Buffering: no');
|
||
ob_end_clean();
|
||
readfile($path);
|
||
exit;
|
||
|
||
default: bad('Unknown action');
|
||
}
|
||
}
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>JARVIS ADMIN</title>
|
||
<style>
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
:root{
|
||
--bg:#080b0f;--surface:#0d1117;--panel:#111820;--border:#1a2535;--border2:#243040;
|
||
--cyan:#00d4ff;--green:#39ff14;--red:#ff3333;--yellow:#ffcc00;--orange:#ff8800;
|
||
--text:#c8d8e8;--dim:#4a6080;--font:'Share Tech Mono',monospace;
|
||
}
|
||
body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:13px;min-height:100vh;display:flex;flex-direction:column}
|
||
a{color:var(--cyan);text-decoration:none}
|
||
|
||
/* ── LOGIN ── */
|
||
#loginWrap{display:flex;align-items:center;justify-content:center;min-height:100vh;flex-direction:column;gap:24px}
|
||
#loginBox{background:var(--surface);border:1px solid var(--border2);padding:36px 40px;width:360px}
|
||
#loginBox h1{color:var(--cyan);font-size:1.4rem;letter-spacing:4px;margin-bottom:6px;text-align:center}
|
||
#loginBox p{color:var(--dim);font-size:0.7rem;letter-spacing:2px;text-align:center;margin-bottom:28px}
|
||
.field{margin-bottom:16px}
|
||
.field label{display:block;color:var(--dim);font-size:0.65rem;letter-spacing:2px;margin-bottom:6px}
|
||
.field input{width:100%;background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:10px 12px;font-family:var(--font);font-size:13px;outline:none}
|
||
.field input:focus{border-color:var(--cyan)}
|
||
#loginErr{color:var(--red);font-size:0.7rem;text-align:center;min-height:16px;margin-bottom:8px}
|
||
.btn{background:transparent;border:1px solid var(--cyan);color:var(--cyan);padding:10px 20px;font-family:var(--font);font-size:0.75rem;letter-spacing:2px;cursor:pointer;transition:all .15s}
|
||
.btn:hover{background:var(--cyan);color:#000}
|
||
.btn-red{border-color:var(--red);color:var(--red)} .btn-red:hover{background:var(--red);color:#fff}
|
||
.btn-green{border-color:var(--green);color:var(--green)} .btn-green:hover{background:var(--green);color:#000}
|
||
.btn-yellow{border-color:var(--yellow);color:var(--yellow)} .btn-yellow:hover{background:var(--yellow);color:#000}
|
||
.btn-sm{padding:4px 10px;font-size:0.65rem;letter-spacing:1px}
|
||
.btn-xs{padding:2px 7px;font-size:0.6rem;letter-spacing:1px}
|
||
.btn-full{width:100%;display:block;text-align:center}
|
||
|
||
/* ── LAYOUT ── */
|
||
#app{display:none;flex:1;flex-direction:column}
|
||
#topbar{background:var(--surface);border-bottom:1px solid var(--border2);padding:10px 20px;display:flex;align-items:center;justify-content:space-between}
|
||
#topbar .logo{color:var(--cyan);font-size:1rem;letter-spacing:5px}
|
||
#topbar .sub{color:var(--dim);font-size:0.6rem;letter-spacing:3px;margin-left:12px}
|
||
#topbar .right{display:flex;align-items:center;gap:16px}
|
||
#topbar .user{color:var(--dim);font-size:0.65rem;letter-spacing:1px}
|
||
#main{display:flex;flex:1;overflow:hidden}
|
||
|
||
/* ── SIDEBAR ── */
|
||
#sidebar{width:180px;background:var(--surface);border-right:1px solid var(--border2);padding:16px 0;flex-shrink:0}
|
||
.nav-item{display:block;padding:10px 20px;color:var(--dim);font-size:0.7rem;letter-spacing:2px;cursor:pointer;border-left:2px solid transparent;transition:all .15s}
|
||
.nav-item:hover{color:var(--text);background:var(--panel)}
|
||
.nav-item.active{color:var(--cyan);border-left-color:var(--cyan);background:var(--panel)}
|
||
.nav-section{padding:16px 20px 6px;color:var(--border2);font-size:0.55rem;letter-spacing:3px}
|
||
|
||
/* ── CONTENT ── */
|
||
#content{flex:1;overflow-y:auto;padding:24px}
|
||
.tab{display:none}.tab.active{display:block}
|
||
.page-title{color:var(--cyan);font-size:0.9rem;letter-spacing:4px;margin-bottom:20px;border-bottom:1px solid var(--border);padding-bottom:10px;display:flex;align-items:center;justify-content:space-between}
|
||
.page-title .actions{display:flex;gap:8px}
|
||
|
||
/* ── STAT CARDS ── */
|
||
.stat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;margin-bottom:24px}
|
||
.stat-card{background:var(--surface);border:1px solid var(--border);padding:16px}
|
||
.stat-card .lbl{color:var(--dim);font-size:0.6rem;letter-spacing:2px;margin-bottom:8px}
|
||
.stat-card .val{font-size:1.6rem;color:var(--cyan)}
|
||
.stat-card .sub{color:var(--dim);font-size:0.65rem;margin-top:4px}
|
||
.stat-card .ok{color:var(--green)}.stat-card .warn{color:var(--yellow)}.stat-card .danger{color:var(--red)}
|
||
|
||
/* ── TABLE ── */
|
||
.tbl-wrap{background:var(--surface);border:1px solid var(--border);overflow-x:auto}
|
||
table{width:100%;border-collapse:collapse}
|
||
th{color:var(--dim);font-size:0.6rem;letter-spacing:2px;padding:10px 12px;text-align:left;border-bottom:1px solid var(--border2);white-space:nowrap}
|
||
td{padding:9px 12px;border-bottom:1px solid var(--border);font-size:0.75rem;vertical-align:middle}
|
||
tr:last-child td{border-bottom:none}
|
||
tr:hover td{background:rgba(0,212,255,0.03)}
|
||
.badge{display:inline-block;padding:2px 8px;font-size:0.6rem;letter-spacing:1px}
|
||
.badge-green{background:rgba(57,255,20,0.1);color:var(--green);border:1px solid rgba(57,255,20,0.3)}
|
||
.badge-red{background:rgba(255,51,51,0.1);color:var(--red);border:1px solid rgba(255,51,51,0.3)}
|
||
.badge-yellow{background:rgba(255,204,0,0.1);color:var(--yellow);border:1px solid rgba(255,204,0,0.3)}
|
||
.badge-cyan{background:rgba(0,212,255,0.1);color:var(--cyan);border:1px solid rgba(0,212,255,0.3)}
|
||
.badge-dim{background:rgba(74,96,128,0.1);color:var(--dim);border:1px solid rgba(74,96,128,0.3)}
|
||
.actions-col{white-space:nowrap;display:flex;gap:4px;flex-wrap:wrap}
|
||
.dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:6px}
|
||
.dot-green{background:var(--green);box-shadow:0 0 6px var(--green)}
|
||
.dot-red{background:var(--red)}
|
||
.dot-dim{background:var(--dim)}
|
||
|
||
/* ── MODAL ── */
|
||
#modalBg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:1000;align-items:center;justify-content:center}
|
||
#modalBg.open{display:flex}
|
||
#modal{background:var(--surface);border:1px solid var(--border2);width:500px;max-width:95vw;max-height:90vh;display:flex;flex-direction:column}
|
||
#modalHead{padding:16px 20px;border-bottom:1px solid var(--border2);display:flex;justify-content:space-between;align-items:center}
|
||
#modalHead h3{color:var(--cyan);font-size:0.8rem;letter-spacing:3px}
|
||
#modalClose{background:none;border:none;color:var(--dim);cursor:pointer;font-size:1.2rem}
|
||
#modalClose:hover{color:var(--red)}
|
||
#modalBody{padding:20px;overflow-y:auto;flex:1}
|
||
#modalFoot{padding:12px 20px;border-top:1px solid var(--border2);display:flex;justify-content:flex-end;gap:8px}
|
||
.form-row{margin-bottom:14px}
|
||
.form-row label{display:block;color:var(--dim);font-size:0.6rem;letter-spacing:2px;margin-bottom:5px}
|
||
.form-row input,.form-row select,.form-row textarea{width:100%;background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:8px 10px;font-family:var(--font);font-size:12px;outline:none;resize:vertical}
|
||
.form-row input:focus,.form-row select:focus,.form-row textarea:focus{border-color:var(--cyan)}
|
||
.form-row textarea{min-height:80px}
|
||
|
||
/* ── FILTERS ── */
|
||
.filters{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap;align-items:center}
|
||
.filters .lbl{color:var(--dim);font-size:0.6rem;letter-spacing:2px;margin-right:4px}
|
||
.filter-btn{background:none;border:1px solid var(--border2);color:var(--dim);padding:4px 12px;font-family:var(--font);font-size:0.65rem;letter-spacing:1px;cursor:pointer}
|
||
.filter-btn.active,.filter-btn:hover{border-color:var(--cyan);color:var(--cyan)}
|
||
select.filter-sel{background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:4px 8px;font-family:var(--font);font-size:0.65rem;outline:none}
|
||
select.filter-sel:focus{border-color:var(--cyan)}
|
||
|
||
/* ── TOAST ── */
|
||
#toast{position:fixed;bottom:24px;right:24px;z-index:2000;display:flex;flex-direction:column;gap:8px}
|
||
.toast-msg{background:var(--panel);border:1px solid var(--border2);padding:10px 16px;font-size:0.7rem;letter-spacing:1px;animation:slideIn .2s ease;border-left:3px solid var(--cyan)}
|
||
.toast-msg.err{border-left-color:var(--red);color:var(--red)}
|
||
.toast-msg.ok{border-left-color:var(--green);color:var(--green)}
|
||
@keyframes slideIn{from{transform:translateX(40px);opacity:0}to{transform:translateX(0);opacity:1}}
|
||
|
||
/* ── MISC ── */
|
||
.empty{color:var(--dim);font-size:0.7rem;letter-spacing:1px;padding:30px;text-align:center}
|
||
@keyframes agentIn{from{opacity:0;transform:translateX(-12px)}to{opacity:1;transform:translateX(0)}}
|
||
.agent-row{animation:agentIn .18s ease forwards;opacity:0}
|
||
.loading{color:var(--dim);font-size:0.7rem;letter-spacing:2px;padding:30px;text-align:center;animation:pulse 1s infinite}
|
||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
||
.meter{height:4px;background:var(--border);margin-top:4px;position:relative}
|
||
.meter-bar{height:100%;background:var(--cyan);transition:width .3s}
|
||
.meter-bar.warn{background:var(--yellow)}.meter-bar.danger{background:var(--red)}
|
||
.ts{color:var(--dim);font-size:0.65rem}
|
||
.monospace{font-family:var(--font)}
|
||
.trunc{max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- LOGIN -->
|
||
<div id="loginWrap">
|
||
<div id="loginBox">
|
||
<h1>JARVIS</h1>
|
||
<p>ADMIN PORTAL</p>
|
||
<div class="field"><label>USERNAME</label><input id="lu" type="text" autofocus></div>
|
||
<div class="field"><label>PASSWORD</label><input id="lp" type="password"></div>
|
||
<div id="loginErr"></div>
|
||
<button class="btn btn-full" onclick="doLogin()">AUTHENTICATE</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- APP -->
|
||
<div id="app">
|
||
<div id="topbar">
|
||
<div style="display:flex;align-items:center">
|
||
<span class="logo">JARVIS</span>
|
||
<span class="sub">ADMIN PORTAL</span>
|
||
</div>
|
||
<div class="right">
|
||
<span class="user" id="adminUser"></span>
|
||
<button class="btn btn-sm btn-red" onclick="doLogout()">LOGOUT</button>
|
||
</div>
|
||
</div>
|
||
<div id="main">
|
||
<div id="sidebar">
|
||
<div class="nav-section">OVERVIEW</div>
|
||
<div class="nav-item active" data-tab="dashboard" onclick="nav(this)">DASHBOARD</div>
|
||
<div class="nav-item" data-tab="backups" onclick="nav(this)">💾 BACKUPS</div>
|
||
<div class="nav-section">MANAGE</div>
|
||
<div class="nav-item" data-tab="agents" onclick="nav(this)">AGENTS</div>
|
||
<div class="nav-item" data-tab="network" onclick="nav(this)">NETWORK</div>
|
||
<div class="nav-item" data-tab="alerts" onclick="nav(this)">ALERTS</div>
|
||
<div class="nav-section">KNOWLEDGE</div>
|
||
<div class="nav-item" data-tab="facts" onclick="nav(this)">KB FACTS</div>
|
||
<div class="nav-item" data-tab="intents" onclick="nav(this)">KB INTENTS</div>
|
||
<div class="nav-section">LIVE</div>
|
||
<div class="nav-item" data-tab="ha" onclick="nav(this)">HOME ASSISTANT</div>
|
||
<div class="nav-item" data-tab="news" onclick="nav(this)">NEWS</div>
|
||
<div class="nav-item" data-tab="vms" onclick="nav(this)">PROXMOX VMs</div>
|
||
<div class="nav-section">COMMUNICATIONS</div>
|
||
<div class="nav-item" data-tab="email" onclick="nav(this)">📧 EMAIL</div>
|
||
<div class="nav-item" data-tab="triage" onclick="nav(this)">◈ GMAIL TRIAGE</div>
|
||
<div class="nav-section">PLANNER</div>
|
||
<div class="nav-item" data-tab="tasks" onclick="nav(this)">📋 TASKS</div>
|
||
<div class="nav-item" data-tab="appointments" onclick="nav(this)">📅 APPOINTMENTS</div>
|
||
<div class="nav-item" data-tab="calendar" onclick="nav(this)">🗓 CALENDAR SYNC</div>
|
||
<div class="nav-section">ARC REACTOR</div>
|
||
<div class="nav-item" data-tab="arc" onclick="nav(this)">⚡ ARC REACTOR</div>
|
||
<div class="nav-section">INFO</div>
|
||
<div class="nav-item" data-tab="sites" onclick="nav(this)">SITES</div>
|
||
<div class="nav-item" data-tab="users" onclick="nav(this)">USERS</div>
|
||
</div>
|
||
<div id="content">
|
||
|
||
<!-- DASHBOARD -->
|
||
<div class="tab active" id="tab-dashboard">
|
||
<div class="page-title">DASHBOARD</div>
|
||
<div class="stat-grid" id="dash-cards"><div class="loading">SCANNING...</div></div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px" id="dash-bottom"></div>
|
||
</div>
|
||
|
||
<!-- AGENTS -->
|
||
<div class="tab" id="tab-agents">
|
||
<div class="page-title"><span id="agents-title">AGENTS</span>
|
||
<div class="actions"><button class="btn btn-sm" onclick="loadAgents()">REFRESH</button></div>
|
||
</div>
|
||
<div class="tbl-wrap" id="agents-tbl"></div>
|
||
</div>
|
||
|
||
<!-- NETWORK -->
|
||
<div class="tab" id="tab-network">
|
||
<div class="page-title">NETWORK DEVICES <span id="net-title-count" style="color:var(--cyan);font-size:0.6rem;letter-spacing:2px"></span>
|
||
<div class="actions">
|
||
<button class="btn btn-sm btn-green" onclick="netModal()">+ ADD DEVICE</button>
|
||
<button class="btn btn-sm btn-yellow" id="scanBtn" onclick="scanNow()">SCAN NOW</button>
|
||
<button class="btn btn-sm" onclick="loadNetwork()">REFRESH</button>
|
||
</div>
|
||
</div>
|
||
<div class="filters" style="margin-bottom:12px">
|
||
<span class="lbl">FILTER:</span>
|
||
<button class="filter-btn active" id="nf-all" onclick="setNetFilter('all',this)">ALL</button>
|
||
<button class="filter-btn" id="nf-online" onclick="setNetFilter('online',this)">ONLINE</button>
|
||
<button class="filter-btn" id="nf-offline" onclick="setNetFilter('offline',this)">OFFLINE</button>
|
||
<button class="filter-btn" id="nf-named" onclick="setNetFilter('named',this)">NAMED</button>
|
||
<span class="lbl" id="net-count" style="color:var(--cyan)"></span>
|
||
</div>
|
||
<div class="tbl-wrap" id="network-tbl"><div class="loading">SCANNING...</div></div>
|
||
</div>
|
||
|
||
<!-- ALERTS -->
|
||
<div class="tab" id="tab-alerts">
|
||
<div class="page-title">ALERTS
|
||
<div class="actions">
|
||
<button class="btn btn-sm btn-green" onclick="alertModal()">+ CREATE</button>
|
||
<button class="btn btn-sm btn-yellow" onclick="apiPost('alerts_resolve_all',{},()=>{toast('All resolved','ok');loadAlerts()})">RESOLVE ALL</button>
|
||
<button class="btn btn-sm btn-red" onclick="apiPost('alerts_purge_resolved',{},()=>{toast('Purged','ok');loadAlerts()})">PURGE RESOLVED</button>
|
||
<button class="btn btn-sm" onclick="loadAlerts()">REFRESH</button>
|
||
</div>
|
||
</div>
|
||
<div class="filters">
|
||
<span class="lbl">FILTER:</span>
|
||
<button class="filter-btn active" onclick="setAlertFilter('active',this)">ACTIVE</button>
|
||
<button class="filter-btn" onclick="setAlertFilter('all',this)">ALL</button>
|
||
<button class="filter-btn" onclick="setAlertFilter('resolved',this)">RESOLVED</button>
|
||
</div>
|
||
<div class="tbl-wrap" id="alerts-tbl"><div class="loading">SCANNING...</div></div>
|
||
</div>
|
||
|
||
<!-- KB FACTS -->
|
||
<div class="tab" id="tab-facts">
|
||
<div class="page-title">KB FACTS <span id="facts-count" style="color:var(--cyan);font-size:0.6rem;letter-spacing:2px"></span>
|
||
<div class="actions">
|
||
<button class="btn btn-sm btn-green" onclick="factModal()">+ ADD FACT</button>
|
||
<button class="btn btn-sm" onclick="loadFacts()">REFRESH</button>
|
||
</div>
|
||
</div>
|
||
<div class="filters">
|
||
<span class="lbl">CATEGORY:</span>
|
||
<select class="filter-sel" id="factCat" onchange="loadFacts()">
|
||
<option value="__all__">ALL</option>
|
||
</select>
|
||
<button class="filter-btn active" id="fact-hide-unavail" onclick="toggleFactUnavail(this)" title="Hide unavailable/empty values">HIDE UNAVAIL</button>
|
||
</div>
|
||
<div class="tbl-wrap" id="facts-tbl"><div class="loading">SCANNING...</div></div>
|
||
</div>
|
||
|
||
<!-- KB INTENTS -->
|
||
<div class="tab" id="tab-intents">
|
||
<div class="page-title">KB INTENTS <span id="intents-count" style="color:var(--cyan);font-size:0.6rem;letter-spacing:2px"></span>
|
||
<div class="actions">
|
||
<button class="btn btn-sm btn-green" onclick="intentModal()">+ ADD INTENT</button>
|
||
<button class="btn btn-sm btn-yellow" onclick="intentTestModal()">TEST PATTERN</button>
|
||
<button class="btn btn-sm" onclick="loadIntents()">REFRESH</button>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;margin-bottom:10px;align-items:center">
|
||
<input id="intents-search" type="text" placeholder="Filter by name, pattern, or response…" style="flex:1;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:6px 10px;font-size:0.75rem;border-radius:3px;font-family:inherit" oninput="filterIntents(this.value)">
|
||
<select id="intents-filter-type" style="background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:6px 8px;font-size:0.75rem;border-radius:3px;font-family:inherit" onchange="filterIntents(document.getElementById('intents-search').value)">
|
||
<option value="">ALL TYPES</option>
|
||
<option value="response">RESPONSE</option>
|
||
<option value="action">ACTION</option>
|
||
</select>
|
||
<select id="intents-filter-status" style="background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:6px 8px;font-size:0.75rem;border-radius:3px;font-family:inherit" onchange="filterIntents(document.getElementById('intents-search').value)">
|
||
<option value="">ALL STATUS</option>
|
||
<option value="1">ACTIVE</option>
|
||
<option value="0">DISABLED</option>
|
||
</select>
|
||
</div>
|
||
<div class="tbl-wrap" id="intents-tbl"><div class="loading">SCANNING...</div></div>
|
||
</div>
|
||
|
||
<!-- HOME ASSISTANT -->
|
||
<div class="tab" id="tab-ha">
|
||
<div class="page-title">HOME ASSISTANT ENTITIES <span id="ha-title-count" style="color:var(--cyan);font-size:0.6rem;letter-spacing:2px"></span>
|
||
<div class="actions"><button class="btn btn-sm" onclick="loadHA()">REFRESH</button></div>
|
||
</div>
|
||
<div class="filters">
|
||
<span class="lbl">DOMAIN:</span>
|
||
<select class="filter-sel" id="ha-domain" onchange="loadHA()"><option value="">ALL</option></select>
|
||
<button class="filter-btn active" id="ha-all-btn" onclick="setHAOnlyOn(false,this)">ALL</button>
|
||
<button class="filter-btn" id="ha-on-btn" onclick="setHAOnlyOn(true,this)">ON ONLY</button>
|
||
<input id="ha-search" placeholder="search by name..." style="background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:4px 8px;font-family:var(--font);font-size:0.65rem;width:200px;outline:none" oninput="filterHATable()" onchange="filterHATable()">
|
||
<span class="lbl" id="ha-count" style="color:var(--cyan)"></span>
|
||
</div>
|
||
<div class="tbl-wrap" id="ha-tbl"><div class="loading">SCANNING...</div></div>
|
||
</div>
|
||
|
||
<!-- NEWS -->
|
||
<div class="tab" id="tab-news">
|
||
<div class="page-title">NEWS MANAGEMENT
|
||
<div class="actions">
|
||
<button class="btn btn-sm btn-green" onclick="newsCustomModal()">+ ADD CUSTOM</button>
|
||
<button class="btn btn-sm" onclick="loadNews()">REFRESH</button>
|
||
</div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
|
||
<div>
|
||
<div style="color:var(--cyan);font-size:0.65rem;letter-spacing:2px;margin-bottom:10px;border-bottom:1px solid var(--border);padding-bottom:6px">PINNED / CUSTOM NEWS</div>
|
||
<div id="news-custom"><div class="loading">SCANNING...</div></div>
|
||
</div>
|
||
<div>
|
||
<div style="color:var(--dim);font-size:0.65rem;letter-spacing:2px;margin-bottom:10px;border-bottom:1px solid var(--border);padding-bottom:6px">LIVE FEED (auto-refreshed)</div>
|
||
<div id="news-live"><div class="loading">SCANNING...</div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- PROXMOX VMs -->
|
||
<div class="tab" id="tab-vms">
|
||
<div class="page-title">PROXMOX VMs <span id="vms-count" style="color:var(--cyan);font-size:0.6rem;letter-spacing:2px"></span>
|
||
<div class="actions"><button class="btn btn-sm" onclick="loadVMs()">REFRESH</button></div>
|
||
</div>
|
||
<div class="tbl-wrap" id="vms-tbl"><div class="loading">SCANNING...</div></div>
|
||
</div>
|
||
|
||
<!-- BACKUPS -->
|
||
<div class="tab" id="tab-backups">
|
||
<div class="page-title">BACKUPS
|
||
<div class="actions">
|
||
<button class="btn btn-sm btn-green" id="backupRunBtn" onclick="triggerBackup()">▶ RUN BACKUP NOW</button>
|
||
<button class="btn btn-sm" onclick="loadBackups()">REFRESH</button>
|
||
</div>
|
||
</div>
|
||
<div id="backup-status-bar" style="display:none;background:var(--panel);border:1px solid var(--border2);padding:12px 16px;margin-bottom:16px;font-size:0.7rem">
|
||
<span style="color:var(--yellow);letter-spacing:1px" id="backup-status-msg">BACKUP RUNNING...</span>
|
||
<div style="margin-top:6px;height:3px;background:var(--border)"><div id="backup-progress-bar" style="height:100%;background:var(--yellow);width:0%;transition:width 1s"></div></div>
|
||
<div style="color:var(--dim);font-size:0.65rem;margin-top:6px" id="backup-log-tail"></div>
|
||
</div>
|
||
<div style="color:var(--dim);font-size:0.65rem;margin-bottom:16px">
|
||
Daily automatic backup runs at 2:00 AM. Files + all databases. Last 7 days retained. Stored on server — download anytime.
|
||
</div>
|
||
<div id="backups-list"><div class="loading">SCANNING...</div></div>
|
||
</div>
|
||
|
||
<!-- SITES -->
|
||
<div class="tab" id="tab-sites">
|
||
<div class="page-title">SITE HEALTH</div>
|
||
<div id="sites-content"><div class="loading">SCANNING...</div></div>
|
||
</div>
|
||
|
||
<!-- EMAIL -->
|
||
<div class="tab" id="tab-email">
|
||
<div class="page-title">EMAIL INTELLIGENCE
|
||
<div class="actions">
|
||
<button class="btn btn-sm" id="email-tab-inbox" onclick="emailShowTab('inbox')" style="background:rgba(0,212,255,0.15)">📥 INBOX</button>
|
||
<button class="btn btn-sm" id="email-tab-actions" onclick="emailShowTab('actions')">⚡ ACTION ITEMS <span id="email-ai-badge" style="background:var(--orange);color:#000;border-radius:10px;padding:0 5px;font-size:0.6rem;margin-left:4px"></span></button>
|
||
<select id="email-acct-filter" onchange="loadEmailInbox()" class="filter-sel">
|
||
<option value="all">ALL ACCOUNTS</option>
|
||
<option value="gmail">Gmail</option>
|
||
<option value="outlook">Outlook</option>
|
||
<option value="icloud">iCloud</option>
|
||
</select>
|
||
<button class="btn btn-sm" onclick="loadEmailInbox(true)">↺ REFRESH</button>
|
||
</div>
|
||
</div>
|
||
<div id="email-inbox-view">
|
||
<div class="tbl-wrap" id="email-tbl"></div>
|
||
</div>
|
||
<div id="email-actions-view" style="display:none">
|
||
<div class="tbl-wrap" id="email-actions-tbl"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TASKS -->
|
||
<div class="tab" id="tab-tasks">
|
||
<div class="page-title">TASKS
|
||
<div class="actions">
|
||
<select id="task-status-filter" onchange="loadTasks()" class="filter-sel">
|
||
<option value="">ACTIVE</option><option value="pending">PENDING</option>
|
||
<option value="in_progress">IN PROGRESS</option><option value="done">DONE</option>
|
||
<option value="cancelled">CANCELLED</option>
|
||
</select>
|
||
<select id="task-cat-filter" onchange="loadTasks()" class="filter-sel">
|
||
<option value="">ALL CATEGORIES</option><option value="personal">PERSONAL</option>
|
||
<option value="work">WORK</option><option value="todo">TODO</option>
|
||
</select>
|
||
<button class="btn btn-sm btn-green" onclick="taskModal()">+ ADD TASK</button>
|
||
<button class="btn btn-sm" onclick="loadTasks()">REFRESH</button>
|
||
</div>
|
||
</div>
|
||
<div class="tbl-wrap" id="tasks-tbl"></div>
|
||
</div>
|
||
|
||
<!-- APPOINTMENTS -->
|
||
<div class="tab" id="tab-appointments">
|
||
<div class="page-title">APPOINTMENTS
|
||
<div class="actions">
|
||
<button class="btn btn-sm btn-green" onclick="apptModal()">+ ADD APPOINTMENT</button>
|
||
<button class="btn btn-sm" onclick="loadAppts()">REFRESH</button>
|
||
</div>
|
||
</div>
|
||
<div class="tbl-wrap" id="appts-tbl"></div>
|
||
</div>
|
||
|
||
<!-- CALENDAR SYNC -->
|
||
<div class="tab" id="tab-calendar">
|
||
<div class="page-title">CALENDAR SYNC
|
||
<div class="actions">
|
||
<button class="btn btn-sm btn-green" onclick="calFeedModal()">+ ADD FEED</button>
|
||
<button class="btn btn-sm" onclick="syncCalNow()" id="calSyncBtn">⟳ SYNC NOW</button>
|
||
<button class="btn btn-sm" onclick="loadCalFeeds()">REFRESH</button>
|
||
</div>
|
||
</div>
|
||
<div style="font-size:0.65rem;color:var(--dim);margin-bottom:10px">
|
||
iCloud CalDAV syncs automatically every 15 min. Add Google Calendar or ICS feeds below.
|
||
<span id="cal-sync-status" style="margin-left:12px;color:var(--cyan)"></span>
|
||
</div>
|
||
<div class="tbl-wrap" id="cal-feeds-tbl"></div>
|
||
</div>
|
||
|
||
<!-- USERS -->
|
||
<div class="tab" id="tab-users">
|
||
<div class="page-title">USERS</div>
|
||
<div class="tbl-wrap" id="users-tbl"><div class="loading">SCANNING...</div></div>
|
||
</div>
|
||
|
||
|
||
<!-- ARC REACTOR -->
|
||
<div class="tab" id="tab-arc">
|
||
<div class="page-title">⚡ ARC REACTOR — CORE DAEMON</div>
|
||
<div id="arc-status-bar" style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap">
|
||
<div class="stat-box" style="background:rgba(0,212,255,0.06);border:1px solid var(--border);padding:12px 20px;border-radius:4px;min-width:130px">
|
||
<div style="font-size:0.55rem;letter-spacing:2px;color:var(--dim);margin-bottom:4px">STATUS</div>
|
||
<div id="arc-status-val" style="font-size:1.1rem;font-family:var(--mono);color:var(--green)">CHECKING...</div>
|
||
</div>
|
||
<div class="stat-box" style="background:rgba(0,212,255,0.06);border:1px solid var(--border);padding:12px 20px;border-radius:4px;min-width:130px">
|
||
<div style="font-size:0.55rem;letter-spacing:2px;color:var(--dim);margin-bottom:4px">VERSION</div>
|
||
<div id="arc-version-val" style="font-size:1.1rem;font-family:var(--mono)">—</div>
|
||
</div>
|
||
<div class="stat-box" style="background:rgba(0,212,255,0.06);border:1px solid var(--border);padding:12px 20px;border-radius:4px;min-width:130px">
|
||
<div style="font-size:0.55rem;letter-spacing:2px;color:var(--dim);margin-bottom:4px">JOBS DONE</div>
|
||
<div id="arc-done-val" style="font-size:1.1rem;font-family:var(--mono)">—</div>
|
||
</div>
|
||
<div class="stat-box" style="background:rgba(0,212,255,0.06);border:1px solid var(--border);padding:12px 20px;border-radius:4px;min-width:130px">
|
||
<div style="font-size:0.55rem;letter-spacing:2px;color:var(--dim);margin-bottom:4px">FAILED</div>
|
||
<div id="arc-fail-val" style="font-size:1.1rem;font-family:var(--mono)">—</div>
|
||
</div>
|
||
<div class="stat-box" style="background:rgba(0,212,255,0.06);border:1px solid var(--border);padding:12px 20px;border-radius:4px;min-width:130px">
|
||
<div style="font-size:0.55rem;letter-spacing:2px;color:var(--dim);margin-bottom:4px">HEARTBEAT</div>
|
||
<div id="arc-hb-val" style="font-size:0.75rem;font-family:var(--mono)">—</div>
|
||
</div>
|
||
<div class="stat-box" style="background:rgba(0,212,255,0.06);border:1px solid var(--border);padding:12px 20px;border-radius:4px;min-width:130px">
|
||
<div style="font-size:0.55rem;letter-spacing:2px;color:var(--dim);margin-bottom:4px">CAPABILITIES</div>
|
||
<div id="arc-caps-val" style="font-size:0.65rem;font-family:var(--mono)">—</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display:flex;gap:10px;margin-bottom:16px;align-items:center;flex-wrap:wrap">
|
||
<button class="btn btn-sm" onclick="loadArc()">↻ REFRESH</button>
|
||
<button class="btn btn-sm btn-green" onclick="arcTestPing()">PING TEST</button>
|
||
<button class="btn btn-sm" onclick="arcPurge()" style="margin-left:auto;opacity:0.7">PURGE OLD JOBS</button>
|
||
<select id="arc-job-filter" class="inp" style="width:auto;padding:4px 8px;font-size:0.65rem" onchange="loadArc()">
|
||
<option value="">ALL JOBS</option>
|
||
<option value="queued">QUEUED</option>
|
||
<option value="running">RUNNING</option>
|
||
<option value="done">DONE</option>
|
||
<option value="failed">FAILED</option>
|
||
<option value="cancelled">CANCELLED</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="tbl-wrap" id="arc-jobs-tbl"><div class="loading">INITIALIZING...</div></div>
|
||
</div>
|
||
|
||
<!-- GMAIL TRIAGE -->
|
||
<div class="tab" id="tab-triage">
|
||
<div class="page-title">◈ COMMS PROTOCOL — GMAIL TRIAGE</div>
|
||
|
||
<div style="display:flex;gap:10px;margin-bottom:16px;align-items:center;flex-wrap:wrap">
|
||
<button class="btn btn-sm btn-green" onclick="triageRunNow('gmail')">⚡ TRIAGE GMAIL</button>
|
||
<button class="btn btn-sm" onclick="triageRunNow('icloud')">⚡ TRIAGE ICLOUD</button>
|
||
<button class="btn btn-sm" onclick="loadTriage()">↻ REFRESH</button>
|
||
<select id="triage-filter" class="inp" style="width:auto;padding:4px 8px;font-size:0.65rem" onchange="loadTriage()">
|
||
<option value="priority">PRIORITY</option>
|
||
<option value="action">ACTION NEEDED</option>
|
||
<option value="urgent">URGENT ONLY</option>
|
||
<option value="all">ALL</option>
|
||
</select>
|
||
<div id="triage-count" style="margin-left:auto;font-family:var(--mono);font-size:0.65rem;color:var(--dim)"></div>
|
||
</div>
|
||
|
||
<div id="triage-summary" style="display:none;margin-bottom:12px;padding:10px 14px;background:rgba(0,212,255,0.04);border:1px solid var(--border);border-radius:4px;font-family:var(--mono);font-size:0.65rem;display:flex;gap:20px;flex-wrap:wrap"></div>
|
||
|
||
<div class="tbl-wrap" id="triage-tbl"><div class="loading">LOADING TRIAGE DATA...</div></div>
|
||
</div>
|
||
|
||
</div><!-- /content -->
|
||
</div><!-- /main -->
|
||
</div><!-- /app -->
|
||
|
||
<!-- MODAL -->
|
||
<div id="modalBg">
|
||
<div id="modal">
|
||
<div id="modalHead"><h3 id="modalTitle">EDIT</h3><button id="modalClose" onclick="closeModal()">×</button></div>
|
||
<div id="modalBody"></div>
|
||
<div id="modalFoot"><button class="btn btn-sm" onclick="closeModal()">CANCEL</button><button class="btn btn-sm btn-green" id="modalSave" onclick="modalSave()">SAVE</button></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="toast"></div>
|
||
|
||
<script>
|
||
// ── UTILS ─────────────────────────────────────────────────────────────────────
|
||
let _alertFilter = 'active';
|
||
let _modalCb = null;
|
||
|
||
function esc(s){ return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
function ts(s){ if(!s) return '—'; const d=new Date(s); return d.toLocaleString('en-US',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}); }
|
||
function ago(s){ if(!s) return '—'; const sec=Math.floor((Date.now()-new Date(s))/1000); if(sec<60) return sec+'s ago'; if(sec<3600) return Math.floor(sec/60)+'m ago'; return Math.floor(sec/3600)+'h ago'; }
|
||
function fmtUp(s){ const d=Math.floor(s/86400),h=Math.floor((s%86400)/3600),m=Math.floor((s%3600)/60); return (d>0?d+'d ':'')+h+'h '+m+'m'; }
|
||
|
||
async function api(action, params={}) {
|
||
const url = new URL(location.href);
|
||
url.searchParams.set('action', action);
|
||
Object.entries(params).forEach(([k,v]) => url.searchParams.set(k,v));
|
||
const r = await fetch(url.toString());
|
||
return r.json();
|
||
}
|
||
|
||
async function apiPost(action, data={}, cb=null) {
|
||
const fd = new FormData();
|
||
fd.append('action', action);
|
||
Object.entries(data).forEach(([k,v]) => fd.append(k,v));
|
||
try {
|
||
const r = await fetch(location.href, {method:'POST', body:fd});
|
||
const d = await r.json();
|
||
if (d.error) { toast(d.error,'err'); return; }
|
||
if (cb) cb(d);
|
||
} catch(e) { toast('Request failed','err'); }
|
||
}
|
||
|
||
function toast(msg, type='ok') {
|
||
const el = document.createElement('div');
|
||
el.className = 'toast-msg '+(type==='err'?'err':'ok');
|
||
el.textContent = msg;
|
||
document.getElementById('toast').appendChild(el);
|
||
setTimeout(() => el.remove(), 3000);
|
||
}
|
||
|
||
function sevBadge(s) {
|
||
const m = {critical:'badge-red',warning:'badge-yellow',info:'badge-cyan'};
|
||
return `<span class="badge ${m[s]||'badge-dim'}">${esc(s).toUpperCase()}</span>`;
|
||
}
|
||
function statusBadge(s) {
|
||
if (s==='online') return `<span class="badge badge-green">ONLINE</span>`;
|
||
if (s==='offline') return `<span class="badge badge-red">OFFLINE</span>`;
|
||
return `<span class="badge badge-dim">${esc(s).toUpperCase()}</span>`;
|
||
}
|
||
|
||
// ── AUTH ──────────────────────────────────────────────────────────────────────
|
||
async function doLogin() {
|
||
const u=document.getElementById('lu').value.trim(), p=document.getElementById('lp').value;
|
||
const fd=new FormData(); fd.append('action','login'); fd.append('username',u); fd.append('password',p);
|
||
const r = await fetch(location.href,{method:'POST',body:fd});
|
||
const d = await r.json();
|
||
if (d.error) { document.getElementById('loginErr').textContent=d.error; return; }
|
||
document.getElementById('loginWrap').style.display='none';
|
||
document.getElementById('app').style.display='flex';
|
||
document.getElementById('adminUser').textContent = (d.name||u).toUpperCase();
|
||
initApp();
|
||
}
|
||
|
||
async function doLogout() {
|
||
await apiPost('logout');
|
||
location.reload();
|
||
}
|
||
|
||
document.getElementById('lp').addEventListener('keydown', e => { if(e.key==='Enter') doLogin(); });
|
||
document.getElementById('lu').addEventListener('keydown', e => { if(e.key==='Enter') document.getElementById('lp').focus(); });
|
||
|
||
// ── NAV ───────────────────────────────────────────────────────────────────────
|
||
function nav(el) {
|
||
document.querySelectorAll('.nav-item').forEach(n=>n.classList.remove('active'));
|
||
el.classList.add('active');
|
||
const tab = el.dataset.tab;
|
||
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
|
||
document.getElementById('tab-'+tab).classList.add('active');
|
||
loadTab(tab);
|
||
}
|
||
|
||
function loadTab(tab) {
|
||
// Stop any existing network auto-refresh when leaving
|
||
if (_netAutoRefresh) { clearInterval(_netAutoRefresh); _netAutoRefresh = null; }
|
||
({
|
||
backups: loadBackups,
|
||
dashboard: loadDashboard,
|
||
agents: loadAgents,
|
||
network: ()=>{ loadNetwork(); _netAutoRefresh = setInterval(loadNetwork, 30000); },
|
||
alerts: loadAlerts,
|
||
facts: ()=>{ loadFactCategories(); loadFacts(); },
|
||
intents: loadIntents,
|
||
ha: loadHA,
|
||
news: loadNews,
|
||
vms: loadVMs,
|
||
sites: loadSites,
|
||
users: loadUsers,
|
||
email: ()=>{ loadEmailInbox(); loadEmailActionItems(); },
|
||
triage: loadTriage,
|
||
tasks: loadTasks,
|
||
appointments: loadAppts,
|
||
calendar: loadCalFeeds,
|
||
arc: loadArc,
|
||
})[tab]?.();
|
||
}
|
||
|
||
function initApp() { loadDashboard(); setInterval(loadDashboard, 15000); }
|
||
|
||
// ── DASHBOARD ─────────────────────────────────────────────────────────────────
|
||
async function loadDashboard() {
|
||
const d = await api('dashboard');
|
||
const s = d.sys;
|
||
const mp = s.mem_pct, mc = mp>80?'danger':mp>60?'warn':'';
|
||
const lc = s.load_1m>2?'danger':s.load_1m>1?'warn':'';
|
||
const dc = (parseInt(s.disk_pct)>88)?'danger':(parseInt(s.disk_pct)>75)?'warn':'';
|
||
|
||
document.getElementById('dash-cards').innerHTML = `
|
||
<div class="stat-card">
|
||
<div class="lbl">MEMORY</div>
|
||
<div class="val ${mc}">${mp}%</div>
|
||
<div class="sub">${s.mem_used_mb} / ${s.mem_total_mb} MB</div>
|
||
<div class="meter"><div class="meter-bar ${mc}" style="width:${mp}%"></div></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="lbl">LOAD AVG</div>
|
||
<div class="val ${lc}">${s.load_1m}</div>
|
||
<div class="sub">1-minute average</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="lbl">DISK USAGE</div>
|
||
<div class="val ${dc}">${s.disk_pct||'—'}</div>
|
||
<div class="sub">root filesystem</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="lbl">UPTIME</div>
|
||
<div class="val" style="font-size:1rem;margin-top:4px">${fmtUp(s.uptime_s)}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="lbl">AGENTS</div>
|
||
<div class="val ${d.agents?.online>0?'':'danger'}">${d.agents?.online||0}<span style="font-size:1rem;color:var(--dim)">/${d.agents?.total||0}</span></div>
|
||
<div class="sub">online</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="lbl">ACTIVE ALERTS</div>
|
||
<div class="val ${d.alerts?.active>0?'danger':''}">${d.alerts?.active||0}<span style="font-size:1rem;color:var(--dim)">/${d.alerts?.total||0}</span></div>
|
||
<div class="sub">unresolved</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="lbl">NAMED DEVICES</div>
|
||
<div class="val">${d.devices?.total||0}</div>
|
||
<div class="sub">${d.devices?.online||0} online</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="lbl">KB FACTS</div>
|
||
<div class="val">${d.facts?.total||0}</div>
|
||
<div class="sub">${d.intents?.active||0}/${d.intents?.total||0} intents active</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ── PROGRESSIVE RENDER HELPER ─────────────────────────────────────────────────
|
||
// Renders rows one-by-one into a tbody, staggered so the table "fills in" live.
|
||
// titleEl: element to show scanning progress. headers: th array. rowFn: item→html string.
|
||
function progressiveRender(items, tbodyId, rowFn, titleEl, titleDone) {
|
||
const tbody = document.getElementById(tbodyId);
|
||
if (!tbody) return;
|
||
if (!items.length) {
|
||
tbody.closest('table')?.parentElement && (tbody.closest('.tbl-wrap').innerHTML = '<div class="empty">NO DATA</div>');
|
||
if (titleEl) titleEl.textContent = titleDone || '';
|
||
return;
|
||
}
|
||
const n = items.length;
|
||
const stagger = Math.min(100, Math.max(15, Math.floor(1800 / n))); // cap total at ~1.8s
|
||
items.forEach((item, i) => {
|
||
setTimeout(() => {
|
||
const tr = document.createElement('tr');
|
||
tr.className = 'agent-row'; // reuse slide-in animation
|
||
tr.innerHTML = rowFn(item, i);
|
||
tbody.appendChild(tr);
|
||
if (titleEl) {
|
||
const done = i + 1;
|
||
titleEl.innerHTML = done < n
|
||
? `${titleDone.split(' ')[0]} <span style="color:var(--dim);font-size:0.6rem;letter-spacing:2px">SCANNING... ${done}/${n}</span>`
|
||
: `${titleDone} <span style="color:var(--cyan);font-size:0.6rem;letter-spacing:2px">${n} TOTAL</span>`;
|
||
}
|
||
}, i * stagger);
|
||
});
|
||
}
|
||
|
||
// Sets up the empty table shell immediately while fetch is in flight
|
||
function scanShell(tblWrapId, headers, titleEl, scanLabel) {
|
||
const wrap = document.getElementById(tblWrapId);
|
||
if (!wrap) return;
|
||
const ths = headers.map(h=>`<th>${h}</th>`).join('');
|
||
wrap.innerHTML = `<table><thead><tr>${ths}</tr></thead><tbody id="${tblWrapId}-tbody"></tbody></table>`;
|
||
if (titleEl) titleEl.innerHTML = `${scanLabel} <span style="color:var(--dim);font-size:0.6rem;letter-spacing:2px">SCANNING...</span>`;
|
||
}
|
||
|
||
// ── AGENTS ────────────────────────────────────────────────────────────────────
|
||
const miniBar = (pct, warn=70, crit=85) => {
|
||
if (pct == null) return '—';
|
||
const c = pct>=crit?'var(--red)':pct>=warn?'var(--yellow)':'var(--green)';
|
||
return `<span style="color:${c}">${Math.round(pct)}%</span>`;
|
||
};
|
||
|
||
async function loadAgents() {
|
||
const tbl = document.getElementById('agents-tbl');
|
||
const title = document.getElementById('agents-title');
|
||
|
||
tbl.innerHTML = `<table><thead><tr>
|
||
<th>HOSTNAME</th><th>STATUS</th><th>TYPE</th><th>IP</th><th>METRICS</th><th>LAST SEEN</th><th>REGISTERED</th><th></th>
|
||
</tr></thead><tbody id="agents-tbody"></tbody></table>`;
|
||
title.innerHTML = 'AGENTS <span style="color:var(--dim);font-size:0.6rem;letter-spacing:2px">SCANNING...</span>';
|
||
|
||
let agents;
|
||
try { agents = await api('agents_list'); }
|
||
catch(e) { tbl.innerHTML='<div class="empty">ERROR LOADING AGENTS</div>'; title.textContent='AGENTS'; return; }
|
||
|
||
if (!Array.isArray(agents) || !agents.length) {
|
||
tbl.innerHTML = '<div class="empty">NO AGENTS REGISTERED</div>';
|
||
title.textContent = 'AGENTS';
|
||
return;
|
||
}
|
||
|
||
agents.forEach((a, i) => {
|
||
setTimeout(() => {
|
||
const tbody = document.getElementById('agents-tbody');
|
||
if (!tbody) return;
|
||
const m = a.metrics;
|
||
const online = a.status === 'online';
|
||
const lastSeen = a.last_seen ? (Date.now() - new Date(a.last_seen)) / 1000 : null;
|
||
const fresh = lastSeen !== null && lastSeen < 30;
|
||
const meterCell = m
|
||
? `<span style="font-size:0.65rem">CPU ${miniBar(m.cpu_pct)} · RAM ${miniBar(m.mem_pct)} · DISK ${miniBar(m.disk_pct,80,90)}</span>`
|
||
: `<span style="color:var(--dim);font-size:0.65rem">no metrics</span>`;
|
||
|
||
const row = document.createElement('tr');
|
||
row.className = 'agent-row';
|
||
row.innerHTML = `
|
||
<td><span class="dot ${online?'dot-green':'dot-red'}"></span>
|
||
<strong>${esc(a.hostname)}</strong>
|
||
${fresh&&online?'<span style="font-size:0.55rem;color:var(--green);margin-left:4px">● LIVE</span>':''}
|
||
</td>
|
||
<td>${statusBadge(a.status)}</td>
|
||
<td><span class="badge badge-cyan">${esc(a.agent_type||'linux').toUpperCase()}</span></td>
|
||
<td style="font-size:0.72rem">${esc(a.ip_address||'—')}</td>
|
||
<td>${meterCell}</td>
|
||
<td class="ts">${ago(a.last_seen)}</td>
|
||
<td class="ts">${ts(a.created_at)}</td>
|
||
<td><button class="btn btn-xs btn-red" onclick="delAgent('${esc(a.agent_id)}','${esc(a.hostname)}')">DEL</button></td>`;
|
||
tbody.appendChild(row);
|
||
|
||
const found = i + 1;
|
||
const onlineCt = agents.slice(0, found).filter(x => x.status === 'online').length;
|
||
title.innerHTML = found < agents.length
|
||
? `AGENTS <span style="color:var(--dim);font-size:0.6rem;letter-spacing:2px">SCANNING... ${found}/${agents.length}</span>`
|
||
: `AGENTS <span style="color:var(--cyan);font-size:0.6rem;letter-spacing:2px">${onlineCt} ONLINE / ${agents.length} TOTAL</span>`;
|
||
}, i * 120);
|
||
});
|
||
}
|
||
|
||
function delAgent(id, name) {
|
||
if (!confirm(`Delete agent "${name}"? This cannot be undone.`)) return;
|
||
apiPost('agents_delete', {agent_id:id}, ()=>{ toast('Agent deleted','ok'); loadAgents(); });
|
||
}
|
||
|
||
// ── NETWORK ───────────────────────────────────────────────────────────────────
|
||
let _netFilter = 'all';
|
||
let _allDevices = [];
|
||
let _netAutoRefresh = null;
|
||
|
||
function setNetFilter(f, el) {
|
||
_netFilter = f;
|
||
document.querySelectorAll('#tab-network .filter-btn').forEach(b=>b.classList.remove('active'));
|
||
el.classList.add('active');
|
||
renderNetwork();
|
||
}
|
||
|
||
async function loadNetwork() {
|
||
scanShell('network-tbl', ['NAME','IP','MAC','VENDOR / TYPE','STATUS','LAST SEEN','ACTIONS'], null, null);
|
||
_allDevices = await api('network_list');
|
||
renderNetwork();
|
||
}
|
||
|
||
function renderNetwork() {
|
||
let devs = _allDevices;
|
||
if (_netFilter === 'online') devs = devs.filter(d => d.status === 'online');
|
||
if (_netFilter === 'offline') devs = devs.filter(d => d.status === 'offline');
|
||
if (_netFilter === 'named') devs = devs.filter(d => d.alias);
|
||
const onlineCount = _allDevices.filter(d=>d.status==='online').length;
|
||
document.getElementById('net-count').textContent = `${onlineCount}/${_allDevices.length} ONLINE`;
|
||
const _ntEl=document.getElementById('net-title-count'); if(_ntEl) _ntEl.textContent=`${onlineCount} ONLINE / ${_allDevices.length} TOTAL`;
|
||
if (!devs.length) { document.getElementById('network-tbl').innerHTML='<div class="empty">NO DEVICES MATCH FILTER</div>'; return; }
|
||
// Re-build shell (filter changed)
|
||
document.getElementById('network-tbl').innerHTML = `<table>
|
||
<thead><tr><th>NAME</th><th>IP</th><th>MAC</th><th>VENDOR / TYPE</th><th>STATUS</th><th>LAST SEEN</th><th>ACTIONS</th></tr></thead>
|
||
<tbody id="network-tbl-tbody"></tbody></table>`;
|
||
progressiveRender(devs, 'network-tbl-tbody', d => {
|
||
const name = d.alias || d.hostname || d.ip;
|
||
const vendor = d.device_type || '—';
|
||
return `<td><span class="dot ${d.status==='online'?'dot-green':d.status==='offline'?'dot-red':'dot-dim'}"></span>
|
||
<strong>${esc(name)}</strong>${d.alias?'':' <span style="color:var(--dim);font-size:0.6rem">(discovered)</span>'}</td>
|
||
<td style="color:var(--cyan)">${esc(d.ip)}</td>
|
||
<td style="font-size:0.65rem;color:var(--dim)">${esc(d.mac||'—')}</td>
|
||
<td class="trunc ts" style="max-width:140px" title="${esc(vendor)}">${esc(vendor)}</td>
|
||
<td>${statusBadge(d.status)}</td>
|
||
<td class="ts">${ago(d.last_seen)}</td>
|
||
<td><div class="actions-col">
|
||
<button class="btn btn-xs" onclick="pingDev('${esc(d.ip)}',this)">PING</button>
|
||
<button class="btn btn-xs btn-yellow" onclick="netModal(${d.id},'${esc(d.ip)}','${esc(d.alias||'')}','${esc(d.device_type||'')}')">NAME</button>
|
||
<button class="btn btn-xs btn-red" onclick="delNet(${d.id},'${esc(name)}')">DEL</button>
|
||
</div></td>`;
|
||
}, null, null);
|
||
}
|
||
|
||
async function scanNow() {
|
||
const btn = document.getElementById('scanBtn');
|
||
btn.textContent = 'QUEUING...'; btn.disabled = true;
|
||
const fd = new FormData(); fd.append('action','network_scan');
|
||
try {
|
||
const r = await fetch(location.href,{method:'POST',body:fd});
|
||
const d = await r.json();
|
||
if (d.ok && d.queued) {
|
||
toast('Scan queued — refreshing in 45s...','ok');
|
||
setTimeout(()=>{ loadNetwork(); }, 45000);
|
||
} else {
|
||
toast(d.note || 'Scan scheduled via cron','ok');
|
||
}
|
||
} catch(e){ toast('Request failed','err'); }
|
||
setTimeout(()=>{ btn.textContent='SCAN NOW'; btn.disabled=false; }, 5000);
|
||
}
|
||
|
||
function netModal(id=0, ip='', alias='', type='') {
|
||
openModal(id?'NAME / EDIT DEVICE':'ADD DEVICE', `
|
||
<div class="form-row"><label>IP ADDRESS</label><input id="f-ip" value="${esc(ip)}" placeholder="10.48.200.x"></div>
|
||
<div class="form-row"><label>NAME / ALIAS</label><input id="f-alias" value="${esc(alias)}" placeholder="My Device"></div>
|
||
<div class="form-row"><label>TYPE</label>
|
||
<select id="f-type">
|
||
${['device','server','router','switch','phone','camera','nas','printer','vm','workstation'].map(t=>`<option value="${t}" ${t===type?'selected':''}>${t.toUpperCase()}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<input type="hidden" id="f-id" value="${id}">
|
||
`, () => {
|
||
const data = {id: document.getElementById('f-id').value, ip: document.getElementById('f-ip').value, alias: document.getElementById('f-alias').value, device_type: document.getElementById('f-type').value};
|
||
apiPost('network_save', data, ()=>{ toast('Saved','ok'); closeModal(); loadNetwork(); });
|
||
});
|
||
}
|
||
|
||
async function pingDev(ip, btn) {
|
||
btn.textContent='…'; btn.disabled=true;
|
||
const fd=new FormData(); fd.append('action','network_ping'); fd.append('ip',ip);
|
||
try {
|
||
const r = await fetch(location.href,{method:'POST',body:fd});
|
||
const d = await r.json();
|
||
toast(d.alive ? `${ip} ONLINE (${d.latency_ms??'?'}ms)` : `${ip} OFFLINE`, d.alive?'ok':'err');
|
||
} catch(e){ toast('Ping failed','err'); }
|
||
btn.textContent='PING'; btn.disabled=false;
|
||
}
|
||
|
||
function delNet(id, name) {
|
||
if (!confirm(`Remove "${name}" from network devices?`)) return;
|
||
apiPost('network_delete', {id}, ()=>{ toast('Removed','ok'); loadNetwork(); });
|
||
}
|
||
|
||
// ── ALERTS ────────────────────────────────────────────────────────────────────
|
||
function setAlertFilter(f, el) {
|
||
_alertFilter = f;
|
||
document.querySelectorAll('.filter-btn').forEach(b=>b.classList.remove('active'));
|
||
el.classList.add('active');
|
||
loadAlerts();
|
||
}
|
||
|
||
async function loadAlerts() {
|
||
scanShell('alerts-tbl', ['SEV','TYPE','TITLE','MESSAGE','STATUS','CREATED','ACTIONS'], null, null);
|
||
const alerts = await api('alerts_list', {filter:_alertFilter});
|
||
if (!alerts.length) { document.getElementById('alerts-tbl').innerHTML='<div class="empty">NO ALERTS</div>'; return; }
|
||
document.getElementById('alerts-tbl').innerHTML = `<table>
|
||
<thead><tr><th>SEV</th><th>TYPE</th><th>TITLE</th><th>MESSAGE</th><th>STATUS</th><th>CREATED</th><th>ACTIONS</th></tr></thead>
|
||
<tbody id="alerts-tbl-tbody"></tbody></table>`;
|
||
progressiveRender(alerts, 'alerts-tbl-tbody', a =>
|
||
`<td>${sevBadge(a.severity)}</td>
|
||
<td>${esc(a.alert_type)}</td>
|
||
<td class="trunc">${esc(a.title)}</td>
|
||
<td class="trunc ts">${esc(a.message||'—')}</td>
|
||
<td>${a.resolved?'<span class="badge badge-dim">RESOLVED</span>':'<span class="badge badge-red">ACTIVE</span>'}</td>
|
||
<td class="ts">${ts(a.created_at)}</td>
|
||
<td><div class="actions-col">
|
||
${!a.resolved?`<button class="btn btn-xs btn-green" onclick="apiPost('alerts_resolve',{id:${a.id}},()=>{toast('Resolved','ok');loadAlerts()})">RESOLVE</button>`:''}
|
||
<button class="btn btn-xs btn-yellow" onclick="alertModal(${a.id},'${esc(a.alert_type)}','${esc(a.title)}','${esc(a.message||'')}','${esc(a.severity)}')">EDIT</button>
|
||
<button class="btn btn-xs btn-red" onclick="apiPost('alerts_delete',{id:${a.id}},()=>{toast('Deleted','ok');loadAlerts()})">DEL</button>
|
||
</div></td>`, null, null);
|
||
}
|
||
|
||
function alertModal(id=0, type='manual', title='', message='', severity='info') {
|
||
openModal(id?'EDIT ALERT':'CREATE ALERT', `
|
||
<div class="form-row"><label>TYPE</label><input id="a-type" value="${esc(type)}" placeholder="manual"></div>
|
||
<div class="form-row"><label>TITLE</label><input id="a-title" value="${esc(title)}"></div>
|
||
<div class="form-row"><label>MESSAGE</label><textarea id="a-msg">${esc(message)}</textarea></div>
|
||
<div class="form-row"><label>SEVERITY</label>
|
||
<select id="a-sev">
|
||
${['info','warning','critical'].map(s=>`<option value="${s}" ${s===severity?'selected':''}>${s.toUpperCase()}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<input type="hidden" id="a-id" value="${id}">
|
||
`, () => {
|
||
const data = {id: document.getElementById('a-id').value, alert_type: document.getElementById('a-type').value, title: document.getElementById('a-title').value, message: document.getElementById('a-msg').value, severity: document.getElementById('a-sev').value};
|
||
apiPost('alerts_save', data, ()=>{ toast('Saved','ok'); closeModal(); loadAlerts(); });
|
||
});
|
||
}
|
||
|
||
// ── KB FACTS ─────────────────────────────────────────────────────────────────
|
||
async function loadFactCategories() {
|
||
const cats = await api('facts_categories');
|
||
const sel = document.getElementById('factCat');
|
||
sel.innerHTML = '<option value="__all__">ALL CATEGORIES</option>' +
|
||
cats.map(c=>`<option value="${esc(c.category)}">${esc(c.category)} (${c.cnt})</option>`).join('');
|
||
const _factTotal = cats.reduce((s,c)=>s+parseInt(c.cnt||0),0);
|
||
const _factCntEl = document.getElementById('facts-count'); if(_factCntEl) _factCntEl.textContent=_factTotal.toLocaleString()+' TOTAL';
|
||
}
|
||
|
||
const _unavailValues = new Set(['unavailable','unknown','none','null','','N/A','n/a','undefined']);
|
||
function toggleFactUnavail(btn){ btn.classList.toggle('active'); loadFacts(); }
|
||
async function loadFacts() {
|
||
scanShell('facts-tbl', ['CATEGORY','KEY','VALUE','UPDATED','ACTIONS'], null, null);
|
||
const cat = document.getElementById('factCat')?.value || '__all__';
|
||
let facts = await api('facts_list', {category: cat});
|
||
const hideUnavail = document.getElementById('fact-hide-unavail')?.classList.contains('active');
|
||
if (hideUnavail) facts = facts.filter(f => { const v=(f.fact_value||'').toLowerCase().trim(); return !_unavailValues.has(v); });
|
||
const _factDispEl = document.getElementById('facts-count');
|
||
if (_factDispEl && hideUnavail) _factDispEl.textContent += ` · ${facts.length} SHOWN`;
|
||
if (!facts.length) { document.getElementById('facts-tbl').innerHTML='<div class="empty">NO FACTS MATCH FILTER</div>'; return; }
|
||
document.getElementById('facts-tbl').innerHTML = `<table>
|
||
<thead><tr><th>CATEGORY</th><th>KEY</th><th>VALUE</th><th>UPDATED</th><th>ACTIONS</th></tr></thead>
|
||
<tbody id="facts-tbl-tbody"></tbody></table>`;
|
||
progressiveRender(facts, 'facts-tbl-tbody', f =>
|
||
`<td><span class="badge badge-cyan">${esc(f.category)}</span></td>
|
||
<td>${esc(f.fact_key)}</td>
|
||
<td class="trunc" style="max-width:320px" title="${esc(f.fact_value)}">${esc(f.fact_value)}</td>
|
||
<td class="ts">${ago(f.updated_at)}</td>
|
||
<td><div class="actions-col">
|
||
<button class="btn btn-xs btn-yellow" onclick='factModal(${f.id},"${esc(f.category)}","${esc(f.fact_key)}",${JSON.stringify(f.fact_value)})'>EDIT</button>
|
||
<button class="btn btn-xs btn-red" onclick="apiPost('facts_delete',{id:${f.id}},()=>{toast('Deleted','ok');loadFacts()})">DEL</button>
|
||
</div></td>`, null, null);
|
||
}
|
||
|
||
function factModal(id=0, category='', key='', value='') {
|
||
openModal(id?'EDIT FACT':'ADD FACT', `
|
||
<div class="form-row"><label>CATEGORY</label><input id="fc-cat" value="${esc(category)}" placeholder="system"></div>
|
||
<div class="form-row"><label>KEY</label><input id="fc-key" value="${esc(key)}" placeholder="fact_name"></div>
|
||
<div class="form-row"><label>VALUE</label><textarea id="fc-val">${esc(value)}</textarea></div>
|
||
<input type="hidden" id="fc-id" value="${id}">
|
||
`, () => {
|
||
const data = {id: document.getElementById('fc-id').value, category: document.getElementById('fc-cat').value, fact_key: document.getElementById('fc-key').value, fact_value: document.getElementById('fc-val').value};
|
||
apiPost('facts_save', data, ()=>{ toast('Saved','ok'); closeModal(); loadFacts(); });
|
||
});
|
||
}
|
||
|
||
// ── KB INTENTS ────────────────────────────────────────────────────────────────
|
||
let _allIntents = [];
|
||
|
||
async function loadIntents() {
|
||
scanShell('intents-tbl', ['NAME','PATTERN','RESPONSE','TYPE','PRI','STATUS','ACTIONS'], null, null);
|
||
_allIntents = await api('intents_list');
|
||
const cntEl=document.getElementById('intents-count'); if(cntEl) cntEl.textContent=_allIntents.length.toLocaleString()+' INTENTS';
|
||
renderIntents(_allIntents);
|
||
}
|
||
|
||
function filterIntents(q) {
|
||
q = (q||'').toLowerCase().trim();
|
||
const typeFilter = (document.getElementById('intents-filter-type')?.value || '').toLowerCase();
|
||
const statusFilter = document.getElementById('intents-filter-status')?.value ?? '';
|
||
let filtered = _allIntents;
|
||
if (q) filtered = filtered.filter(i =>
|
||
i.intent_name.toLowerCase().includes(q) ||
|
||
(i.pattern||'').toLowerCase().includes(q) ||
|
||
(i.response_template||'').toLowerCase().includes(q)
|
||
);
|
||
if (typeFilter) filtered = filtered.filter(i => i.action_type === typeFilter);
|
||
if (statusFilter !== '') filtered = filtered.filter(i => String(i.active) === statusFilter);
|
||
const cntEl=document.getElementById('intents-count');
|
||
if(cntEl) cntEl.textContent = (q||typeFilter||statusFilter!=='' ? filtered.length+'/'+_allIntents.length : _allIntents.length.toLocaleString())+' INTENTS';
|
||
renderIntents(filtered);
|
||
}
|
||
|
||
function renderIntents(intents) {
|
||
if (!intents.length) { document.getElementById('intents-tbl').innerHTML='<div class="empty">NO INTENTS MATCH</div>'; return; }
|
||
document.getElementById('intents-tbl').innerHTML = `<table>
|
||
<thead><tr><th>NAME</th><th>PATTERN</th><th>RESPONSE</th><th>TYPE</th><th style="text-align:center">PRI</th><th>STATUS</th><th>ACTIONS</th></tr></thead>
|
||
<tbody id="intents-tbl-tbody"></tbody></table>`;
|
||
progressiveRender(intents, 'intents-tbl-tbody', i =>
|
||
`<td>${esc(i.intent_name)}</td>
|
||
<td class="trunc" style="max-width:240px" title="${esc(i.pattern)}"><code style="font-size:0.65rem;color:var(--yellow)">${esc(i.pattern)}</code></td>
|
||
<td class="trunc" style="max-width:200px"><span style="font-size:0.7rem">${esc(i.response_template||'—')}</span></td>
|
||
<td><span class="badge badge-dim">${esc(i.action_type)}</span></td>
|
||
<td style="text-align:center">${i.priority}</td>
|
||
<td>${i.active?'<span class="badge badge-green">ON</span>':'<span class="badge badge-dim">OFF</span>'}</td>
|
||
<td><div class="actions-col">
|
||
<button class="btn btn-xs" onclick="apiPost('intents_toggle',{id:${i.id}},()=>{toast('Toggled','ok');loadIntents()})">${i.active?'DISABLE':'ENABLE'}</button>
|
||
<button class="btn btn-xs btn-yellow" onclick='intentModal(${i.id},"${esc(i.intent_name)}","${esc(i.pattern)}",${JSON.stringify(i.response_template||"")},"${esc(i.action_type)}",${i.priority},${i.active})'>EDIT</button>
|
||
<button class="btn btn-xs btn-red" onclick="apiPost('intents_delete',{id:${i.id}},()=>{toast('Deleted','ok');loadIntents()})">DEL</button>
|
||
</div></td>`, null, null);
|
||
}
|
||
|
||
function intentModal(id=0, name='', pattern='', response='', type='response', priority=5, active=1) {
|
||
openModal(id?'EDIT INTENT':'ADD INTENT', `
|
||
<div class="form-row"><label>INTENT NAME</label><input id="i-name" value="${esc(name)}" placeholder="my_intent"></div>
|
||
<div class="form-row"><label>REGEX PATTERN</label><input id="i-pat" value="${esc(pattern)}" placeholder="(?i)(keyword).*(match)"></div>
|
||
<div class="form-row"><label>RESPONSE TEMPLATE</label><textarea id="i-resp">${esc(response)}</textarea></div>
|
||
<div class="form-row"><label>ACTION TYPE</label>
|
||
<select id="i-type">${['response','action','query'].map(t=>`<option value="${t}" ${t===type?'selected':''}>${t.toUpperCase()}</option>`).join('')}</select>
|
||
</div>
|
||
<div class="form-row"><label>PRIORITY (1-10)</label><input id="i-pri" type="number" min="1" max="10" value="${priority}"></div>
|
||
<div class="form-row"><label>ACTIVE</label>
|
||
<select id="i-act"><option value="1" ${active?'selected':''}>YES</option><option value="0" ${!active?'selected':''}>NO</option></select>
|
||
</div>
|
||
<input type="hidden" id="i-id" value="${id}">
|
||
`, () => {
|
||
const data = {id: document.getElementById('i-id').value, intent_name: document.getElementById('i-name').value, pattern: document.getElementById('i-pat').value, response_template: document.getElementById('i-resp').value, action_type: document.getElementById('i-type').value, priority: document.getElementById('i-pri').value, active: document.getElementById('i-act').value};
|
||
apiPost('intents_save', data, ()=>{ toast('Saved','ok'); closeModal(); loadIntents(); });
|
||
});
|
||
}
|
||
|
||
function intentTestModal() {
|
||
openModal('TEST INTENT PATTERN', `
|
||
<div class="form-row" style="display:flex;gap:8px;align-items:center">
|
||
<input id="t-phrase" placeholder="say something JARVIS should handle…" style="flex:1" onkeydown="if(event.key==='Enter')runIntentTest()">
|
||
<button class="btn btn-sm btn-yellow" onclick="runIntentTest()">TEST</button>
|
||
</div>
|
||
<div id="t-result" style="margin-top:12px;padding:10px;background:var(--bg2);border:1px solid var(--border);border-radius:3px;min-height:60px;font-size:0.75rem;color:var(--fg2);line-height:1.6">Enter a phrase and click TEST or press Enter.</div>
|
||
`, ()=>closeModal(), 'CLOSE');
|
||
setTimeout(()=>document.getElementById('t-phrase')?.focus(), 60);
|
||
}
|
||
|
||
function runIntentTest() {
|
||
const phrase = (document.getElementById('t-phrase')?.value || '').trim();
|
||
if (!phrase) return;
|
||
const resultEl = document.getElementById('t-result');
|
||
if (!resultEl) return;
|
||
// Sort by priority desc, id asc (same order as PHP KBEngine::match)
|
||
const sorted = [..._allIntents].filter(i=>i.active).sort((a,b)=> b.priority-a.priority || a.id-b.id);
|
||
let matched = null;
|
||
for (const i of sorted) {
|
||
try {
|
||
let pat = i.pattern.replace(/^\(\?i\)/, '');
|
||
const re = new RegExp(pat, 'i');
|
||
if (re.test(phrase)) { matched = i; break; }
|
||
} catch(e) { /* invalid regex, skip */ }
|
||
}
|
||
if (matched) {
|
||
resultEl.innerHTML = '<span style="color:var(--green)">✓ MATCHED:</span> <strong>' + esc(matched.intent_name) + '</strong> (priority ' + matched.priority + ' · ' + matched.action_type + ')<br><span style="color:var(--yellow)">Pattern:</span> <code style="color:var(--yellow)">' + esc(matched.pattern) + '</code><br><span style="color:var(--cyan)">Response:</span> ' + esc((matched.response_template||'[action handler in chat.php]').substring(0,300));
|
||
} else {
|
||
resultEl.innerHTML = '<span style="color:var(--red)">✗ NO MATCH</span> — this phrase falls through to Ollama → Groq → Claude.';
|
||
}
|
||
}
|
||
|
||
// ── SITES ─────────────────────────────────────────────────────────────────────
|
||
async function loadSites() {
|
||
document.getElementById('sites-content').innerHTML='<div class="loading">SCANNING...</div>';
|
||
const sites = await api('sites_list');
|
||
if (!sites.length) { document.getElementById('sites-content').innerHTML='<div class="empty">NO SITE DATA</div>'; return; }
|
||
const labels = {
|
||
'jarvis':'jarvis.orbishosting.com','tomsjavajive':'tomsjavajive.com',
|
||
'epictravelexp':'epictravelexpeditions.com','parkersling':'parkerslingshot',
|
||
'orbishosting':'orbishosting.com','orbisportal':'orbis.orbishosting.com','tomtomgames':'tomtomgames.com'
|
||
};
|
||
let cards = sites.map(s => {
|
||
const up = s.fact_value==='up';
|
||
return `<div class="stat-card" style="border-left:3px solid ${up?'var(--green)':'var(--red)'}">
|
||
<div class="lbl">${esc(labels[s.fact_key]||s.fact_key)}</div>
|
||
<div class="val ${up?'ok':'danger'}" style="font-size:1.2rem">${up?'ONLINE':'OFFLINE'}</div>
|
||
<div class="sub">checked ${ago(s.updated_at)}</div>
|
||
</div>`;
|
||
}).join('');
|
||
document.getElementById('sites-content').innerHTML = `<div class="stat-grid">${cards}</div>`;
|
||
}
|
||
|
||
// ── USERS ─────────────────────────────────────────────────────────────────────
|
||
async function loadUsers() {
|
||
scanShell('users-tbl', ['USERNAME','DISPLAY NAME','LAST SEEN','CREATED','ACTIONS'], null, null);
|
||
const users = await api('users_list');
|
||
if (!users.length) { document.getElementById('users-tbl').innerHTML='<div class="empty">NO USERS</div>'; return; }
|
||
document.getElementById('users-tbl').innerHTML = `<table>
|
||
<thead><tr><th>USERNAME</th><th>DISPLAY NAME</th><th>LAST SEEN</th><th>CREATED</th><th>ACTIONS</th></tr></thead>
|
||
<tbody id="users-tbl-tbody"></tbody></table>`;
|
||
progressiveRender(users, 'users-tbl-tbody', u =>
|
||
`<td>${esc(u.username)}</td>
|
||
<td>${esc(u.display_name||'—')}</td>
|
||
<td class="ts">${ago(u.last_seen)}</td>
|
||
<td class="ts">${ts(u.created_at)}</td>
|
||
<td><button class="btn btn-xs btn-yellow" onclick="userModal(${u.id},'${esc(u.display_name||'')}')">EDIT</button></td>`,
|
||
null, null);
|
||
}
|
||
|
||
function userModal(id, display) {
|
||
openModal('EDIT USER', `
|
||
<div class="form-row"><label>DISPLAY NAME</label><input id="u-dn" value="${esc(display)}"></div>
|
||
<div class="form-row"><label>NEW PASSWORD (leave blank to keep)</label><input id="u-pw" type="password" placeholder="••••••••"></div>
|
||
<input type="hidden" id="u-id" value="${id}">
|
||
`, () => {
|
||
const data = {id: document.getElementById('u-id').value, display_name: document.getElementById('u-dn').value, password: document.getElementById('u-pw').value};
|
||
apiPost('users_save', data, ()=>{ toast('Saved','ok'); closeModal(); loadUsers(); });
|
||
});
|
||
}
|
||
|
||
// ── MODAL ─────────────────────────────────────────────────────────────────────
|
||
function openModal(title, body, saveCb, saveLabel) {
|
||
document.getElementById('modalTitle').textContent = title;
|
||
document.getElementById('modalBody').innerHTML = body;
|
||
_modalCb = saveCb;
|
||
const saveBtn = document.getElementById('modalSave');
|
||
if (saveBtn) saveBtn.textContent = saveLabel || 'SAVE';
|
||
document.getElementById('modalBg').classList.add('open');
|
||
const first = document.querySelector('#modalBody input, #modalBody textarea, #modalBody select');
|
||
if (first) setTimeout(()=>first.focus(), 50);
|
||
}
|
||
|
||
function closeModal() { document.getElementById('modalBg').classList.remove('open'); _modalCb=null; const sb=document.getElementById('modalSave'); if(sb) sb.textContent='SAVE'; }
|
||
function modalSave() { if (_modalCb) _modalCb(); }
|
||
|
||
document.getElementById('modalBg').addEventListener('click', e => { if (e.target===document.getElementById('modalBg')) closeModal(); });
|
||
document.addEventListener('keydown', e => { if (e.key==='Escape') closeModal(); });
|
||
document.addEventListener('keydown', e => { if (e.key==='Enter' && e.ctrlKey && document.getElementById('modalBg').classList.contains('open')) modalSave(); });
|
||
|
||
// ── HOME ASSISTANT ────────────────────────────────────────────────────────────
|
||
let _haEntities = [];
|
||
|
||
async function loadHA() {
|
||
document.getElementById('ha-tbl').innerHTML = '<div class="loading">SCANNING...</div>';
|
||
const domain = document.getElementById('ha-domain')?.value || '';
|
||
const data = await api('ha_list', {domain});
|
||
_haEntities = data.entities || [];
|
||
// Populate domain filter
|
||
const sel = document.getElementById('ha-domain');
|
||
const cur = sel.value;
|
||
sel.innerHTML = '<option value="">ALL DOMAINS</option>' + (data.domains||[]).map(d=>`<option value="${esc(d)}" ${d===cur?'selected':''}>${esc(d).toUpperCase()} </option>`).join('');
|
||
if (cur) sel.value = cur;
|
||
const age = data.ts ? Math.floor((Date.now()/1000)-data.ts) : null;
|
||
document.getElementById('ha-count').textContent = `${_haEntities.length} ENTITIES${age!=null?' · CACHE '+age+'s AGO':''}`;
|
||
const _haTitleEl=document.getElementById('ha-title-count'); if(_haTitleEl) _haTitleEl.textContent=_haEntities.length.toLocaleString()+' TOTAL';
|
||
renderHATable(_haEntities);
|
||
}
|
||
|
||
let _haOnlyOn = false;
|
||
|
||
function setHAOnlyOn(onlyOn, btn) {
|
||
_haOnlyOn = onlyOn;
|
||
document.getElementById('ha-all-btn').classList.toggle('active', !onlyOn);
|
||
document.getElementById('ha-on-btn').classList.toggle('active', onlyOn);
|
||
filterHATable();
|
||
}
|
||
|
||
function filterHATable() {
|
||
const q = document.getElementById('ha-search')?.value.toLowerCase() || '';
|
||
const ON_STATES = ['on','home','open','playing','mowing','active','idle'];
|
||
let list = _haEntities;
|
||
if (_haOnlyOn) list = list.filter(e => ON_STATES.includes(e.state));
|
||
if (q) list = list.filter(e => (e.name||'').toLowerCase().includes(q)||(e.entity_id||'').toLowerCase().includes(q));
|
||
renderHATable(list);
|
||
}
|
||
|
||
function renderHATable(entities) {
|
||
const avail = entities.filter(e => e.state !== 'unavailable' && e.state !== 'unknown');
|
||
if (!avail.length) { document.getElementById('ha-tbl').innerHTML='<div class="empty">NO ENTITIES</div>'; return; }
|
||
const domainColors = {light:'#ffcc00',switch:'#00d4ff',media_player:'#ff8800',alarm_control_panel:'#ff3333',scene:'#00d4ff',lawn_mower:'#39ff14',water_heater:'#ff8800',fan:'#9b9bff'};
|
||
const domainIcon = {light:'\u{1F4A1}',switch:'\u{1F50C}',scene:'\u{1F3AC}',media_player:'\u{1F4FA}',alarm_control_panel:'\u{1F512}',lawn_mower:'\u{1F33F}',water_heater:'\u{1F321}',fan:'\u{1F4A8}'};
|
||
let rows = avail.map(e => {
|
||
const on = ['on','home','open','playing','mowing','armed_home','armed_away','armed_night','active'].includes(e.state);
|
||
const isScene = e.domain === 'scene';
|
||
const dc = domainColors[e.domain] || 'var(--dim)';
|
||
const icon = domainIcon[e.domain] || '•';
|
||
const stateLabel = isScene ? '—' : (on ? 'ON' : 'OFF');
|
||
const ctrl = isScene
|
||
? `<button class="btn btn-xs" onclick="haToggle('${e.entity_id.replace(/'/g,"\\'")}','${e.state}',this)">▶ RUN</button>`
|
||
: `<label style="position:relative;display:inline-block;width:30px;height:15px;cursor:pointer">
|
||
<input type="checkbox" style="opacity:0;width:0;height:0;position:absolute" ${on?'checked':''} onchange="haToggle('${e.entity_id.replace(/'/g,"\\'")}','${e.state}',this.parentElement)">
|
||
<span id="sl-${e.entity_id.replace(/[^a-z0-9]/gi,'_')}" style="position:absolute;inset:0;border-radius:8px;background:${on?'rgba(0,255,100,0.22)':'rgba(255,255,255,0.08)'};border:1px solid ${on?'var(--green)':'rgba(255,255,255,0.14)'};transition:all .18s">
|
||
<span style="position:absolute;left:${on?'17':'2'}px;top:2px;width:9px;height:9px;border-radius:50%;background:${on?'var(--green)':'var(--dim)'};transition:all .18s"></span>
|
||
</span>
|
||
</label>`;
|
||
return `<tr>
|
||
<td style="width:28px;text-align:center;font-size:0.85rem">${icon}</td>
|
||
<td><span style="color:${dc};font-size:0.58rem;letter-spacing:1px">${esc(e.domain)}</span></td>
|
||
<td>${esc(e.name||e.entity_id)}</td>
|
||
<td style="text-align:center"><span class="badge ${on?'badge-green':'badge-dim'}">${stateLabel}</span></td>
|
||
<td style="text-align:center">${ctrl}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
document.getElementById('ha-tbl').innerHTML = `<table>
|
||
<thead><tr><th></th><th>DOMAIN</th><th>NAME</th><th>STATE</th><th>CTRL</th></tr></thead>
|
||
<tbody>${rows}</tbody></table>`;
|
||
}
|
||
|
||
function haToggle(entityId, currentState, el) {
|
||
const ON_STATES = ['on','home','open','playing','mowing','armed_home','armed_away','armed_night','active'];
|
||
const wasOn = ON_STATES.includes(currentState);
|
||
el.style.opacity = '0.5';
|
||
apiPost('ha_toggle', {entity_id: entityId, state: currentState}, (res) => {
|
||
el.style.opacity = '1';
|
||
if (res.ok) {
|
||
// Optimistic update — flip state in cache so re-render shows new state immediately
|
||
const ent = _haEntities.find(e => e.entity_id === entityId);
|
||
if (ent) {
|
||
ent.state = wasOn ? 'off' : 'on';
|
||
filterHATable();
|
||
}
|
||
// Sync from ha_entities (real-time agent data) after 5s — enough time for HA to execute + push
|
||
setTimeout(loadHA, 5000);
|
||
} else {
|
||
toast('Toggle failed (code ' + (res.code||'?') + ')', 'err');
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── NEWS ──────────────────────────────────────────────────────────────────────
|
||
async function loadNews() {
|
||
document.getElementById('news-custom').innerHTML='<div class="loading">SCANNING...</div>';
|
||
document.getElementById('news-live').innerHTML='<div class="loading">SCANNING...</div>';
|
||
const data = await api('news_list');
|
||
|
||
// Custom entries
|
||
const custom = data.custom||[];
|
||
if (!custom.length) {
|
||
document.getElementById('news-custom').innerHTML='<div class="empty">NO CUSTOM ENTRIES</div>';
|
||
} else {
|
||
document.getElementById('news-custom').innerHTML = custom.map(c=>`
|
||
<div style="background:var(--surface);border:1px solid var(--border);padding:10px 12px;margin-bottom:8px;display:flex;align-items:center;gap:8px">
|
||
<div style="flex:1">
|
||
<div style="font-size:0.75rem">${esc(c.title)}</div>
|
||
${c.url?`<div style="font-size:0.6rem;color:var(--dim)">${esc(c.url)}</div>`:''}
|
||
</div>
|
||
<button class="btn btn-xs btn-yellow" onclick='newsCustomModal(${c.id},"${esc(c.title)}","${esc(c.url||"")}")'>EDIT</button>
|
||
<button class="btn btn-xs btn-red" onclick="apiPost('news_custom_delete',{id:${c.id}},()=>{toast('Deleted','ok');loadNews()})">DEL</button>
|
||
</div>`).join('');
|
||
}
|
||
|
||
// Live feed
|
||
const cats = data.news?.categories || {};
|
||
const all = Object.values(cats).flat().slice(0,30);
|
||
if (!all.length) {
|
||
document.getElementById('news-live').innerHTML='<div class="empty">NO FEED DATA</div>';
|
||
} else {
|
||
document.getElementById('news-live').innerHTML = all.map(n=>`
|
||
<div style="border-bottom:1px solid var(--border);padding:8px 0;font-size:0.72rem">
|
||
<div>${esc(n.title||'')}</div>
|
||
<div style="color:var(--dim);font-size:0.6rem">${esc(n.source||'')}${n.published?' · '+n.published:''}</div>
|
||
</div>`).join('');
|
||
}
|
||
}
|
||
|
||
function newsCustomModal(id=0, title='', url='') {
|
||
openModal(id?'EDIT CUSTOM NEWS':'ADD CUSTOM NEWS', `
|
||
<div class="form-row"><label>HEADLINE / TITLE</label><input id="nc-t" value="${esc(title)}"></div>
|
||
<div class="form-row"><label>URL (optional)</label><input id="nc-u" value="${esc(url)}" placeholder="https://..."></div>
|
||
<input type="hidden" id="nc-id" value="${id}">
|
||
`, () => {
|
||
apiPost('news_custom_save',{id:document.getElementById('nc-id').value,title:document.getElementById('nc-t').value,url:document.getElementById('nc-u').value},
|
||
()=>{ toast('Saved','ok'); closeModal(); loadNews(); });
|
||
});
|
||
}
|
||
|
||
// ── PROXMOX VMs ───────────────────────────────────────────────────────────────
|
||
async function loadVMs() {
|
||
document.getElementById('vms-tbl').innerHTML='<div class="loading">SCANNING...</div>';
|
||
const data = await api('vms_list');
|
||
const vms = [...(data.vms||[]), ...(data.containers||[])];
|
||
const _vmsCntEl=document.getElementById('vms-count'); if(_vmsCntEl){const _vmRun=vms.filter(v=>v.status==='running').length;_vmsCntEl.textContent=`${_vmRun} RUNNING / ${vms.length} TOTAL`;}
|
||
if (!vms.length) { document.getElementById('vms-tbl').innerHTML='<div class="empty">NO VM DATA — Proxmox cache empty, refreshes every 5 min</div>'; return; }
|
||
|
||
const ni = data.node_info||{};
|
||
function nodeBar(info) {
|
||
if (!info) return '';
|
||
const cc = info.cpu_pct>80?'var(--red)':info.cpu_pct>60?'var(--yellow)':'var(--green)';
|
||
const mc = info.mem_pct>80?'var(--red)':info.mem_pct>60?'var(--yellow)':'var(--cyan)';
|
||
return `CPU <span style="color:${cc}">${info.cpu_pct}%</span> · `+
|
||
`RAM <span style="color:${mc}">${info.mem_used_gb}/${info.mem_total_gb}GB (${info.mem_pct}%)</span> · `+
|
||
`Disk ${info.disk_used_gb}/${info.disk_total_gb}GB · Up ${info.uptime}`;
|
||
}
|
||
|
||
function meter(pct, warn=70, crit=85) {
|
||
if (pct == null) return '<span style="color:var(--dim)">—</span>';
|
||
const c = pct>=crit?'var(--red)':pct>=warn?'var(--yellow)':'var(--green)';
|
||
return `<div style="display:flex;align-items:center;gap:5px">
|
||
<div style="width:44px;height:4px;background:var(--border);flex-shrink:0">
|
||
<div style="width:${Math.min(pct,100)}%;height:100%;background:${c}"></div>
|
||
</div>
|
||
<span style="color:${c};font-size:0.65rem">${pct}%</span>
|
||
</div>`;
|
||
}
|
||
|
||
// Group by node
|
||
const nodes = [...new Set(vms.map(v=>v.node||'pve'))].sort();
|
||
let html = '';
|
||
for (const node of nodes) {
|
||
const nodeVMs = vms.filter(v=>(v.node||'pve')===node);
|
||
const info = ni[node];
|
||
html += `<div style="font-family:var(--font-mono);font-size:0.6rem;color:var(--cyan);letter-spacing:2px;padding:10px 12px 4px;border-top:1px solid var(--border2)">`+
|
||
`${node.toUpperCase()} NODE${info?` — ${nodeBar(info)}`:''}</div>`;
|
||
html += nodeVMs.map(v => {
|
||
const run = v.status==='running';
|
||
const typeColor = v.type==='lxc'?'var(--orange)':'var(--cyan)';
|
||
const memLabel = v.mem_used_mb && v.mem_total_mb
|
||
? `${Math.round(v.mem_used_mb/1024*10)/10}/${Math.round(v.mem_total_mb/1024*10)/10}GB`
|
||
: '—';
|
||
return `<tr>
|
||
<td style="color:var(--dim)">${v.vmid}</td>
|
||
<td><strong>${esc(v.name)}</strong></td>
|
||
<td><span style="color:${typeColor};font-size:0.6rem">${(v.type||'qemu').toUpperCase()}</span></td>
|
||
<td>${run?'<span class="badge badge-green">RUNNING</span>':'<span class="badge badge-red">'+esc(v.status||'stopped').toUpperCase()+'</span>'}</td>
|
||
<td>${meter(v.cpu_pct,50,80)} <span style="font-size:0.6rem;color:var(--dim)">${v.cpus||1}vCPU</span></td>
|
||
<td>${meter(v.mem_pct)} <span style="font-size:0.6rem;color:var(--dim)">${memLabel}</span></td>
|
||
<td style="font-size:0.65rem;color:var(--dim)">${v.disk_gb||'—'}GB</td>
|
||
<td class="ts">${run?(v.uptime_human||'—'):'—'}</td>
|
||
<td style="font-size:0.6rem;color:var(--dim)">↓${v.netin_fmt||'—'} ↑${v.netout_fmt||'—'}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
document.getElementById('vms-tbl').innerHTML =
|
||
`<table><thead><tr><th>VMID</th><th>NAME</th><th>TYPE</th><th>STATUS</th><th>CPU</th><th>RAM</th><th>DISK</th><th>UPTIME</th><th>NETWORK</th></tr></thead>
|
||
<tbody>${html}</tbody></table>`;
|
||
}
|
||
|
||
// ── BACKUPS ────────────────────────────────────────────────────────────────────
|
||
let _backupPollTimer = null;
|
||
|
||
function fmtSize(bytes) {
|
||
if (bytes >= 1073741824) return (bytes/1073741824).toFixed(1) + ' GB';
|
||
if (bytes >= 1048576) return (bytes/1048576).toFixed(1) + ' MB';
|
||
return (bytes/1024).toFixed(0) + ' KB';
|
||
}
|
||
|
||
async function loadBackups() {
|
||
const list = document.getElementById('backups-list');
|
||
list.innerHTML = '<div class="loading">SCANNING...</div>';
|
||
const data = await api('backups_list');
|
||
|
||
// Show/hide running status bar
|
||
const bar = document.getElementById('backup-status-bar');
|
||
if (data.running) {
|
||
bar.style.display = 'block';
|
||
document.getElementById('backup-status-msg').textContent = 'BACKUP IN PROGRESS...';
|
||
document.getElementById('backup-log-tail').textContent = data.last_log || '';
|
||
// Animate progress bar
|
||
let pct = parseInt(document.getElementById('backup-progress-bar').style.width) || 5;
|
||
pct = Math.min(pct + 8, 90);
|
||
document.getElementById('backup-progress-bar').style.width = pct + '%';
|
||
document.getElementById('backup-progress-bar').style.background = 'var(--yellow)';
|
||
if (!_backupPollTimer) _backupPollTimer = setInterval(loadBackups, 4000);
|
||
} else {
|
||
if (_backupPollTimer) { clearInterval(_backupPollTimer); _backupPollTimer = null; }
|
||
if (bar.style.display !== 'none') {
|
||
// Just finished
|
||
document.getElementById('backup-status-msg').textContent = '✓ BACKUP COMPLETE';
|
||
document.getElementById('backup-progress-bar').style.width = '100%';
|
||
document.getElementById('backup-progress-bar').style.background = 'var(--green)';
|
||
document.getElementById('backup-log-tail').textContent = data.last_log || '';
|
||
setTimeout(() => { bar.style.display = 'none'; }, 4000);
|
||
}
|
||
document.getElementById('backupRunBtn').disabled = false;
|
||
document.getElementById('backupRunBtn').textContent = '▶ RUN BACKUP NOW';
|
||
}
|
||
|
||
const files = data.files || [];
|
||
if (!files.length) {
|
||
list.innerHTML = '<div class="empty">NO BACKUPS YET — click RUN BACKUP NOW to create the first one</div>';
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = `<table>
|
||
<thead><tr><th>DATE / TIME</th><th>FILENAME</th><th>SIZE</th><th>DOWNLOAD</th></tr></thead>
|
||
<tbody>${files.map((f, i) => `<tr class="agent-row" style="animation-delay:${i*60}ms">
|
||
<td style="color:${i===0?'var(--cyan)':'var(--text)'}">${f.date}${i===0?' <span style="font-size:0.55rem;color:var(--green)">● LATEST</span>':''}</td>
|
||
<td style="font-size:0.65rem;color:var(--dim)">${esc(f.file)}</td>
|
||
<td>${fmtSize(f.size)}</td>
|
||
<td><a href="?action=backup_download&file=${encodeURIComponent(f.file)}" class="btn btn-sm btn-green" style="display:inline-block;padding:4px 12px;font-size:0.65rem" download="${esc(f.file)}">↓ DOWNLOAD</a></td>
|
||
</tr>`).join('')}</tbody></table>`;
|
||
}
|
||
|
||
async function triggerBackup() {
|
||
const btn = document.getElementById('backupRunBtn');
|
||
btn.disabled = true; btn.textContent = 'STARTING...';
|
||
const bar = document.getElementById('backup-status-bar');
|
||
bar.style.display = 'block';
|
||
document.getElementById('backup-status-msg').textContent = 'BACKUP STARTING...';
|
||
document.getElementById('backup-progress-bar').style.width = '5%';
|
||
document.getElementById('backup-progress-bar').style.background = 'var(--yellow)';
|
||
document.getElementById('backup-log-tail').textContent = '';
|
||
|
||
const fd = new FormData(); fd.append('action','backup_trigger');
|
||
try {
|
||
const r = await fetch(location.href, {method:'POST', body:fd});
|
||
const d = await r.json();
|
||
if (d.ok) {
|
||
toast('Backup started — polling for completion...', 'ok');
|
||
btn.textContent = 'RUNNING...';
|
||
if (!_backupPollTimer) _backupPollTimer = setInterval(loadBackups, 4000);
|
||
} else {
|
||
toast(d.message || 'Already running', 'ok');
|
||
btn.disabled = false; btn.textContent = '▶ RUN BACKUP NOW';
|
||
}
|
||
} catch(e) { toast('Failed to start backup', 'err'); btn.disabled = false; btn.textContent = '▶ RUN BACKUP NOW'; }
|
||
}
|
||
|
||
// ── AUTO-LOGIN CHECK (PHP session) ───────────────────────────────────────────
|
||
<?php if (loggedIn()): ?>
|
||
document.getElementById('loginWrap').style.display='none';
|
||
document.getElementById('app').style.display='flex';
|
||
document.getElementById('adminUser').textContent = '<?= htmlspecialchars($_SESSION['admin_name'] ?? $_SESSION['admin_user']) ?>'.toUpperCase();
|
||
initApp();
|
||
<?php endif; ?>
|
||
// ── EMAIL ───────────────────────────────────────────────────────────────────
|
||
let _emailCurrentTab = 'inbox';
|
||
|
||
function emailShowTab(tab) {
|
||
_emailCurrentTab = tab;
|
||
document.getElementById('email-inbox-view').style.display = tab==='inbox' ? '' : 'none';
|
||
document.getElementById('email-actions-view').style.display = tab==='actions' ? '' : 'none';
|
||
document.getElementById('email-tab-inbox').style.background = tab==='inbox' ? 'rgba(0,212,255,0.15)' : '';
|
||
document.getElementById('email-tab-actions').style.background = tab==='actions' ? 'rgba(0,212,255,0.15)' : '';
|
||
if (tab === 'actions') loadEmailActionItems();
|
||
else loadEmailInbox();
|
||
}
|
||
|
||
async function loadEmailInbox(force=false) {
|
||
const acct = document.getElementById('email-acct-filter')?.value || 'all';
|
||
const el = document.getElementById('email-tbl');
|
||
if (el) el.innerHTML = '<div class="loading">FETCHING EMAIL…</div>';
|
||
const d = await api('email_inbox', {account: acct, ...(force?{force:1}:{})});
|
||
if (d.error) { el.innerHTML = `<div class="loading text-red">${d.error}</div>`; return; }
|
||
// Update action item badge
|
||
const badge = document.getElementById('email-ai-badge');
|
||
if (badge && d.action_items_count) badge.textContent = d.action_items_count; else if(badge) badge.textContent = '';
|
||
const msgs = d.summary?.recent || [];
|
||
if (!msgs.length) { el.innerHTML='<div class="loading">No messages.</div>'; return; }
|
||
const rows = msgs.map(m => {
|
||
const ai = m.action_type ? `<span style="background:${m.action_type==='appointment'?'var(--cyan)':'var(--orange)'};color:#000;border-radius:3px;padding:0 4px;font-size:0.55rem">${m.action_type.toUpperCase()}</span> ` : '';
|
||
const unread = m.unread ? `<span style="color:var(--cyan);font-weight:700">●</span> ` : '';
|
||
const acctBadge = m.account ? `<span style="color:var(--text-dim);font-size:0.58rem">[${m.account.toUpperCase()}]</span>` : '';
|
||
return `<tr${m.unread?' style="background:rgba(0,212,255,0.04)"':''}>
|
||
<td style="width:16px">${unread}</td>
|
||
<td style="max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(m.from_name||m.from_email||'')}</td>
|
||
<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${ai}${esc(m.subject||'')}</td>
|
||
<td style="color:var(--text-dim);font-size:0.62rem;white-space:nowrap">${esc(m.date||'')} ${acctBadge}</td>
|
||
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-dim);font-size:0.62rem">${esc((m.preview||'').substring(0,120))}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
el.innerHTML = `<table><thead><tr><th></th><th>FROM</th><th>SUBJECT</th><th>DATE</th><th>PREVIEW</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||
}
|
||
|
||
async function loadEmailActionItems() {
|
||
const el = document.getElementById('email-actions-tbl');
|
||
if (!el) return;
|
||
const d = await api('email_action_items');
|
||
const items = d.action_items || [];
|
||
const badge = document.getElementById('email-ai-badge');
|
||
if (badge) badge.textContent = items.length || '';
|
||
if (!items.length) { el.innerHTML='<div class="loading">No action items pending — inbox is clear.</div>'; return; }
|
||
const rows = items.map(it => {
|
||
const typeColor = it.action_type==='appointment' ? 'var(--cyan)' : 'var(--orange)';
|
||
const sugDate = it.suggested_date ? `<input type="date" id="ead-${it.id}" value="${it.suggested_date}" style="background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:2px 4px;font-size:0.6rem;width:110px">` : `<input type="date" id="ead-${it.id}" style="background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:2px 4px;font-size:0.6rem;width:110px">`;
|
||
const titleIn = `<input id="eat-${it.id}" value="${esc((it.suggested_title||it.subject||'').substring(0,80))}" style="background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:2px 6px;font-size:0.65rem;width:200px">`;
|
||
const btnTask = `<button class="btn btn-xs" style="border-color:var(--orange);color:var(--orange)" onclick="emailMakeTask(${it.id})">+ TASK</button>`;
|
||
const btnAppt = `<button class="btn btn-xs" style="border-color:var(--cyan);color:var(--cyan)" onclick="emailMakeAppt(${it.id})">📅 APPT</button>`;
|
||
const btnDismiss = `<button class="btn btn-xs" onclick="emailDismiss(${it.id})">✗ DISMISS</button>`;
|
||
return `<tr>
|
||
<td style="white-space:nowrap"><span style="background:${typeColor};color:#000;border-radius:3px;padding:1px 5px;font-size:0.6rem">${it.action_type.toUpperCase()}</span></td>
|
||
<td style="max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(it.from_name||it.from_email||'')}</td>
|
||
<td>${titleIn}</td>
|
||
<td>${sugDate}</td>
|
||
<td style="white-space:nowrap">${btnTask} ${btnAppt} ${btnDismiss}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
el.innerHTML = `<table><thead><tr><th>TYPE</th><th>FROM</th><th>TITLE</th><th>DATE</th><th>ACTIONS</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||
}
|
||
|
||
function emailMakeTask(id) {
|
||
const title = document.getElementById('eat-'+id)?.value || '';
|
||
const due = document.getElementById('ead-'+id)?.value || '';
|
||
apiPost('email_create_task',{id,title,due_date:due},()=>{ toast('Task created','ok'); loadEmailActionItems(); loadTasks(); });
|
||
}
|
||
function emailMakeAppt(id) {
|
||
const title = document.getElementById('eat-'+id)?.value || '';
|
||
const dateVal = document.getElementById('ead-'+id)?.value || '';
|
||
const start = dateVal ? dateVal + 'T09:00' : '';
|
||
apiPost('email_create_appt',{id,title,start_at:start},()=>{ toast('Appointment created','ok'); loadEmailActionItems(); loadAppts(); });
|
||
}
|
||
function emailDismiss(id) {
|
||
apiPost('email_dismiss',{id},()=>{ toast('Dismissed','ok'); loadEmailActionItems(); });
|
||
}
|
||
|
||
// ── GMAIL TRIAGE ─────────────────────────────────────────────────────────────
|
||
const _TRIAGE_COLORS = {urgent:'var(--red)',action:'var(--orange)',reply:'var(--cyan)',meeting:'#a78bfa',info:'var(--text-dim)',promo:'rgba(255,255,255,0.25)',spam:'rgba(255,255,255,0.15)'};
|
||
|
||
async function loadTriage() {
|
||
const el = document.getElementById('triage-tbl');
|
||
if (!el) return;
|
||
el.innerHTML = '<div class="loading">LOADING...</div>';
|
||
const filter = document.getElementById('triage-filter')?.value || 'priority';
|
||
const d = await api('triage_list', {filter, limit: 100});
|
||
const items = d.items || [];
|
||
const counts = d.counts || {};
|
||
|
||
document.getElementById('triage-count').textContent = `${items.length} ITEMS`;
|
||
const sumEl = document.getElementById('triage-summary');
|
||
if (counts && sumEl) {
|
||
sumEl.style.display = 'flex';
|
||
sumEl.innerHTML = `<span style="color:var(--red)">URGENT: ${counts.urgent||0}</span>`
|
||
+ `<span style="color:var(--orange)">ACTION: ${counts.action||0}</span>`
|
||
+ `<span style="color:var(--cyan)">REPLY: ${counts.reply||0}</span>`
|
||
+ `<span style="color:#a78bfa">MEETING: ${counts.meeting||0}</span>`
|
||
+ `<span style="color:var(--text-dim);margin-left:auto">PENDING: ${counts.pending||0}</span>`;
|
||
}
|
||
|
||
if (!items.length) {
|
||
el.innerHTML = '<div class="loading">No triage items matching filter. Run a triage to populate.</div>';
|
||
return;
|
||
}
|
||
|
||
const rows = items.map(it => {
|
||
const catColor = _TRIAGE_COLORS[it.category] || 'var(--text-dim)';
|
||
const catBadge = `<span style="color:${catColor};font-weight:700">${(it.category||'').toUpperCase()}</span>`;
|
||
const hasDraft = it.draft_reply && it.draft_reply.trim().length > 5;
|
||
const draftBtn = hasDraft ? `<button class="btn btn-xs" style="border-color:var(--cyan);color:var(--cyan)" onclick="triageViewDraft(${it.id})">VIEW DRAFT</button> ` : '';
|
||
return `<tr>
|
||
<td style="width:60px">${catBadge}</td>
|
||
<td style="width:30px;text-align:center;color:${it.priority>=8?'var(--red)':it.priority>=5?'var(--orange)':'var(--text-dim)'}">${it.priority||0}</td>
|
||
<td style="max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.65rem">${esc(it.from_name||it.from_email||'')}</td>
|
||
<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(it.subject||'')}</td>
|
||
<td style="max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-dim);font-size:0.62rem">${esc(it.summary||'')}</td>
|
||
<td style="white-space:nowrap">
|
||
${draftBtn}
|
||
<button class="btn btn-xs btn-green" onclick="triageMarkDone(${it.id})">✓ DONE</button>
|
||
<button class="btn btn-xs" onclick="triageDismiss(${it.id})">✗</button>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
el.innerHTML = `<table><thead><tr>
|
||
<th>CATEGORY</th><th>PRI</th><th>FROM</th><th>SUBJECT</th><th>SUMMARY</th><th>ACTIONS</th>
|
||
</tr></thead><tbody>${rows}</tbody></table>`;
|
||
}
|
||
|
||
async function triageRunNow(account = 'gmail') {
|
||
const d = await api('triage_run', {account, max: 25});
|
||
if (d.job_id) {
|
||
toast('Triage job started — Job #' + d.job_id, 'ok');
|
||
setTimeout(() => loadTriage(), 3000);
|
||
} else {
|
||
toast('Failed to start triage: ' + (d.error || 'Arc Reactor offline'), 'err');
|
||
}
|
||
}
|
||
|
||
async function triageDismiss(id) {
|
||
await apiPost('triage_action', {id, action: 'dismissed'}, () => loadTriage());
|
||
}
|
||
|
||
async function triageMarkDone(id) {
|
||
await apiPost('triage_action', {id, action: 'done'}, () => { toast('Marked done', 'ok'); loadTriage(); });
|
||
}
|
||
|
||
function triageViewDraft(id) {
|
||
api('triage_list', {filter: 'all', limit: 200}).then(d => {
|
||
const item = (d.items || []).find(i => i.id == id);
|
||
if (!item) return;
|
||
openModal('DRAFT REPLY — ' + esc(item.subject||''), `
|
||
<div style="font-size:0.65rem;color:var(--text-dim);margin-bottom:10px">FROM: ${esc(item.from_name||item.from_email||'')} · PRIORITY: ${item.priority}/10</div>
|
||
<div style="font-size:0.65rem;color:var(--text);margin-bottom:10px;padding:8px;background:rgba(0,212,255,0.04);border:1px solid var(--border);border-radius:3px">${esc(item.summary||'')}</div>
|
||
<div style="font-size:0.55rem;letter-spacing:2px;color:var(--dim);margin-bottom:6px">DRAFT REPLY</div>
|
||
<textarea id="triage-draft-edit" style="width:100%;height:160px;background:#060a0e;border:1px solid var(--border);color:var(--text);padding:8px;font-size:0.65rem;resize:vertical;border-radius:3px">${esc(item.draft_reply||'')}</textarea>
|
||
`, async () => {
|
||
await navigator.clipboard.writeText(document.getElementById('triage-draft-edit')?.value || '').catch(() => {});
|
||
await apiPost('triage_action', {id, action: 'replied'}, () => { toast('Copied & marked replied', 'ok'); loadTriage(); });
|
||
}, 'COPY & MARK REPLIED');
|
||
});
|
||
}
|
||
|
||
// ── PLANNER ─────────────────────────────────────────────────────────────────
|
||
const _PRI_COLOR = {urgent:'var(--red)',high:'var(--orange)',normal:'var(--text)',low:'var(--border2)'};
|
||
|
||
async function loadTasks() {
|
||
const status = document.getElementById('task-status-filter')?.value || '';
|
||
const cat = document.getElementById('task-cat-filter')?.value || '';
|
||
const d = await api('task_list', {status, category:cat});
|
||
const tasks = d.tasks || [];
|
||
const el = document.getElementById('tasks-tbl');
|
||
if (!tasks.length) { el.innerHTML='<div class="loading">No tasks found.</div>'; return; }
|
||
const rows = tasks.map(t => {
|
||
const due = t.due_date ? `<span style="color:${new Date(t.due_date)<new Date()?'var(--red)':'var(--text-dim)'}">${t.due_date}</span>` : '—';
|
||
const pri = `<span style="color:${_PRI_COLOR[t.priority]||'var(--text)'};font-size:0.6rem">${t.priority.toUpperCase()}</span>`;
|
||
const done = t.status==='done'||t.status==='cancelled';
|
||
const doneBtnHtml = done ? '' : `<button class="btn btn-xs btn-green" onclick="taskDone(${t.id})">DONE</button> `;
|
||
const td = `style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap${done?';opacity:0.45;text-decoration:line-through':''}"`;
|
||
const tJson = JSON.stringify(t).replace(/"/g,'"');
|
||
return `<tr><td ${td}>${esc(t.title)}</td><td>${t.category}</td><td>${pri}</td><td>${due}</td>
|
||
<td style="font-size:0.6rem;color:var(--text-dim)">${t.status.replace('_',' ').toUpperCase()}</td>
|
||
<td style="white-space:nowrap">${doneBtnHtml}<button class="btn btn-xs" onclick='taskModal(${tJson})'>EDIT</button> <button class="btn btn-xs btn-red" onclick="taskDel(${t.id})">DEL</button></td></tr>`;
|
||
}).join('');
|
||
el.innerHTML = `<table><thead><tr><th>TITLE</th><th>CAT</th><th>PRI</th><th>DUE</th><th>STATUS</th><th>ACTIONS</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||
}
|
||
|
||
function taskModal(t={}) {
|
||
const id = t.id||0;
|
||
document.body.insertAdjacentHTML('beforeend',`<div class="modal-overlay" onclick="this.remove()">
|
||
<div class="modal" onclick="event.stopPropagation()">
|
||
<div class="modal-title">${id?'EDIT':'NEW'} TASK</div>
|
||
<label>TITLE *<input id="tm-title" value="${(t.title||'').replace(/"/g,'"')}" style="width:100%;margin-top:4px"></label>
|
||
<label style="margin-top:8px;display:block">NOTES<textarea id="tm-notes" rows="2" style="width:100%;margin-top:4px;background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:6px;resize:vertical">${esc(t.notes||'')}</textarea></label>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px">
|
||
<label>CATEGORY<select id="tm-cat" style="width:100%;margin-top:4px">
|
||
<option value="personal"${t.category==='personal'?' selected':''}>Personal</option>
|
||
<option value="work"${t.category==='work'?' selected':''}>Work</option>
|
||
<option value="todo"${t.category==='todo'?' selected':''}>Todo</option>
|
||
</select></label>
|
||
<label>PRIORITY<select id="tm-pri" style="width:100%;margin-top:4px">
|
||
<option value="low"${t.priority==='low'?' selected':''}>Low</option>
|
||
<option value="normal"${!t.priority||t.priority==='normal'?' selected':''}>Normal</option>
|
||
<option value="high"${t.priority==='high'?' selected':''}>High</option>
|
||
<option value="urgent"${t.priority==='urgent'?' selected':''}>Urgent</option>
|
||
</select></label>
|
||
<label>DUE DATE<input type="date" id="tm-due" value="${t.due_date||''}" style="width:100%;margin-top:4px"></label>
|
||
<label>STATUS<select id="tm-stat" style="width:100%;margin-top:4px">
|
||
<option value="pending"${!t.status||t.status==='pending'?' selected':''}>Pending</option>
|
||
<option value="in_progress"${t.status==='in_progress'?' selected':''}>In Progress</option>
|
||
<option value="done"${t.status==='done'?' selected':''}>Done</option>
|
||
<option value="cancelled"${t.status==='cancelled'?' selected':''}>Cancelled</option>
|
||
</select></label>
|
||
</div>
|
||
<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">
|
||
<button class="btn" onclick="this.closest('.modal-overlay').remove()">CANCEL</button>
|
||
<button class="btn btn-green" onclick="taskSave(${id})">SAVE</button>
|
||
</div>
|
||
</div></div>`);
|
||
}
|
||
|
||
function taskSave(id) {
|
||
apiPost('task_save',{id,title:document.getElementById('tm-title').value,notes:document.getElementById('tm-notes').value,
|
||
category:document.getElementById('tm-cat').value,priority:document.getElementById('tm-pri').value,
|
||
status:document.getElementById('tm-stat').value,due_date:document.getElementById('tm-due').value},
|
||
()=>{ document.querySelector('.modal-overlay')?.remove(); toast('Saved','ok'); loadTasks(); });
|
||
}
|
||
function taskDone(id){ apiPost('task_done',{id},()=>{toast('Marked done','ok');loadTasks();}); }
|
||
function taskDel(id){ if(!confirm('Delete task?'))return; apiPost('task_delete',{id},()=>{toast('Deleted','ok');loadTasks();}); }
|
||
|
||
async function loadAppts() {
|
||
const from = new Date().toISOString().slice(0,10);
|
||
const to = new Date(Date.now()+90*86400000).toISOString().slice(0,10);
|
||
const d = await api('appt_list', {from, to});
|
||
const appts = d.appointments || [];
|
||
const el = document.getElementById('appts-tbl');
|
||
if (!appts.length) { el.innerHTML='<div class="loading">No upcoming appointments.</div>'; return; }
|
||
const rows = appts.map(a => {
|
||
const start = new Date(a.start_at).toLocaleString('en-US',{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
|
||
const aJson = JSON.stringify(a).replace(/"/g,'"');
|
||
return `<tr><td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.title)}</td>
|
||
<td>${a.category}</td><td>${start}</td>
|
||
<td style="max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-dim)">${esc(a.location||'—')}</td>
|
||
<td style="white-space:nowrap"><button class="btn btn-xs" onclick='apptModal(${aJson})'>EDIT</button> <button class="btn btn-xs btn-red" onclick="apptDel(${a.id})">DEL</button></td></tr>`;
|
||
}).join('');
|
||
el.innerHTML = `<table><thead><tr><th>TITLE</th><th>CAT</th><th>START</th><th>LOCATION</th><th>ACTIONS</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||
}
|
||
|
||
function apptModal(a={}) {
|
||
const id=a.id||0;
|
||
const sv=a.start_at?a.start_at.slice(0,16):''; const ev=a.end_at?a.end_at.slice(0,16):'';
|
||
document.body.insertAdjacentHTML('beforeend',`<div class="modal-overlay" onclick="this.remove()">
|
||
<div class="modal" onclick="event.stopPropagation()">
|
||
<div class="modal-title">${id?'EDIT':'NEW'} APPOINTMENT</div>
|
||
<label>TITLE *<input id="am-title" value="${(a.title||'').replace(/"/g,'"')}" style="width:100%;margin-top:4px"></label>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px">
|
||
<label>START *<input type="datetime-local" id="am-start" value="${sv}" style="width:100%;margin-top:4px"></label>
|
||
<label>END<input type="datetime-local" id="am-end" value="${ev}" style="width:100%;margin-top:4px"></label>
|
||
<label>CATEGORY<select id="am-cat" style="width:100%;margin-top:4px">
|
||
<option value="personal"${!a.category||a.category==='personal'?' selected':''}>Personal</option>
|
||
<option value="work"${a.category==='work'?' selected':''}>Work</option>
|
||
<option value="medical"${a.category==='medical'?' selected':''}>Medical</option>
|
||
<option value="other"${a.category==='other'?' selected':''}>Other</option>
|
||
</select></label>
|
||
<label>LOCATION<input id="am-loc" value="${(a.location||'').replace(/"/g,'"')}" placeholder="optional" style="width:100%;margin-top:4px"></label>
|
||
</div>
|
||
<label style="margin-top:8px;display:block">NOTES<textarea id="am-desc" rows="2" style="width:100%;margin-top:4px;background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:6px;resize:vertical">${esc(a.description||'')}</textarea></label>
|
||
<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">
|
||
<button class="btn" onclick="this.closest('.modal-overlay').remove()">CANCEL</button>
|
||
<button class="btn btn-green" onclick="apptSave(${id})">SAVE</button>
|
||
</div>
|
||
</div></div>`);
|
||
}
|
||
|
||
function apptSave(id){ apiPost('appt_save',{id,title:document.getElementById('am-title').value,description:document.getElementById('am-desc').value,
|
||
category:document.getElementById('am-cat').value,location:document.getElementById('am-loc').value,
|
||
start_at:document.getElementById('am-start').value,end_at:document.getElementById('am-end').value},
|
||
()=>{ document.querySelector('.modal-overlay')?.remove(); toast('Saved','ok'); loadAppts(); }); }
|
||
function apptDel(id){ if(!confirm('Delete appointment?'))return; apiPost('appt_delete',{id},()=>{toast('Deleted','ok');loadAppts();}); }
|
||
|
||
|
||
// ── CALENDAR FEEDS ────────────────────────────────────────────────────────────
|
||
async function loadCalFeeds() {
|
||
const feeds = await api('cal_feeds_list');
|
||
const el = document.getElementById('cal-feeds-tbl');
|
||
if (!feeds || !feeds.length) {
|
||
el.innerHTML = '<div class="loading">No calendar feeds configured. iCloud syncs via config.php credentials.</div>';
|
||
return;
|
||
}
|
||
const srcLabel = {google:'Google',icloud:'iCloud',outlook:'Outlook',caldav:'CalDAV',ics:'ICS URL'};
|
||
el.innerHTML = `<table class="data-tbl"><thead><tr>
|
||
<th>NAME</th><th>SOURCE</th><th>ICS URL</th><th>LAST SYNC</th><th>COUNT</th><th>STATUS</th><th>ACTIONS</th>
|
||
</tr></thead><tbody>${feeds.map(f=>`<tr>
|
||
<td>${esc(f.name)}</td>
|
||
<td><span class="badge badge-dim">${srcLabel[f.source]||f.source}</span></td>
|
||
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.6rem">${esc(f.ics_url||'—')}</td>
|
||
<td>${ts(f.last_sync)}</td>
|
||
<td>${f.last_count||0}</td>
|
||
<td>${f.active?'<span class="badge badge-green">ACTIVE</span>':'<span class="badge badge-red">PAUSED</span>'}</td>
|
||
<td><button class="btn btn-xs" onclick='calFeedModal(${JSON.stringify(f)})'>EDIT</button>
|
||
<button class="btn btn-xs btn-red" onclick="calFeedDel(${f.id})">DEL</button></td>
|
||
</tr>`).join('')}</tbody></table>`;
|
||
}
|
||
|
||
function calFeedModal(f={}) {
|
||
const id = f.id||0;
|
||
openModal(id?'EDIT CALENDAR FEED':'ADD CALENDAR FEED', `
|
||
<div class="form-row"><label>NAME</label><input id="cf-name" class="inp" value="${esc(f.name||'')}"></div>
|
||
<div class="form-row"><label>SOURCE</label>
|
||
<select id="cf-source" class="inp">
|
||
<option value="google"${f.source==='google'?' selected':''}>Google Calendar (ICS)</option>
|
||
<option value="ics"${f.source==='ics'||!f.source?' selected':''}>ICS URL</option>
|
||
<option value="caldav"${f.source==='caldav'?' selected':''}>CalDAV</option>
|
||
<option value="outlook"${f.source==='outlook'?' selected':''}>Outlook (ICS)</option>
|
||
</select></div>
|
||
<div class="form-row"><label>ICS URL</label><input id="cf-ics" class="inp" value="${esc(f.ics_url||'')}" placeholder="https://..."></div>
|
||
<div class="form-row"><label>USERNAME (optional)</label><input id="cf-user" class="inp" value="${esc(f.username||'')}"></div>
|
||
<div class="form-row"><label>PASSWORD (optional)</label><input id="cf-pass" class="inp" type="password" placeholder="leave blank to keep"></div>
|
||
<div class="form-row"><label>ACTIVE</label>
|
||
<select id="cf-active" class="inp"><option value="1"${f.active!==0?' selected':''}>Yes</option><option value="0"${f.active===0?' selected':''}>No</option></select></div>
|
||
`, () => calFeedSave(id));
|
||
}
|
||
|
||
async function calFeedSave(id) {
|
||
await apiPost('cal_feed_save', {
|
||
id, name: document.getElementById('cf-name').value,
|
||
source: document.getElementById('cf-source').value,
|
||
ics_url: document.getElementById('cf-ics').value,
|
||
username: document.getElementById('cf-user').value,
|
||
password: document.getElementById('cf-pass').value,
|
||
active: document.getElementById('cf-active').value
|
||
}, () => { toast('Saved','ok'); closeModal(); loadCalFeeds(); });
|
||
}
|
||
|
||
function calFeedDel(id) {
|
||
if (!confirm('Delete this calendar feed?')) return;
|
||
apiPost('cal_feed_delete', {id}, () => { toast('Deleted','ok'); loadCalFeeds(); });
|
||
}
|
||
|
||
async function syncCalNow() {
|
||
const btn = document.getElementById('calSyncBtn');
|
||
const status = document.getElementById('cal-sync-status');
|
||
btn.disabled = true; btn.textContent = '⟳ SYNCING...';
|
||
status.textContent = 'Syncing...';
|
||
apiPost('cal_sync_now', {}, d => {
|
||
status.textContent = d.ok ? Object.entries(d.results||{}).map(([k,v])=>k+': '+v).join(' | ') || 'Sync complete' : 'Error';
|
||
toast('Calendar sync complete','ok');
|
||
loadCalFeeds();
|
||
btn.disabled = false; btn.textContent = '⟳ SYNC NOW';
|
||
});
|
||
}
|
||
|
||
</script>
|
||
</body>
|
||
</html>
|