Files
jarvis/public_html/admin/index.php
T
myron f15225994a Phase 5: Guardian Mode — continuous awareness + proactive AI alerts
- reactor.py: v5.0.0; guardian_loop() background task scans all agents every
  120s; checks CPU/mem/disk thresholds + agent offline transitions + failed
  services; 10min cooldown per metric to debounce repeat alerts; AI analysis
  of critical findings via Claude; proactive chat injection into conversations
  table; handle_sitrep() generates Iron Man-style full/brief situation reports;
  handle_guardian_config() reads/writes guardian_config table; FastAPI endpoints:
  /guardian/status, /guardian/events, /guardian/events/{id}/ack, /guardian/chat
- arc.php: guardian_status, guardian_events, guardian_ack, guardian_chat actions
- chat.php: Tier 0.9d detects sitrep/situation report/how are things commands
- index.html: GUARDIAN tab in right panel; guardian event list with severity
  badges + AI analysis; ACK / ACK ALL buttons; Guardian badge in bottom bar
  (green/amber/red pulse based on unread critical events); proactive chat
  polling every 30s surfacing guardian-injected messages as JARVIS speech
- admin/index.php: GUARDIAN MODE tab; status bar + events table + config modal;
  inline SITREP runner with result modal; threshold configuration
2026-06-11 04:52:08 +00:00

2822 lines
162 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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']);
// ── VISION PROTOCOL ──────────────────────────────────────────────────
case 'vision_list':
$limit = min((int)($_GET['limit'] ?? 30), 100);
$agent = $_GET['agent'] ?? '';
$url = 'http://127.0.0.1:7474/screenshots?' . http_build_query(array_filter(['limit'=>$limit,'agent'=>$agent]));
$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 'vision_get':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$ch = curl_init('http://127.0.0.1:7474/screenshots/' . $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 'vision_delete':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$ch = curl_init('http://127.0.0.1:7474/screenshots/' . $id);
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]);
case 'vision_screenshot':
$agent = trim($_GET['agent'] ?? ''); if (!$agent) bad('Missing agent');
$analyze = ($_GET['analyze'] ?? '1') !== '0';
$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'=>'screenshot','payload'=>['agent'=>$agent,'analyze'=>$analyze],'priority'=>8,'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 'vision_purge':
$ch = curl_init('http://127.0.0.1:7474/screenshots/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]);
// ── GUARDIAN MODE ─────────────────────────────────────────────────
case 'guardian_status':
$ch = curl_init('http://127.0.0.1:7474/guardian/status');
curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>5]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw,true) ?: ['error'=>'unreachable']);
case 'guardian_events':
$limit = (int)($_GET['limit'] ?? 50);
$severity = $_GET['severity'] ?? '';
$url = 'http://127.0.0.1:7474/guardian/events?' . http_build_query(array_filter(['limit'=>$limit,'severity'=>$severity]));
$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 'guardian_ack':
$id = (int)($_GET['id'] ?? $_POST['id'] ?? 0);
if ($id) {
$ch = curl_init('http://127.0.0.1:7474/guardian/events/'.$id.'/ack');
} else {
$ch = curl_init('http://127.0.0.1:7474/guardian/events/ack_all');
}
curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>5,CURLOPT_POST=>true,CURLOPT_POSTFIELDS=>'']);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw,true) ?: ['ok'=>true]);
case 'guardian_sitrep':
$detail = $_GET['detail'] ?? 'full';
$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'=>'sitrep','payload'=>['detail'=>$detail,'provider'=>'claude'],'priority'=>9,'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 'guardian_config_set':
$key = $_POST['key'] ?? $_GET['key'] ?? '';
$val = $_POST['value'] ?? $_GET['value'] ?? '';
if (!$key) bad('Missing key');
$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'=>'guardian_config','payload'=>['action'=>'set','key'=>$key,'value'=>$val],'priority'=>9,'created_by'=>'admin']),
CURLOPT_HTTPHEADER=>['Content-Type: application/json'],
]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw,true) ?: ['ok'=>true]);
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-item" data-tab="vision" onclick="nav(this)">◈ VISION PROTOCOL</div>
<div class="nav-item" data-tab="guardian" onclick="nav(this)" id="nav-guardian">◈ GUARDIAN MODE</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>
&nbsp;<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>
&nbsp;<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>
&nbsp;<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>
&nbsp;<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>
<!-- VISION PROTOCOL -->
<div class="tab" id="tab-vision">
<div class="page-title">◈ VISION PROTOCOL — FIELD SCREENSHOTS</div>
<div style="display:flex;gap:10px;margin-bottom:16px;align-items:center;flex-wrap:wrap">
<button class="btn btn-sm btn-green" onclick="visionRunScreenshot()">◈ TAKE SCREENSHOT</button>
<button class="btn btn-sm" onclick="loadVision()">↻ REFRESH</button>
<select id="vision-agent-filter" class="inp" style="width:auto;padding:4px 8px;font-size:0.65rem" onchange="loadVision()">
<option value="">ALL AGENTS</option>
</select>
<button class="btn btn-sm" onclick="visionPurge()" style="margin-left:auto;opacity:0.7">PURGE OLD</button>
<div id="vision-count" style="font-family:var(--mono);font-size:0.65rem;color:var(--dim)"></div>
</div>
<div id="vision-gallery" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px">
<div class="loading">LOADING SCREENSHOTS...</div>
</div>
</div>
<!-- GUARDIAN MODE -->
<div class="tab" id="tab-guardian">
<div class="page-title" id="guardian-title">◈ GUARDIAN MODE</div>
<div id="guardian-status-bar" style="display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap">
<div class="stat-box" style="background:rgba(0,212,255,0.06);border:1px solid var(--border);padding:10px 18px;border-radius:4px">
<div style="font-size:0.55rem;letter-spacing:2px;color:var(--dim);margin-bottom:3px">STATUS</div>
<div id="guardian-stat-status" style="font-size: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:10px 18px;border-radius:4px">
<div style="font-size:0.55rem;letter-spacing:2px;color:var(--dim);margin-bottom:3px">LAST SCAN</div>
<div id="guardian-stat-scan" style="font-size:0.75rem;font-family:var(--mono)">—</div>
</div>
<div class="stat-box" style="background:rgba(255,34,68,0.06);border:1px solid var(--border);padding:10px 18px;border-radius:4px">
<div style="font-size:0.55rem;letter-spacing:2px;color:var(--dim);margin-bottom:3px">UNREAD</div>
<div id="guardian-stat-unread" style="font-size:1rem;font-family:var(--mono);color:var(--red)">—</div>
</div>
<div class="stat-box" style="background:rgba(0,212,255,0.06);border:1px solid var(--border);padding:10px 18px;border-radius:4px">
<div style="font-size:0.55rem;letter-spacing:2px;color:var(--dim);margin-bottom:3px">24H EVENTS</div>
<div id="guardian-stat-24h" style="font-size: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:10px 18px;border-radius:4px">
<div style="font-size:0.55rem;letter-spacing:2px;color:var(--dim);margin-bottom:3px">THRESHOLDS</div>
<div id="guardian-stat-thresh" style="font-size:0.62rem;font-family:var(--mono);line-height:1.6">—</div>
</div>
</div>
<div style="display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap;align-items:center">
<button class="btn btn-sm btn-green" onclick="guardianRunSitrep()">◈ RUN SITREP</button>
<button class="btn btn-sm" onclick="loadGuardian()">↻ REFRESH</button>
<button class="btn btn-sm" onclick="guardianAckAllAdmin()">✓ ACK ALL</button>
<select id="guardian-filter" class="inp" style="width:auto;padding:4px 8px;font-size:0.65rem" onchange="loadGuardian()">
<option value="">ALL EVENTS</option>
<option value="critical">CRITICAL</option>
<option value="warning">WARNING</option>
<option value="info">INFO</option>
</select>
<button class="btn btn-sm" onclick="guardianConfigModal()" style="margin-left:auto">⚙ CONFIGURE</button>
</div>
<div class="tbl-wrap" id="guardian-events-tbl"><div class="loading">LOADING...</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
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,
vision: loadVision,
guardian: loadGuardian,
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> &nbsp;(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(); });
}
// ── VISION PROTOCOL ──────────────────────────────────────────────────────────
let _visionAgents = [];
async function loadVision() {
const gallery = document.getElementById('vision-gallery');
if (!gallery) return;
gallery.innerHTML = '<div class="loading">LOADING...</div>';
const filter = document.getElementById('vision-agent-filter')?.value || '';
const shots = await api('vision_list', {limit: 30, agent: filter});
// Populate agent filter dropdown
const select = document.getElementById('vision-agent-filter');
if (select && Array.isArray(shots) && shots.length) {
const agents = [...new Set(shots.map(s => s.hostname).filter(Boolean))];
_visionAgents = agents;
const current = select.value;
select.innerHTML = '<option value="">ALL AGENTS</option>' +
agents.map(a => `<option value="${esc(a)}"${a===current?' selected':''}>${esc(a)}</option>`).join('');
}
document.getElementById('vision-count').textContent = Array.isArray(shots) ? shots.length + ' SCREENSHOTS' : '';
if (!Array.isArray(shots) || !shots.length) {
gallery.innerHTML = '<div class="loading">No screenshots yet. Click "TAKE SCREENSHOT" to capture a field station.</div>';
return;
}
gallery.innerHTML = shots.map(s => {
const ts = ts(s.created_at);
const has = s.file_size > 0;
const meth = (s.method || 'unknown').toUpperCase();
const dim = s.width && s.height ? `${s.width}×${s.height}` : '';
const analysis = (s.vision_analysis || '').substring(0, 180);
return `<div style="background:rgba(0,212,255,0.03);border:1px solid var(--border);border-radius:4px;overflow:hidden">
<div style="background:rgba(0,212,255,0.06);padding:8px 10px;display:flex;align-items:center;gap:8px">
<span style="font-family:var(--mono);font-size:0.65rem;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(s.hostname||'unknown')}</span>
<span style="font-family:var(--mono);font-size:0.55rem;color:var(--dim)">${meth}</span>
<button class="btn btn-xs" onclick="visionViewScreenshot(${s.id})" style="border-color:var(--cyan);color:var(--cyan)">VIEW</button>
<button class="btn btn-xs" onclick="visionDeleteShot(${s.id})">✗</button>
</div>
<div style="padding:8px 10px">
${has ? `<div style="background:#060a0e;border:1px solid var(--border);border-radius:3px;padding:5px;margin-bottom:8px;cursor:pointer;font-family:var(--mono);font-size:0.6rem;color:var(--text-dim);text-align:center" onclick="visionViewScreenshot(${s.id})">
◈ ${dim ? dim + ' · ' : ''}${Math.round((s.file_size||0)/1024)}KB IMAGE
</div>` : '<div style="font-size:0.6rem;color:var(--dim);margin-bottom:6px;font-family:var(--mono)">TEXT SNAPSHOT ONLY</div>'}
${analysis ? `<div style="font-size:0.62rem;line-height:1.5;color:var(--text-dim)">${esc(analysis)}${s.vision_analysis?.length>180?'…':''}</div>` : ''}
<div style="font-family:var(--mono);font-size:0.55rem;color:var(--border2);margin-top:6px">${ts}</div>
</div>
</div>`;
}).join('');
}
async function visionViewScreenshot(id) {
const d = await api('vision_get', {id});
if (d.error) { toast('Error: ' + d.error, 'err'); return; }
const imgHtml = d.image_b64
? `<img src="data:image/png;base64,${d.image_b64}" style="max-width:100%;border:1px solid var(--border);border-radius:3px;margin-bottom:12px">`
: '<div style="color:var(--dim);font-family:var(--mono);font-size:0.65rem;padding:12px 0">No image data — text snapshot only</div>';
openModal('◈ VISION — ' + esc(d.hostname||''), `
<div style="font-size:0.6rem;color:var(--dim);margin-bottom:10px;font-family:var(--mono)">
METHOD: ${esc(d.method||'')} · ${d.width&&d.height?d.width+'×'+d.height+' · ':''} ${Math.round((d.file_size||0)/1024)}KB · ${ts(d.created_at)}
</div>
${imgHtml}
${d.vision_analysis ? `<div style="font-size:0.55rem;letter-spacing:2px;color:var(--dim);margin-bottom:6px">VISION ANALYSIS</div>
<pre style="white-space:pre-wrap;font-size:0.65rem;line-height:1.6;color:var(--text);background:rgba(0,212,255,0.04);border:1px solid var(--border);padding:10px;border-radius:3px;max-height:300px;overflow-y:auto">${esc(d.vision_analysis)}</pre>` : ''}
`, null, null);
document.getElementById('modalSave').style.display = 'none';
}
async function visionRunScreenshot() {
// Pick agent from dropdown or prompt
const agents = _visionAgents;
if (!agents.length) {
toast('No agents online — check AGENTS tab', 'err'); return;
}
// Build select for agent
openModal('◈ TAKE SCREENSHOT', `
<div style="margin-bottom:14px">
<label style="font-size:0.65rem;color:var(--dim);display:block;margin-bottom:6px">SELECT FIELD AGENT</label>
<select id="vision-agent-sel" class="inp" style="width:100%">
${agents.map(a => `<option value="${esc(a)}">${esc(a)}</option>`).join('')}
</select>
</div>
<div>
<label style="font-size:0.65rem;color:var(--dim);display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="vision-analyze-chk" checked> Run Claude vision analysis
</label>
</div>
`, async () => {
const agent = document.getElementById('vision-agent-sel')?.value || '';
const analyze = document.getElementById('vision-analyze-chk')?.checked ? '1' : '0';
const d = await api('vision_screenshot', {agent, analyze});
if (d.job_id) {
toast('Screenshot job started — Job #' + d.job_id, 'ok');
setTimeout(() => loadVision(), 5000);
} else {
toast('Failed: ' + (d.error || 'Arc offline'), 'err');
}
closeModal();
}, 'CAPTURE');
}
async function visionDeleteShot(id) {
await api('vision_delete', {id});
toast('Deleted', 'ok');
loadVision();
}
async function visionPurge() {
await api('vision_purge');
toast('Old screenshots purged', 'ok');
loadVision();
}
// ── GUARDIAN MODE ────────────────────────────────────────────────────────────
const _SEV_COLOR = {critical:'var(--red)', warning:'#f5a623', info:'var(--cyan)'};
const _SEV_ICON = {critical:'⚠', warning:'⚡', info:'◈'};
const _EV_ICON = {agent_offline:'⚠',agent_online:'✓',cpu_high:'⚡',mem_high:'⚡',
disk_high:'💾',service_down:'✗',service_recovered:'✓',sitrep:'◈',anomaly:'◈'};
let _guardianJobPoll = null;
async function loadGuardian() {
const tbl = document.getElementById('guardian-events-tbl');
if (!tbl) return;
const [statusData, eventsData] = await Promise.all([
api('guardian_status'),
api('guardian_events', {limit: 100, severity: document.getElementById('guardian-filter')?.value || ''}),
]);
// Update status bar
const s = statusData || {};
const c = s.counts || {};
const thresh = s.thresholds || {};
setEl('guardian-stat-status', s.enabled ? '● ACTIVE' : '○ PAUSED', s.enabled ? 'var(--green)' : 'var(--red)');
setEl('guardian-stat-scan', s.last_scan ? new Date(s.last_scan+'Z').toLocaleString() : '—', '');
setEl('guardian-stat-unread', c.unread || '0', c.unread > 0 ? 'var(--red)' : 'var(--green)');
setEl('guardian-stat-24h', c.events_24h || '0', '');
setEl('guardian-stat-thresh', `CPU >${thresh.cpu}% · MEM >${thresh.memory}% · DISK >${thresh.disk}%`, '');
const navItem = document.getElementById('nav-guardian');
if (navItem && c.critical_unread > 0) navItem.style.color = 'var(--red)';
else if (navItem) navItem.style.color = '';
const events = Array.isArray(eventsData) ? eventsData : [];
if (!events.length) {
tbl.innerHTML = '<div class="loading" style="text-align:center;padding:30px">◈ ALL CLEAR — No events match filter</div>';
return;
}
const rows = events.map(ev => {
const sev = ev.severity || 'info';
const color = _SEV_COLOR[sev] || 'var(--text)';
const icon = _EV_ICON[ev.event_type] || '◈';
const acked = ev.acknowledged;
const ts = ts(ev.created_at);
return `<tr style="${acked?'opacity:0.4':''}">
<td style="width:70px">
<span style="color:${color};font-size:0.62rem;font-weight:700">${_SEV_ICON[sev]||'◈'} ${sev.toUpperCase()}</span>
</td>
<td style="width:70px;font-size:0.6rem;color:var(--dim)">${esc(ev.event_type||'').replace('_',' ').toUpperCase()}</td>
<td style="font-size:0.62rem">${esc(ev.hostname||ev.agent_id||'—')}</td>
<td style="max-width:300px">
<div style="font-size:0.65rem">${icon} ${esc(ev.message||'')}</div>
${ev.ai_analysis ? `<div style="font-size:0.58rem;color:var(--cyan);opacity:0.8;margin-top:3px;font-style:italic">${esc(ev.ai_analysis.substring(0,200))}</div>` : ''}
</td>
<td style="font-size:0.6rem;color:var(--dim);white-space:nowrap">${ts}</td>
<td style="white-space:nowrap">
${!acked ? `<button class="btn btn-xs" onclick="guardianAck(${ev.id})">ACK</button>` : '<span style="color:var(--border2);font-size:0.55rem">ACKED</span>'}
</td>
</tr>`;
}).join('');
tbl.innerHTML = `<table><thead><tr>
<th>SEVERITY</th><th>TYPE</th><th>AGENT</th><th>MESSAGE</th><th>TIME</th><th></th>
</tr></thead><tbody>${rows}</tbody></table>`;
}
async function guardianRunSitrep() {
const d = await api('guardian_sitrep', {detail: 'full'});
if (d.job_id) {
toast('SITREP job started — Job #' + d.job_id, 'ok');
if (_guardianJobPoll) clearInterval(_guardianJobPoll);
_guardianJobPoll = setInterval(async () => {
const job = await api('arc_job_get', {id: d.job_id});
if (job.status === 'done') {
clearInterval(_guardianJobPoll); _guardianJobPoll = null;
const r = typeof job.result === 'string' ? JSON.parse(job.result) : job.result;
openModal('◈ SITREP — ' + new Date().toLocaleString(), `
<div style="font-size:0.6rem;color:var(--dim);margin-bottom:10px;font-family:var(--mono)">
ONLINE: ${r.agents_online} · OFFLINE: ${r.agents_offline} · EVENTS 24H: ${r.events_24h} · CRITICAL: ${r.critical_24h}
</div>
<pre style="white-space:pre-wrap;font-size:0.7rem;line-height:1.7;color:var(--text)">${esc(r.sitrep||'')}</pre>
`, null, null);
document.getElementById('modalSave').style.display = 'none';
loadGuardian();
} else if (job.status === 'failed') {
clearInterval(_guardianJobPoll); _guardianJobPoll = null;
toast('SITREP failed: ' + (job.error||'unknown'), 'err');
}
}, 3000);
} else {
toast('Failed to start SITREP: ' + (d.error||'Arc offline'), 'err');
}
}
async function guardianAck(id) {
await api('guardian_ack', {id});
toast('Acknowledged', 'ok');
loadGuardian();
}
async function guardianAckAllAdmin() {
await api('guardian_ack');
toast('All events acknowledged', 'ok');
loadGuardian();
}
async function guardianConfigModal() {
const d = await api('guardian_status');
const thresh = d.thresholds || {};
const enabled = d.enabled;
openModal('⚙ GUARDIAN CONFIGURATION', `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div>
<label class="lbl">CPU THRESHOLD (%)</label>
<input id="gcfg-cpu" class="inp" type="number" value="${thresh.cpu||85}" min="50" max="99">
</div>
<div>
<label class="lbl">MEMORY THRESHOLD (%)</label>
<input id="gcfg-mem" class="inp" type="number" value="${thresh.memory||88}" min="50" max="99">
</div>
<div>
<label class="lbl">DISK THRESHOLD (%)</label>
<input id="gcfg-disk" class="inp" type="number" value="${thresh.disk||88}" min="50" max="99">
</div>
<div>
<label class="lbl">OFFLINE TIMEOUT (min)</label>
<input id="gcfg-offline" class="inp" type="number" value="${thresh.offline_minutes||3}" min="1" max="30">
</div>
<div>
<label class="lbl">SCAN INTERVAL (sec)</label>
<input id="gcfg-interval" class="inp" type="number" value="${d.scan_interval||120}" min="30" max="600">
</div>
<div>
<label class="lbl">GUARDIAN ENABLED</label>
<select id="gcfg-enabled" class="inp">
<option value="1"${enabled?' selected':''}>ENABLED</option>
<option value="0"${!enabled?' selected':''}>PAUSED</option>
</select>
</div>
</div>
`, async () => {
const updates = {
cpu_threshold: document.getElementById('gcfg-cpu')?.value,
mem_threshold: document.getElementById('gcfg-mem')?.value,
disk_threshold: document.getElementById('gcfg-disk')?.value,
offline_minutes: document.getElementById('gcfg-offline')?.value,
scan_interval: document.getElementById('gcfg-interval')?.value,
enabled: document.getElementById('gcfg-enabled')?.value,
};
for (const [key, value] of Object.entries(updates)) {
await api('guardian_config_set', {key, value});
}
toast('Guardian config saved', 'ok');
closeModal();
loadGuardian();
}, 'SAVE CONFIG');
}
// ── 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,'&quot;');
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,'&quot;')}" 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,'&quot;');
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,'&quot;')}" 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,'&quot;')}" 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>