Files
jarvis/public_html/admin/index.php
T
myron 90e4ded7c9 Fix 8 issues from code review
- ha-poller: replace recursive main() retry with while loop (stack overflow fix)
- ha-poller: advance last_push on empty HA response (log spam fix)
- ha-poller: use datetime.now(timezone.utc) instead of deprecated utcnow()
- ping-probe: always call update_status() unconditionally so offline devices register as offline
- agent.php: heartbeat reads status from payload instead of hardcoding 'online'
- phone-probe: delegate JSON building to python3 (bash concatenation injection fix)
- netscan + phone-probe: read registration key from /etc/jarvis-agent/reg-key
- admin/index.php: sync ha_list skipDomains with ha.php (14 missing domains added)
- facts_collector: self-check JARVIS via 127.0.0.1 instead of Cloudflare hairpin
2026-06-29 20:58:22 -05:00

4744 lines
264 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();
// Prevent Cloudflare Rocket Loader from deferring inline scripts (breaks doLogin, CAT_COLORS, etc.)
header('Cache-Control: no-transform');
// ── 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','media_player','scene','water_heater',
'alarm_control_panel','automation','script','calendar','notify',
'weather','camera','siren','remote','todo','lawn_mower'];
$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 'workers_list':
$agents = JarvisDB::query(
'SELECT agent_id, hostname, agent_type, ip_address, status, capabilities, version, last_seen
FROM registered_agents ORDER BY status DESC, hostname ASC'
);
// Latest available versions per platform
$latestVersions = [
'linux' => '3.1',
'proxmox' => '3.1',
'windows' => '3.0',
'macos' => '3.0',
'homeassistant' => null,
];
$reactorRaw = @file_get_contents('http://127.0.0.1:7474/status');
$reactor = $reactorRaw ? json_decode($reactorRaw, true) : null;
$arcStats = JarvisDB::query(
'SELECT status, COUNT(*) as cnt FROM arc_jobs
WHERE created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR) GROUP BY status'
);
$arcCounts = [];
foreach ($arcStats as $r) $arcCounts[$r['status']] = (int)$r['cnt'];
$cronLast = [];
$cronLog = '/home/jarvis.orbishosting.com/logs/cron.log';
if (file_exists($cronLog)) {
$lines = array_filter(explode("\n", shell_exec("grep -a 'facts\\|stats\\|calendar' " . escapeshellarg($cronLog) . " | tail -60")));
foreach ($lines as $line) {
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*facts/i', $line, $m)) $cronLast['facts_collector'] = $m[1];
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*stats/i', $line, $m)) $cronLast['stats_cache'] = $m[1];
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\].*calendar/i', $line, $m)) $cronLast['calendar_sync'] = $m[1];
}
}
if (empty($cronLast['stats_cache'])) {
$row = JarvisDB::query('SELECT MAX(updated_at) as t FROM api_cache WHERE cache_key IN ("weather","news")');
if (!empty($row[0]['t'])) $cronLast['stats_cache'] = $row[0]['t'];
}
$deployLog = '/home/jarvis.orbishosting.com/logs/deploy.log';
if (file_exists($deployLog)) {
$last = shell_exec("grep -a '\\[' " . escapeshellarg($deployLog) . " | tail -1");
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\]/', trim($last), $m)) $cronLast['jarvis_deploy'] = $m[1];
}
$wdLog = '/home/jarvis.orbishosting.com/logs/watchdog.log';
if (file_exists($wdLog)) $cronLast['jarvis_watchdog'] = date('Y-m-d H:i:s', filemtime($wdLog));
$bkLog = '/var/backups/jarvis/backup.log';
if (file_exists($bkLog)) {
$last = shell_exec("grep -a '\\[' " . escapeshellarg($bkLog) . " | tail -1");
if (preg_match('/^\\[(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})\\]/', trim($last), $m)) $cronLast['jarvis_backup'] = $m[1];
}
$doLog = '/var/log/do-server-backup.log';
if (file_exists($doLog)) $cronLast['do_server_backup'] = date('Y-m-d H:i:s', filemtime($doLog));
j(['agents'=>$agents,'reactor'=>$reactor,'arc_counts'=>$arcCounts,'cron_last'=>$cronLast,'latest_versions'=>$latestVersions]);
break;
case 'worker_action':
$wType = $data['worker_type'] ?? '';
$wId = $data['worker_id'] ?? '';
$wAction = $data['action'] ?? '';
if ($wType === 'agent' && $wAction === 'update') {
JarvisDB::execute('INSERT INTO agent_commands (agent_id,command_type,command_data,status) VALUES (?,?,?,?)',
[$wId,'update','{}','pending']);
j(['ok'=>true,'msg'=>'Update dispatched to '.$wId]);
} elseif ($wType === 'agent' && $wAction === 'screenshot') {
$ch = curl_init('http://127.0.0.1:7474/job');
curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_POST=>true,
CURLOPT_POSTFIELDS=>json_encode(['type'=>'screenshot','payload'=>['agent'=>$wId,'analyze'=>false],'priority'=>8,'created_by'=>'admin:workers']),
CURLOPT_HTTPHEADER=>['Content-Type: application/json'],CURLOPT_TIMEOUT=>5]);
j(json_decode(curl_exec($ch),true)?:['error'=>'reactor unreachable']);
} elseif ($wType === 'cron' && $wAction === 'run') {
$scripts = [
'facts_collector'=>[true, '/home/jarvis.orbishosting.com/api/endpoints/facts_collector.php'],
'stats_cache' =>[true, '/home/jarvis.orbishosting.com/api/endpoints/stats_cache.php'],
'calendar_sync' =>[true, '/home/jarvis.orbishosting.com/api/endpoints/calendar_sync.php'],
'jarvis_deploy' =>[false,'/usr/local/bin/jarvis-deploy.sh'],
'jarvis_watchdog'=>[false,'/usr/local/bin/jarvis-watchdog.sh'],
];
if (isset($scripts[$wId])) {
[$isPhp,$path] = $scripts[$wId];
$cmd = $isPhp
? '/usr/local/lsws/lsphp85/bin/lsphp '.escapeshellarg($path).' >> /home/jarvis.orbishosting.com/logs/cron.log 2>&1 &'
: escapeshellcmd($path).' >> /home/jarvis.orbishosting.com/logs/deploy.log 2>&1 &';
shell_exec($cmd);
j(['ok'=>true,'msg'=>ucwords(str_replace('_',' ',$wId)).' triggered']);
} else { bad('Unknown cron worker'); }
} elseif ($wType === 'daemon' && $wId === 'arc_reactor' && $wAction === 'restart') {
shell_exec('pkill -f reactor.py 2>/dev/null; sleep 1; cd /opt/jarvis-arc && source venv/bin/activate && nohup python3 reactor.py >> /home/jarvis.orbishosting.com/logs/arc_reactor.log 2>&1 &');
j(['ok'=>true,'msg'=>'Arc Reactor restarting']);
} else { bad('Invalid worker action'); }
break;
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']);
// ── OUTBOX ────────────────────────────────────────────────────────────
case 'outbox_list':
$limit = min((int)($_GET['limit'] ?? 50), 200);
$status = $_GET['status'] ?? '';
$qs = http_build_query(array_filter(['limit' => $limit, 'status' => $status]));
$ch = curl_init('http://127.0.0.1:7474/comms/sent' . ($qs ? "?{$qs}" : ''));
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: []);
case 'outbox_delete':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$ch = curl_init('http://127.0.0.1:7474/comms/sent/' . $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) ?: ['error'=>'failed']);
case 'send_reply':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing triage id');
$content = $_GET['content'] ?? '';
$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'=>'send_email','payload'=>['triage_id'=>$id,'content'=>$content],'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 'compose_email':
$to = $_GET['to'] ?? ''; if (!$to) bad('Missing recipient');
$subject = $_GET['subject'] ?? '';
$body = $_GET['body'] ?? '';
$account = $_GET['account'] ?? 'gmail';
$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'=>'compose_email','payload'=>['recipient'=>$to,'subject'=>$subject,'instructions'=>$body,'account'=>$account,'auto_send'=>false],'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']);
// ── DIRECTIVES ───────────────────────────────────────────────────────
case 'directive_list':
$status = $_GET['status'] ?? 'active';
$category = $_GET['category'] ?? '';
$where = '1=1'; $params = [];
if ($status && $status !== 'all') { $where .= ' AND d.status=?'; $params[] = $status; }
if ($category) { $where .= ' AND d.category=?'; $params[] = $category; }
$rows = JarvisDB::query(
"SELECT d.*,
COUNT(kr.id) AS kr_count,
COALESCE(SUM(kr.current_value),0) AS kr_current_sum,
COALESCE(SUM(kr.target_value),0) AS kr_target_sum,
(SELECT COUNT(*) FROM directive_links dl WHERE dl.directive_id=d.id) AS link_count
FROM directives d
LEFT JOIN directive_key_results kr ON kr.directive_id=d.id
WHERE {$where}
GROUP BY d.id
ORDER BY d.priority DESC, d.target_date ASC, d.created_at DESC",
$params
) ?: [];
foreach ($rows as &$r) {
$r['progress'] = ($r['kr_target_sum'] > 0)
? (float)round($r['kr_current_sum'] / $r['kr_target_sum'] * 100, 1)
: 0;
}
j(['directives' => $rows]);
case 'directive_get':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$d = JarvisDB::single("SELECT * FROM directives WHERE id=?", [$id]);
if (!$d) bad('Not found', 404);
$krs = JarvisDB::query("SELECT * FROM directive_key_results WHERE directive_id=? ORDER BY id", [$id]) ?: [];
$links = JarvisDB::query(
"SELECT dl.*, COALESCE(t.title,a.title) AS linked_title
FROM directive_links dl
LEFT JOIN tasks t ON dl.link_type='task' AND t.id=dl.link_id
LEFT JOIN appointments a ON dl.link_type='appointment' AND a.id=dl.link_id
WHERE dl.directive_id=? ORDER BY dl.created_at DESC",
[$id]
) ?: [];
$cur = array_sum(array_column($krs,'current_value'));
$tgt = array_sum(array_column($krs,'target_value'));
$d['progress'] = $tgt > 0 ? round($cur/$tgt*100,1) : 0;
$d['key_results'] = $krs;
$d['links'] = $links;
j($d);
case 'directive_save':
$id = (int)($_GET['id'] ?? 0);
$body = file_get_contents('php://input');
$data_in = json_decode($body, true) ?: [];
$title = trim($data_in['title'] ?? '');
$description = trim($data_in['description'] ?? '');
$category = $data_in['category'] ?? 'work';
$status = $data_in['status'] ?? 'active';
$priority = (int)($data_in['priority'] ?? 5);
$target_date = $data_in['target_date'] ?? null;
$krs = $data_in['key_results'] ?? [];
if (!$title) bad('Title required');
if ($id) {
JarvisDB::execute(
"UPDATE directives SET title=?,description=?,category=?,status=?,priority=?,target_date=?,updated_at=NOW() WHERE id=?",
[$title,$description,$category,$status,$priority,$target_date?:null,$id]
);
} else {
$id = JarvisDB::insert(
"INSERT INTO directives (title,description,category,status,priority,target_date) VALUES (?,?,?,?,?,?)",
[$title,$description,$category,$status,$priority,$target_date?:null]
);
}
if (is_array($krs)) {
JarvisDB::execute("DELETE FROM directive_key_results WHERE directive_id=?", [$id]);
foreach ($krs as $kr) {
$krt = trim($kr['title'] ?? ''); if (!$krt) continue;
JarvisDB::execute(
"INSERT INTO directive_key_results (directive_id,title,current_value,target_value,unit) VALUES (?,?,?,?,?)",
[$id,$krt,(float)($kr['current_value']??0),(float)($kr['target_value']??100),$kr['unit']??'%']
);
}
}
j(['ok' => true, 'id' => $id]);
case 'directive_delete':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
JarvisDB::execute("DELETE FROM directive_key_results WHERE directive_id=?", [$id]);
JarvisDB::execute("DELETE FROM directive_links WHERE directive_id=?", [$id]);
JarvisDB::execute("DELETE FROM directives WHERE id=?", [$id]);
j(['ok' => true]);
case 'arc_action':
$body = file_get_contents('php://input');
$d = json_decode($body, true) ?: [];
$type = $d['action'] === 'job_create' ? ($d['type'] ?? '') : '';
$payload = $d['payload'] ?? [];
$pri = (int)($d['priority'] ?? 5);
if (!$type) bad('Missing type');
$ch = curl_init('http://127.0.0.1:7474/job');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>10, CURLOPT_POST=>true,
CURLOPT_POSTFIELDS => json_encode(['type'=>$type,'payload'=>$payload,'priority'=>$pri,'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']);
// ── MISSION OPS ──────────────────────────────────────────────────────
case 'mission_list':
$ch = curl_init('http://127.0.0.1:7474/missions');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: []);
case 'mission_get':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$ch = curl_init('http://127.0.0.1:7474/missions/' . $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 'mission_runs':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$limit = (int)($_GET['limit'] ?? 20);
$ch = curl_init("http://127.0.0.1:7474/missions/{$id}/runs?limit={$limit}");
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: []);
case 'mission_save': // create or update
$id = (int)($_GET['id'] ?? 0);
$url = $id ? "http://127.0.0.1:7474/missions/{$id}" : 'http://127.0.0.1:7474/missions';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>10,
CURLOPT_CUSTOMREQUEST => $id ? 'PUT' : 'POST',
CURLOPT_POSTFIELDS => file_get_contents('php://input'),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['error'=>'Arc Reactor unreachable']);
case 'mission_delete':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$ch = curl_init('http://127.0.0.1:7474/missions/' . $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 'mission_run':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$ch = curl_init("http://127.0.0.1:7474/missions/{$id}/run");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>120, CURLOPT_POST=>true,
CURLOPT_POSTFIELDS => json_encode(['trigger_source'=>'admin']),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['error'=>'Arc Reactor unreachable or timeout']);
case 'mission_toggle':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$enabled = (int)($_GET['enabled'] ?? 0);
$ch = curl_init('http://127.0.0.1:7474/missions/' . $id);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CUSTOMREQUEST=>'PUT',
CURLOPT_POSTFIELDS => json_encode(['enabled'=>$enabled]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['ok'=>true]);
// ── CLEARANCE PROTOCOL ───────────────────────────────────────────────
case 'clearance_pending':
$ch = curl_init('http://127.0.0.1:7474/clearance/pending');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: []);
case 'clearance_history':
$limit = min((int)($_GET['limit'] ?? 50), 200);
$ch = curl_init('http://127.0.0.1:7474/clearance/history?limit=' . $limit);
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: []);
case 'clearance_approve':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$body = json_decode(file_get_contents('php://input'), true) ?: [];
$decidedBy = $body['decided_by'] ?? 'admin';
$ch = curl_init('http://127.0.0.1:7474/clearance/' . $id . '/approve');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>10, CURLOPT_POST=>true,
CURLOPT_POSTFIELDS => json_encode(['decided_by'=>$decidedBy]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['ok'=>true]);
case 'clearance_deny':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$body = json_decode(file_get_contents('php://input'), true) ?: [];
$decidedBy = $body['decided_by'] ?? 'admin';
$note = $body['note'] ?? '';
$ch = curl_init('http://127.0.0.1:7474/clearance/' . $id . '/deny');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_POST=>true,
CURLOPT_POSTFIELDS => json_encode(['decided_by'=>$decidedBy,'note'=>$note]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['ok'=>true]);
case 'clearance_rules':
$ch = curl_init('http://127.0.0.1:7474/clearance/rules');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: []);
case 'clearance_rule_update':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$body = json_decode(file_get_contents('php://input'), true) ?: [];
$ch = curl_init('http://127.0.0.1:7474/clearance/rules/' . $id);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_CUSTOMREQUEST=>'PUT',
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['ok'=>true]);
case 'clearance_rule_create':
$body = json_decode(file_get_contents('php://input'), true) ?: [];
$ch = curl_init('http://127.0.0.1:7474/clearance/rules');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER=>true, CURLOPT_TIMEOUT=>5, CURLOPT_POST=>true,
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw, true) ?: ['ok'=>true]);
// ── MEMORY CORE ──────────────────────────────────────────────────────
case 'memory_list':
$limit = min((int)($_GET['limit'] ?? 200), 500);
$category = $_GET['category'] ?? '';
$search = $_GET['search'] ?? '';
$qs = http_build_query(array_filter(['limit'=>$limit,'category'=>$category,'search'=>$search]));
$ch = curl_init('http://127.0.0.1:7474/memory/facts' . ($qs ? '?'.$qs : ''));
curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>5]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw,true) ?: []);
case 'memory_stats':
$ch = curl_init('http://127.0.0.1:7474/memory/stats');
curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>5]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw,true) ?: ['total'=>0,'by_category'=>[]]);
case 'memory_store':
$body = json_decode(file_get_contents('php://input'),true) ?: [];
$ch = curl_init('http://127.0.0.1:7474/memory/facts');
curl_setopt_array($ch,[
CURLOPT_RETURNTRANSFER=>true, CURLOPT_POST=>true, CURLOPT_TIMEOUT=>5,
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$raw = curl_exec($ch); curl_close($ch);
j(json_decode($raw,true) ?: ['ok'=>true]);
case 'memory_delete':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$ch = curl_init('http://127.0.0.1:7474/memory/facts/' . $id);
curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_CUSTOMREQUEST=>'DELETE',CURLOPT_TIMEOUT=>5]);
curl_exec($ch); curl_close($ch);
j(['ok'=>true]);
case 'memory_clear':
$category = $_GET['category'] ?? '';
$url = 'http://127.0.0.1:7474/memory/facts' . ($category ? '?category=' . urlencode($category) : '');
$ch = curl_init($url);
curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_CUSTOMREQUEST=>'DELETE',CURLOPT_TIMEOUT=>5]);
curl_exec($ch); curl_close($ch);
j(['ok'=>true]);
// ── 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_analyze':
$id = (int)($_GET['id'] ?? 0); if (!$id) bad('Missing id');
$ch = curl_init('http://127.0.0.1:7474/job');
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER=>true, CURLOPT_POST=>true, CURLOPT_TIMEOUT=>5,
CURLOPT_POSTFIELDS=>json_encode(['type'=>'vision','payload'=>['screenshot_id'=>$id,'provider'=>'claude'],'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']);
break;
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>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Rajdhani:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#000810;--surface:#000d1a;--panel:rgba(0,15,35,0.9);--border:rgba(0,212,255,0.15);--border2:rgba(0,212,255,0.25);
--cyan:#00d4ff;--green:#00ff88;--red:#ff2244;--yellow:#ffd700;--orange:#ff6600;
--text:#c8e6ff;--text-dim:rgba(200,230,255,0.5);--dim:rgba(0,212,255,0.45);
--font-display:'Orbitron',monospace;--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(--text-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-family:var(--font-display);font-size:1rem;letter-spacing:5px}
#topbar .sub{color:var(--text-dim);font-size:0.6rem;letter-spacing:3px;margin-left:12px}
#topbar .right{display:flex;align-items:center;gap:16px}
#topbar .user{color:var(--text-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(--text-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:rgba(0,212,255,0.06)}
.nav-item.active{color:var(--cyan);border-left-color:var(--cyan);background:rgba(0,212,255,0.08)}
.nav-section{padding:16px 20px 6px;color:rgba(200,230,255,0.35);font-size:0.5rem;letter-spacing:3px;text-transform:uppercase}
/* ── CONTENT ── */
#content{flex:1;overflow-y:auto;padding:24px}
.tab{display:none}.tab.active{display:block}
.page-title{color:var(--cyan);font-family:var(--font-display);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(--text-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(--text-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(--text-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(--text-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(--text-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(--text-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(--text-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(--text-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="workers" onclick="nav(this)">&#9881; WORKERS</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-item" data-tab="outbox" onclick="nav(this)">◈ OUTBOX</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-item" data-tab="missions" onclick="nav(this)">◈ MISSION OPS</div>
<div class="nav-item" data-tab="directives" onclick="nav(this)">◈ DIRECTIVES</div>
<div class="nav-item" data-tab="clearance" onclick="nav(this)" id="nav-clearance">🔒 CLEARANCE</div>
<div class="nav-item" data-tab="memory" onclick="nav(this)" id="nav-memory">◈ MEMORY CORE</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 class="nav-item" data-tab="docs" onclick="nav(this)">📄 DOCS</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-workers">
<div class="page-title">&#9881; JARVIS AGENT WORKERS
<button onclick="loadWorkers()" style="font-size:0.6rem;padding:4px 12px">&#8635; REFRESH</button>
</div>
<div class="page-title" style="font-size:0.7rem;margin-top:0;border:none;padding-bottom:4px">FIELD AGENTS</div>
<table><thead><tr>
<th>HOSTNAME</th><th>TYPE</th><th>IP</th><th>STATUS</th><th>VERSION</th><th>CAPABILITIES</th><th>LAST SEEN</th><th>ACTIONS</th>
</tr></thead><tbody id="workers-agents"><tr><td colspan="8" class="loading">LOADING...</td></tr></tbody></table>
<div class="page-title" style="font-size:0.7rem;margin-top:24px;border:none;padding-bottom:4px">CRON WORKERS</div>
<table><thead><tr>
<th>WORKER</th><th>SCHEDULE</th><th>HOST</th><th>LAST RUN</th><th>ACTIONS</th>
</tr></thead><tbody id="workers-crons"></tbody></table>
<div class="page-title" style="font-size:0.7rem;margin-top:24px;border:none;padding-bottom:4px">DAEMONS</div>
<table><thead><tr>
<th>DAEMON</th><th>HOST</th><th>STATUS</th><th>INFO</th><th>ACTIONS</th>
</tr></thead><tbody id="workers-daemons"></tbody></table>
</div>
<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>
<!-- DOCS -->
<div class="tab" id="tab-docs">
<div class="page-title">DOCUMENTATION</div>
<div class="card" style="padding:24px;margin:20px 0">
<div style="font-size:0.7rem;letter-spacing:2px;color:var(--cyan);margin-bottom:8px">INFRASTRUCTURE REFERENCE</div>
<div style="color:var(--text-dim);font-size:0.75rem;margin-bottom:16px">Complete server map, credentials, deployment workflow, service configs, and phone system reference.</div>
<a href="downloads/INFRASTRUCTURE-REFERENCE.md" download="INFRASTRUCTURE-REFERENCE.md"
style="display:inline-block;padding:8px 20px;background:rgba(0,212,255,0.1);border:1px solid var(--cyan);color:var(--cyan);font-size:0.7rem;letter-spacing:2px;text-decoration:none">
↓ DOWNLOAD INFRASTRUCTURE-REFERENCE.MD
</a>
</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>
<!-- OUTBOX -->
<div class="tab" id="tab-outbox">
<div class="page-title">◈ COMMS OUTBOX — SENT &amp; QUEUED</div>
<div style="display:flex;gap:10px;margin-bottom:16px;align-items:center;flex-wrap:wrap">
<button class="btn btn-sm btn-green" onclick="outboxCompose()">+ COMPOSE</button>
<button class="btn btn-sm" onclick="loadOutbox()">↻ REFRESH</button>
<select id="outbox-status" class="inp" style="width:auto;padding:4px 8px;font-size:0.65rem" onchange="loadOutbox()">
<option value="">ALL</option>
<option value="sent">SENT</option>
<option value="queued">QUEUED</option>
<option value="failed">FAILED</option>
</select>
<div id="outbox-count" style="margin-left:auto;font-family:var(--mono);font-size:0.65rem;color:var(--dim)"></div>
</div>
<div class="tbl-wrap" id="outbox-tbl"><div class="loading">LOADING OUTBOX...</div></div>
</div>
<!-- MISSION OPS -->
<div class="tab" id="tab-missions">
<div class="page-title">◈ MISSION OPS — AUTOMATED WORKFLOWS</div>
<div style="display:flex;gap:10px;margin-bottom:16px;align-items:center;flex-wrap:wrap">
<button class="btn btn-sm btn-green" onclick="missionNew()">+ NEW MISSION</button>
<button class="btn btn-sm" onclick="loadMissions()">↻ REFRESH</button>
<div id="missions-count" style="margin-left:auto;font-family:var(--mono);font-size:0.65rem;color:var(--dim)"></div>
</div>
<!-- Mission list -->
<div id="missions-list"><div class="loading">LOADING MISSIONS...</div></div>
<!-- Builder panel (hidden until a mission is selected/created) -->
<div id="mission-builder" style="display:none;margin-top:20px;border:1px solid var(--border);border-radius:6px;padding:16px;background:rgba(0,212,255,0.02)">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
<div id="builder-title" style="font-family:var(--mono);font-size:0.75rem;letter-spacing:2px;color:var(--cyan)">◈ MISSION BUILDER</div>
<button class="btn btn-xs" onclick="document.getElementById('mission-builder').style.display='none'">✕ CLOSE</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:14px">
<div>
<div class="lbl">MISSION NAME</div>
<input id="mb-name" class="inp" placeholder="e.g. Daily Morning Brief">
</div>
<div>
<div class="lbl">TRIGGER</div>
<select id="mb-trigger" class="inp" onchange="missionTriggerChange()">
<option value="manual">Manual (run by hand)</option>
<option value="schedule">Schedule (every N minutes)</option>
<option value="guardian_event">Guardian Event (on alert)</option>
<option value="email_keyword">Email Keyword (on triage match)</option>
</select>
</div>
</div>
<div id="mb-trigger-config" style="margin-bottom:14px"></div>
<div>
<div class="lbl">DESCRIPTION (optional)</div>
<input id="mb-desc" class="inp" placeholder="What does this mission do?">
</div>
<div style="margin-top:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<div class="lbl" style="margin:0">STEPS</div>
<button class="btn btn-xs btn-green" onclick="missionAddStep()">+ ADD STEP</button>
</div>
<div id="mb-steps"></div>
</div>
<input type="hidden" id="mb-mission-id" value="">
<div style="display:flex;gap:8px;margin-top:16px">
<button class="btn btn-sm btn-green" onclick="missionSave()">◈ SAVE MISSION</button>
<button id="mb-run-btn" class="btn btn-sm" style="display:none" onclick="missionRunFromBuilder()">▶ RUN NOW</button>
<button id="mb-del-btn" class="btn btn-sm btn-red" style="display:none" onclick="missionDeleteFromBuilder()">✗ DELETE</button>
</div>
<div id="mb-status" style="font-family:var(--mono);font-size:0.6rem;color:var(--cyan);margin-top:8px;min-height:14px"></div>
</div>
<!-- Run history panel -->
<div id="mission-run-history" style="display:none;margin-top:16px;border:1px solid var(--border);border-radius:6px;padding:14px">
<div style="font-family:var(--mono);font-size:0.65rem;letter-spacing:2px;color:var(--cyan);margin-bottom:10px">◈ RUN HISTORY</div>
<div id="mission-runs-tbl"></div>
</div>
</div>
<!-- DIRECTIVES -->
<div class="tab" id="tab-directives">
<div class="page-title">◈ MISSION DIRECTIVES — OBJECTIVES &amp; KEY RESULTS</div>
<div style="display:flex;gap:10px;margin-bottom:16px;align-items:center;flex-wrap:wrap">
<button class="btn btn-sm btn-green" onclick="directiveNew()">+ NEW DIRECTIVE</button>
<button class="btn btn-sm" onclick="directiveReviewAI()">◈ AI REVIEW</button>
<button class="btn btn-sm" onclick="loadDirectives()">↻ REFRESH</button>
<select id="dir-status-filter" class="inp" style="width:auto;padding:4px 8px;font-size:0.65rem" onchange="loadDirectives()">
<option value="active">ACTIVE</option>
<option value="all">ALL</option>
<option value="paused">PAUSED</option>
<option value="complete">COMPLETE</option>
</select>
<select id="dir-cat-filter" class="inp" style="width:auto;padding:4px 8px;font-size:0.65rem" onchange="loadDirectives()">
<option value="">ALL CATEGORIES</option>
<option value="work">WORK</option>
<option value="personal">PERSONAL</option>
<option value="health">HEALTH</option>
<option value="finance">FINANCE</option>
<option value="home">HOME</option>
</select>
<div id="directives-count" style="margin-left:auto;font-family:var(--mono);font-size:0.65rem;color:var(--dim)"></div>
</div>
<div id="directives-list"><div class="loading">LOADING DIRECTIVES...</div></div>
<!-- Directive editor panel -->
<div id="directive-editor" style="display:none;margin-top:20px;border:1px solid var(--border);border-radius:6px;padding:16px;background:rgba(0,212,255,0.02)">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
<div id="dir-editor-title" style="font-family:var(--mono);font-size:0.75rem;letter-spacing:2px;color:var(--cyan)">◈ DIRECTIVE EDITOR</div>
<button class="btn btn-xs" onclick="document.getElementById('directive-editor').style.display='none'">✕ CLOSE</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:12px">
<div style="grid-column:1/3">
<div class="lbl">OBJECTIVE TITLE</div>
<input id="dir-title" class="inp" placeholder="What do you want to achieve?">
</div>
<div>
<div class="lbl">STATUS</div>
<select id="dir-status" class="inp">
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="complete">Complete</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:12px">
<div>
<div class="lbl">CATEGORY</div>
<select id="dir-category" class="inp">
<option value="work">Work</option>
<option value="personal">Personal</option>
<option value="health">Health</option>
<option value="finance">Finance</option>
<option value="home">Home</option>
<option value="other">Other</option>
</select>
</div>
<div>
<div class="lbl">PRIORITY (1-10)</div>
<input id="dir-priority" class="inp" type="number" min="1" max="10" value="5">
</div>
<div>
<div class="lbl">TARGET DATE</div>
<input id="dir-target-date" class="inp" type="date">
</div>
</div>
<div style="margin-bottom:14px">
<div class="lbl">DESCRIPTION</div>
<textarea id="dir-desc" class="inp" rows="2" placeholder="Context, why this matters..."></textarea>
</div>
<div style="margin-bottom:14px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<div class="lbl" style="margin:0">KEY RESULTS</div>
<button class="btn btn-xs btn-green" onclick="dirAddKR()">+ ADD KEY RESULT</button>
</div>
<div id="dir-kr-list"></div>
</div>
<input type="hidden" id="dir-id" value="">
<div style="display:flex;gap:8px">
<button class="btn btn-sm btn-green" onclick="directiveSave()">◈ SAVE</button>
<button id="dir-del-btn" class="btn btn-sm btn-red" style="display:none" onclick="directiveDelete()">✗ DELETE</button>
</div>
<div id="dir-save-status" style="font-family:var(--mono);font-size:0.6rem;color:var(--cyan);margin-top:6px;min-height:14px"></div>
</div>
</div>
<!-- CLEARANCE PROTOCOL -->
<div class="tab" id="tab-clearance">
<div class="page-title">🔒 CLEARANCE PROTOCOL</div>
<div style="display:flex;gap:10px;margin-bottom:16px;align-items:center;flex-wrap:wrap">
<button class="btn btn-sm" onclick="loadClearance()">↻ REFRESH</button>
<div id="clearance-badge" style="margin-left:auto;font-family:var(--mono);font-size:0.65rem;color:var(--dim)"></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<!-- Pending requests -->
<div>
<div style="font-family:var(--mono);font-size:0.7rem;letter-spacing:2px;color:var(--red);margin-bottom:10px">PENDING AUTHORIZATION</div>
<div id="clearance-pending-list"><div class="loading">LOADING...</div></div>
</div>
<!-- Rules configuration -->
<div>
<div style="font-family:var(--mono);font-size:0.7rem;letter-spacing:2px;color:var(--cyan);margin-bottom:10px">CLEARANCE RULES</div>
<div id="clearance-rules-list"><div class="loading">LOADING...</div></div>
<div style="margin-top:10px">
<div style="font-family:var(--mono);font-size:0.65rem;letter-spacing:1px;color:var(--dim);margin-bottom:6px">ADD CUSTOM RULE</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:6px">
<div><div class="lbl">JOB TYPE</div><input id="clr-new-type" class="inp" placeholder="job_type"></div>
<div><div class="lbl">RISK LEVEL</div>
<select id="clr-new-risk" class="inp">
<option value="medium">MEDIUM</option>
<option value="high" selected>HIGH</option>
<option value="critical">CRITICAL</option>
</select>
</div>
<div><div class="lbl">REQUIRE APPROVAL</div>
<select id="clr-new-req" class="inp">
<option value="1" selected>YES</option>
<option value="0">NO (LOG ONLY)</option>
</select>
</div>
<div><div class="lbl">AUTO-APPROVE AFTER (MIN)</div><input id="clr-new-auto" class="inp" type="number" placeholder="blank=never"></div>
</div>
<input id="clr-new-desc" class="inp" placeholder="Description" style="margin-bottom:6px">
<button class="btn btn-sm btn-green" onclick="clearanceRuleCreate()">+ ADD RULE</button>
</div>
</div>
</div>
<!-- History -->
<div style="margin-top:20px">
<div style="font-family:var(--mono);font-size:0.7rem;letter-spacing:2px;color:var(--dim);margin-bottom:10px">DECISION HISTORY</div>
<div id="clearance-history-list"><div class="loading">LOADING...</div></div>
</div>
</div>
<!-- MEMORY CORE -->
<div class="tab" id="tab-memory">
<div class="page-title">◈ MEMORY CORE — KNOWLEDGE GRAPH</div>
<div style="display:flex;gap:10px;margin-bottom:16px;align-items:center;flex-wrap:wrap">
<button class="btn btn-sm btn-green" onclick="memoryNew()">+ ADD FACT</button>
<button class="btn btn-sm" onclick="loadMemory()">↻ REFRESH</button>
<select id="mem-cat-filter" class="inp" style="width:auto;padding:4px 8px;font-size:0.65rem" onchange="loadMemory()">
<option value="">ALL CATEGORIES</option>
<option value="preference">PREFERENCE</option>
<option value="person">PERSON</option>
<option value="place">PLACE</option>
<option value="routine">ROUTINE</option>
<option value="goal">GOAL</option>
<option value="fact">FACT</option>
<option value="instruction">INSTRUCTION</option>
</select>
<input id="mem-search" class="inp" placeholder="Search..." style="width:160px;padding:4px 8px;font-size:0.65rem" oninput="loadMemory()">
<button class="btn btn-sm btn-red" onclick="memoryClearAll()">CLEAR ALL</button>
<div id="memory-stats-bar" style="margin-left:auto;font-family:var(--mono);font-size:0.65rem;color:var(--dim)"></div>
</div>
<div id="memory-list"><div class="loading">LOADING MEMORY CORE...</div></div>
<!-- Add fact panel -->
<div id="memory-editor" style="display:none;margin-top:20px;border:1px solid var(--border);border-radius:6px;padding:16px;background:rgba(0,212,255,0.02)">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<div style="font-family:var(--mono);font-size:0.7rem;letter-spacing:2px;color:var(--cyan)">◈ ADD MEMORY FACT</div>
<button class="btn btn-xs" onclick="document.getElementById('memory-editor').style.display='none'">✕</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:8px;margin-bottom:10px">
<div>
<div class="lbl">CATEGORY</div>
<select id="mem-new-cat" class="inp">
<option value="fact">FACT</option>
<option value="preference">PREFERENCE</option>
<option value="person">PERSON</option>
<option value="place">PLACE</option>
<option value="routine">ROUTINE</option>
<option value="goal">GOAL</option>
<option value="instruction">INSTRUCTION</option>
</select>
</div>
<div>
<div class="lbl">SUBJECT</div>
<input id="mem-new-subject" class="inp" placeholder="e.g. user, Tom">
</div>
<div>
<div class="lbl">PREDICATE</div>
<input id="mem-new-predicate" class="inp" placeholder="e.g. prefers, works at">
</div>
<div>
<div class="lbl">VALUE</div>
<input id="mem-new-object" class="inp" placeholder="e.g. dark mode">
</div>
</div>
<button class="btn btn-sm btn-green" onclick="memorySave()">◈ SAVE FACT</button>
</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,
workers: loadWorkers,
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,
outbox: loadOutbox,
missions: loadMissions,
directives: loadDirectives,
clearance: loadClearance,
memory: loadMemory,
vision: loadVision,
guardian: loadGuardian,
tasks: loadTasks,
appointments: loadAppts,
calendar: loadCalFeeds,
arc: loadArc,
})[tab]?.();
}
function initApp() { loadDashboard(); setInterval(loadDashboard, 15000); }
// ── DASHBOARD ─────────────────────────────────────────────────────────────────
// ── WORKERS ───────────────────────────────────────────────────────────────────
const CRON_DEFS = [
{id:'facts_collector', label:'Facts Collector', schedule:'Every 3 min', host:'jarvis-do'},
{id:'stats_cache', label:'Stats Cache', schedule:'Every 5 min', host:'jarvis-do'},
{id:'calendar_sync', label:'Calendar Sync', schedule:'Every 15 min', host:'jarvis-do'},
{id:'jarvis_deploy', label:'Deploy Runner', schedule:'Every 1 min', host:'jarvis-do'},
{id:'jarvis_watchdog', label:'Watchdog', schedule:'Every 5 min', host:'jarvis-do'},
{id:'jarvis_backup', label:'JARVIS Backup', schedule:'Daily 2am', host:'jarvis-do', norun:true},
{id:'do_server_backup',label:'DO Server Backup', schedule:'Weekly Sun 4am', host:'jarvis-do', norun:true},
];
function wBtn(col) {
const c={cyan:'var(--cyan)',red:'var(--red)',green:'var(--green)',dim:'var(--dim)'}[col]||'var(--dim)';
return `background:none;border:1px solid ${c};color:${c};padding:3px 8px;font-family:var(--font);font-size:0.55rem;letter-spacing:1px;cursor:pointer;border-radius:2px;margin-right:3px`;
}
function wAgo(ts) {
if (!ts) return '<span style="color:var(--red)">UNKNOWN</span>';
const d=new Date(ts.replace(' ','T')+(ts.includes('T')?'':'Z'));
const s=Math.floor((Date.now()-d)/1000);
if(isNaN(s)||s<0) return ts;
if(s<60) return s+'s ago';
if(s<3600) return Math.floor(s/60)+'m ago';
if(s<86400) return Math.floor(s/3600)+'h ago';
return Math.floor(s/86400)+'d ago';
}
function wToast(msg,err=false) {
let t=document.getElementById('w-toast');
if(!t){t=document.createElement('div');t.id='w-toast';
t.style.cssText='position:fixed;bottom:24px;right:24px;padding:10px 18px;border-radius:4px;font-size:0.65rem;letter-spacing:1px;z-index:9999;transition:opacity 0.5s';
document.body.appendChild(t);}
t.style.background=err?'rgba(255,34,68,0.12)':'rgba(0,212,255,0.12)';
t.style.border=err?'1px solid var(--red)':'1px solid var(--cyan)';
t.style.color=err?'var(--red)':'var(--cyan)';
t.style.opacity='1';t.textContent=msg;
setTimeout(()=>{t.style.opacity='0';},3000);
}
async function workerAction(type,id,action) {
if (type === 'agent' && action === 'update') {
await agentUpdateFlow(id);
return;
}
const res=await api('worker_action',{worker_type:type,worker_id:id,action});
if(res&&res.ok){wToast(res.msg||'Done');setTimeout(loadWorkers,2500);}
else wToast((res&&res.error)||'Action failed',true);
}
async function agentUpdateFlow(agentId) {
// Find the current version for this agent from the table
const row = [...document.querySelectorAll('#workers-agents tr')]
.find(r => r.innerHTML.includes(agentId));
const curVerEl = row ? row.querySelector('td:nth-child(5) span') : null;
const curVer = curVerEl ? curVerEl.textContent.replace(/[^0-9.]/g,'').trim() : '?';
// Show modal with live status
openModal(`⬆ UPDATE AGENT — ${agentId}`,
`<div id="upd-status" style="font-size:0.7rem;line-height:2;font-family:var(--mono)">
<div>Dispatching update command...</div>
</div>`, null, null);
document.getElementById('modalSave').style.display = 'none';
const log = (msg, col) => {
const el = document.getElementById('upd-status');
if (el) el.innerHTML += `<div style="color:${col||'var(--text)'}">${msg}</div>`;
};
// Dispatch command
const res = await api('worker_action', {worker_type:'agent', worker_id:agentId, action:'update'});
if (!res || !res.ok) {
log('✗ Failed to dispatch: ' + (res?.error||'unknown'), 'var(--red)');
document.getElementById('modalSave').style.display = '';
document.getElementById('modalSave').textContent = 'CLOSE';
document.getElementById('modalSave').onclick = closeModal;
return;
}
log('✓ Command dispatched — waiting for agent to pick up...', 'var(--cyan)');
// Poll agent_commands for the result (max 90s)
const cmdRes = await api('worker_action', {worker_type:'agent', worker_id:agentId, action:'update_status'}).catch(()=>null);
// Actually poll via workers_list for version change
const deadline = Date.now() + 90000;
let done = false;
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, 4000));
const wData = await api('workers_list').catch(()=>null);
if (!wData || !wData.agents) break;
const ag = wData.agents.find(a => a.agent_id === agentId);
if (!ag) break;
const newVer = ag.version || null;
const latest = (wData.latest_versions||{})[ag.agent_type] || null;
if (latest && newVer === latest) {
log(`✓ Agent confirmed v${newVer} — up to date!`, 'var(--green)');
// Update the version cell in the table live
if (curVerEl) {
curVerEl.textContent = `v${newVer} ✓`;
curVerEl.style.color = 'var(--green)';
const tdVer = curVerEl.closest('td');
if (tdVer) tdVer.innerHTML = `<span style="color:var(--green);font-size:0.62rem;font-family:var(--mono)">v${newVer} ✓</span>`;
}
done = true;
break;
} else if (newVer && newVer !== curVer) {
log(`↻ Version changed: ${curVer} → ${newVer} (checking if latest...)`, 'var(--yellow)');
} else {
log('· Waiting...', 'var(--text-dim)');
}
}
if (!done) {
log('⚠ Timed out — agent may update in background (self-update runs periodically)', 'var(--yellow)');
}
document.getElementById('modalSave').style.display = '';
document.getElementById('modalSave').textContent = 'CLOSE';
document.getElementById('modalSave').onclick = () => { closeModal(); loadWorkers(); };
}
async function loadWorkers() {
const d=await api('workers_list');
if(!d||d.error) return;
const latestVer = d.latest_versions || {};
// Field Agents
const agTbody=document.getElementById('workers-agents');
if(!d.agents||!d.agents.length){
agTbody.innerHTML='<tr><td colspan="8" class="empty">NO AGENTS</td></tr>';
} else {
agTbody.innerHTML=d.agents.map(ag=>{
const on=ag.status==='online';
const dot=`<span class="dot ${on?'dot-green':'dot-red'}" style="margin-right:6px;vertical-align:middle"></span>`;
const caps=JSON.parse(ag.capabilities||'[]');
const capHtml=caps.map(c=>{
const col=c==='screenshot'?'var(--cyan)':c==='proxmox'?'var(--orange)':c==='docker'?'var(--green)':c==='ollama'?'var(--yellow)':'rgba(200,230,255,0.3)';
return `<span style="font-size:0.48rem;padding:1px 4px;border:1px solid ${col};color:${col};border-radius:2px;margin-right:2px;white-space:nowrap">${c.toUpperCase()}</span>`;
}).join('');
const shotBtn=caps.includes('screenshot')?`<button onclick="workerAction('agent','${ag.hostname}','screenshot')" style="${wBtn('dim')}">&#9670; SHOT</button>`:'';
// Version column
const curVer = ag.version || null;
const latVer = latestVer[ag.agent_type] || null;
let verHtml;
if (!latVer) {
verHtml = '<span class="ts">—</span>';
} else if (!curVer) {
verHtml = `<span style="color:var(--yellow);font-size:0.62rem;font-family:var(--mono)">? / ${latVer}</span>`;
} else if (curVer === latVer) {
verHtml = `<span style="color:var(--green);font-size:0.62rem;font-family:var(--mono)">v${curVer} ✓</span>`;
} else {
verHtml = `<span style="color:var(--red);font-size:0.62rem;font-family:var(--mono)">v${curVer}</span><span class="ts"> → v${latVer}</span>`;
}
const needsUpdate = latVer && curVer !== latVer;
const updBtn = caps.includes('commands')
? `<button onclick="workerAction('agent','${ag.agent_id}','update')" style="${wBtn(needsUpdate?'cyan':'dim')}">${needsUpdate?'⬆ UPDATE':'↻ UPDATE'}</button>`
: '';
return `<tr${needsUpdate?' style="background:rgba(0,212,255,0.04)"':''}>
<td>${dot}<strong>${ag.hostname}</strong></td>
<td class="ts">${ag.agent_type||'linux'}</td>
<td class="ts">${ag.ip_address||'&mdash;'}</td>
<td>${dot}${on?'<span style="color:var(--green)">ONLINE</span>':'<span style="color:var(--red)">OFFLINE</span>'}</td>
<td>${verHtml}</td>
<td>${capHtml||'<span class="ts">—</span>'}</td>
<td class="ts">${wAgo(ag.last_seen)}</td>
<td>${updBtn}${shotBtn}</td>
</tr>`;
}).join('');
}
// Cron Workers
const cl=d.cron_last||{};
document.getElementById('workers-crons').innerHTML=CRON_DEFS.map(c=>{
const runBtn=!c.norun?`<button onclick="workerAction('cron','${c.id}','run')" style="${wBtn('cyan')}">&#9654; RUN</button>`:'<span class="ts">&mdash;</span>';
return `<tr>
<td><strong>${c.label}</strong></td>
<td class="ts">${c.schedule}</td>
<td class="ts">${c.host}</td>
<td class="ts">${wAgo(cl[c.id])}</td>
<td>${runBtn}</td>
</tr>`;
}).join('');
// Daemons
const r=d.reactor,ron=r&&!r.error;
const rdot=`<span class="dot ${ron?'dot-green':'dot-red'}" style="margin-right:6px;vertical-align:middle"></span>`;
const rinfo=ron?`${r.handlers||'?'} handlers &nbsp;&middot;&nbsp; ${(d.arc_counts||{}).done||0} jobs done (24h) &nbsp;&middot;&nbsp; ${(d.arc_counts||{}).failed||0} failed`:'Not responding on :7474';
document.getElementById('workers-daemons').innerHTML=`<tr>
<td>${rdot}<strong>Arc Reactor</strong></td>
<td class="ts">jarvis-do :7474</td>
<td>${rdot}${ron?'<span style="color:var(--green)">ONLINE</span>':'<span style="color:var(--red)">OFFLINE</span>'}</td>
<td class="ts">${rinfo}</td>
<td><button onclick="workerAction('daemon','arc_reactor','restart')" style="${wBtn('red')}">&#8635; RESTART</button></td>
</tr>`;
}
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 shotTs = ts(s.created_at);
const hasImg = (s.file_size || 0) > 0;
const meth = (s.method || 'unknown').toUpperCase();
const rawAnalysis = s.vision_analysis || '';
const isFailed = rawAnalysis.startsWith('Vision analysis unavailable');
const analysis = isFailed ? '' : rawAnalysis.substring(0, 200);
const hasAnalysis = !isFailed && rawAnalysis.length > 0;
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:6px">
<span class="dot ${s.hostname?'dot-green':'dot-dim'}" style="flex-shrink:0"></span>
<span style="font-family:var(--mono);font-size:0.65rem;font-weight:600;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(s.hostname||'unknown')}</span>
<span style="font-family:var(--mono);font-size:0.5rem;color:var(--text-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="visionReanalyze(${s.id})" title="Re-run Claude vision analysis" style="border-color:var(--yellow);color:var(--yellow)">◈</button>
<button class="btn btn-xs" onclick="visionDeleteShot(${s.id})" style="border-color:var(--red);color:var(--red)">✗</button>
</div>
<div style="padding:8px 10px">
${hasImg
? `<div style="background:#060a0e;border:1px solid var(--border);border-radius:3px;padding:6px;margin-bottom:8px;cursor:pointer;display:flex;align-items:center;gap:8px" onclick="visionViewScreenshot(${s.id})">
<span style="font-size:1.2rem">🖥</span>
<span style="font-family:var(--mono);font-size:0.6rem;color:var(--text-dim)">${Math.round((s.file_size||0)/1024)}KB &nbsp;·&nbsp; click to view</span>
</div>`
: '<div style="font-size:0.6rem;color:var(--text-dim);margin-bottom:6px;font-family:var(--mono)">TEXT SNAPSHOT ONLY</div>'}
${hasAnalysis
? `<div style="font-size:0.62rem;line-height:1.6;color:var(--text);">${esc(analysis)}${rawAnalysis.length>200?'…':''}</div>`
: `<div style="font-size:0.6rem;color:var(--text-dim);font-family:var(--mono);font-style:italic">${isFailed?'Analysis failed — credits needed':'No analysis yet — click ◈ to analyze'}</div>`}
<div style="font-family:var(--mono);font-size:0.55rem;color:var(--text-dim);opacity:0.5;margin-top:6px">${shotTs}</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 && !d.vision_analysis.startsWith('Vision analysis unavailable') ? `
<div style="font-size:0.55rem;letter-spacing:2px;color:var(--text-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>` :
`<div style="font-size:0.6rem;color:var(--text-dim);font-family:var(--mono);padding:8px 0">No analysis — click ◈ in gallery to analyze when credits are available.</div>`}
`, null, null);
document.getElementById('modalSave').style.display = 'none';
}
async function visionReanalyze(id) {
toast('Submitting vision analysis job...', 'ok');
const d = await api('vision_analyze', {id});
if (d && d.job_id) {
toast('Analysis job #' + d.job_id + ' queued', 'ok');
setTimeout(() => loadVision(), 8000);
} else {
toast((d && d.error) || 'Failed — Arc offline or no credits', 'err');
}
}
async function visionRunScreenshot() {
const all = await api('agents_list');
const agents = (Array.isArray(all) ? all : []).filter(a => a.status === 'online').map(a => a.hostname).filter(Boolean);
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 evTs = 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">${evTs}</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> ` : '';
const sendBtn = hasDraft ? `<button class="btn btn-xs btn-green" onclick="triageSendReply(${it.id})">◈ SEND</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">
${sendBtn}${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');
});
}
async function triageSendReply(id) {
if (!confirm('Send the drafted reply for this email now?')) return;
const d = await api('send_reply', {id});
if (d.job_id) {
toast('Send job dispatched — Job #' + d.job_id, 'ok');
setTimeout(() => { loadTriage(); loadOutbox(); }, 3000);
} else {
toast('Send failed: ' + (d.error || 'Arc Reactor offline'), 'err');
}
}
// ── OUTBOX ───────────────────────────────────────────────────────────────────
async function loadOutbox() {
const el = document.getElementById('outbox-tbl');
if (!el) return;
const status = document.getElementById('outbox-status')?.value || '';
const d = await api('outbox_list', {limit: 100, status});
const sent = Array.isArray(d) ? d : (d.sent || []);
document.getElementById('outbox-count').textContent = sent.length + ' MESSAGES';
if (!sent.length) { el.innerHTML = '<div class="loading">No messages in outbox.</div>'; return; }
const statusColor = {sent:'var(--green)',failed:'var(--red)',queued:'var(--orange)'};
const rows = sent.map(m => {
const sc = m.status || 'sent';
const ts = m.sent_at ? new Date(m.sent_at + 'Z').toLocaleString() : '—';
const sCol = statusColor[sc] || 'var(--text-dim)';
return `<tr>
<td style="color:${sCol};font-size:0.6rem;font-weight:700">${sc.toUpperCase()}</td>
<td style="max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.65rem">${esc(m.to_email||m.to_name||'')}</td>
<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(m.subject||'(no subject)')}</td>
<td style="font-size:0.6rem;color:var(--dim)">${m.account||'gmail'}</td>
<td style="font-size:0.6rem;color:var(--dim)">${ts}</td>
<td style="white-space:nowrap">
<button class="btn btn-xs" onclick="outboxViewBody(${m.id})">VIEW</button>
<button class="btn btn-xs btn-red" onclick="outboxDelete(${m.id})">DEL</button>
</td>
</tr>`;
}).join('');
el.innerHTML = `<table><thead><tr><th>STATUS</th><th>TO</th><th>SUBJECT</th><th>ACCOUNT</th><th>SENT AT</th><th>ACTIONS</th></tr></thead><tbody>${rows}</tbody></table>`;
}
async function outboxDelete(id) {
if (!confirm('Delete this sent message record?')) return;
const d = await api('outbox_delete', {id});
if (d.ok || d.deleted) { toast('Deleted', 'ok'); loadOutbox(); }
else toast('Delete failed: ' + (d.error||''), 'err');
}
async function outboxViewBody(id) {
const d = await api('outbox_list', {limit: 200});
const sent = Array.isArray(d) ? d : (d.sent || []);
const m = sent.find(x => x.id == id);
if (!m) return;
openModal('SENT MESSAGE — ' + esc(m.subject||''), `
<div style="font-family:var(--mono);font-size:0.65rem;color:var(--text-dim);margin-bottom:8px">TO: ${esc(m.to_email||'')} · ACCOUNT: ${m.account||'gmail'}</div>
<pre style="font-size:0.65rem;white-space:pre-wrap;max-height:300px;overflow-y:auto;background:rgba(0,212,255,0.03);border:1px solid var(--border);border-radius:3px;padding:8px">${esc(m.body||'(no body)')}</pre>
`, null, null);
}
function outboxCompose() {
openModal('COMPOSE MESSAGE', `
<div style="display:flex;flex-direction:column;gap:8px">
<select id="oc-account" class="inp" style="padding:4px 8px;font-size:0.65rem">
<option value="gmail">Gmail</option>
<option value="icloud">iCloud</option>
</select>
<input id="oc-to" class="inp" placeholder="To: email address" type="email">
<input id="oc-subject" class="inp" placeholder="Subject">
<textarea id="oc-body" class="inp" rows="5" style="resize:vertical" placeholder="Describe what to say — AI will draft the full message"></textarea>
</div>
`, async () => {
const to = document.getElementById('oc-to')?.value.trim();
const subject = document.getElementById('oc-subject')?.value.trim();
const body = document.getElementById('oc-body')?.value.trim();
const account = document.getElementById('oc-account')?.value;
if (!to || !body) { toast('Please fill To and message description', 'err'); return; }
const d = await api('compose_email', {to, subject, body, account});
if (d.job_id) {
toast('Compose job dispatched — Job #' + d.job_id, 'ok');
closeModal();
setTimeout(loadOutbox, 5000);
} else {
toast('Failed: ' + (d.error||'Arc Reactor offline'), 'err');
}
}, 'DISPATCH');
}
// ── MISSION OPS ──────────────────────────────────────────────────────────────
const JOB_TYPES = ['ping','echo','shell','llm','research','tool_loop','gmail_triage',
'remote_exec','screenshot','vision','sysinfo','sitrep','send_email','compose_email',
'schedule_event','meeting_prep','run_mission'];
let _missionBuilderSteps = [];
let _missionBuilderStepIdx = 0;
async function loadMissions() {
const el = document.getElementById('missions-list');
if (!el) return;
const missions = await api('mission_list');
const list = Array.isArray(missions) ? missions : [];
document.getElementById('missions-count').textContent = list.length + ' MISSIONS';
if (!list.length) {
el.innerHTML = '<div class="loading">No missions yet. Click + NEW MISSION to create one.</div>';
return;
}
const triggerIcons = {manual:'🖐', schedule:'⏱', guardian_event:'🛡', email_keyword:'📧'};
const statusColor = {done:'var(--green)', failed:'var(--red)', running:'var(--orange)'};
const rows = list.map(m => {
const icon = triggerIcons[m.trigger_type] || '◈';
const enabled = m.enabled ? '<span style="color:var(--green)">ENABLED</span>' : '<span style="color:var(--dim)">DISABLED</span>';
const lastRun = m.last_run_at ? new Date(m.last_run_at+'Z').toLocaleString() : '—';
return `<tr>
<td style="font-family:var(--mono);font-size:0.7rem">${icon} ${esc(m.name)}</td>
<td style="font-size:0.6rem;color:var(--dim)">${m.trigger_type.replace('_',' ').toUpperCase()}</td>
<td>${enabled}</td>
<td style="font-size:0.6rem;color:var(--dim)">${m.run_count||0} runs · last ${lastRun}</td>
<td style="white-space:nowrap">
<button class="btn btn-xs btn-green" onclick="missionRunNow(${m.id})">▶ RUN</button>
<button class="btn btn-xs" onclick="missionEdit(${m.id})">EDIT</button>
<button class="btn btn-xs" onclick="missionViewRuns(${m.id})">HISTORY</button>
<button class="btn btn-xs" onclick="missionToggle(${m.id},${m.enabled?0:1})">${m.enabled?'DISABLE':'ENABLE'}</button>
</td>
</tr>`;
}).join('');
el.innerHTML = `<table><thead><tr><th>NAME</th><th>TRIGGER</th><th>STATUS</th><th>LAST RUN</th><th>ACTIONS</th></tr></thead><tbody>${rows}</tbody></table>`;
}
function missionNew() {
_missionBuilderSteps = [];
_missionBuilderStepIdx = 0;
document.getElementById('mb-mission-id').value = '';
document.getElementById('mb-name').value = '';
document.getElementById('mb-desc').value = '';
document.getElementById('mb-trigger').value = 'manual';
document.getElementById('builder-title').textContent = '◈ NEW MISSION';
document.getElementById('mb-run-btn').style.display = 'none';
document.getElementById('mb-del-btn').style.display = 'none';
document.getElementById('mb-status').textContent = '';
missionTriggerChange();
_renderBuilderSteps();
document.getElementById('mission-builder').style.display = 'block';
document.getElementById('mission-run-history').style.display = 'none';
document.getElementById('mission-builder').scrollIntoView({behavior:'smooth'});
}
async function missionEdit(id) {
const m = await api('mission_get', {id});
if (m.error) { toast('Load failed: ' + m.error, 'err'); return; }
document.getElementById('mb-mission-id').value = m.id;
document.getElementById('mb-name').value = m.name || '';
document.getElementById('mb-desc').value = m.description || '';
document.getElementById('mb-trigger').value = m.trigger_type || 'manual';
document.getElementById('builder-title').textContent = '◈ EDIT MISSION — ' + esc(m.name);
document.getElementById('mb-run-btn').style.display = '';
document.getElementById('mb-del-btn').style.display = '';
document.getElementById('mb-status').textContent = '';
missionTriggerChange(m.trigger_config || {});
_missionBuilderSteps = (m.steps || []).map(s => ({
id: ++_missionBuilderStepIdx,
label: s.label || '',
job_type: s.job_type || 'ping',
payload: typeof s.job_payload === 'string' ? s.job_payload : JSON.stringify(s.job_payload||{}, null, 2),
continue_on_failure: s.continue_on_failure ? 1 : 0,
}));
_renderBuilderSteps();
document.getElementById('mission-builder').style.display = 'block';
document.getElementById('mission-run-history').style.display = 'none';
document.getElementById('mission-builder').scrollIntoView({behavior:'smooth'});
}
function missionTriggerChange(cfg) {
const t = document.getElementById('mb-trigger')?.value;
const el = document.getElementById('mb-trigger-config');
if (!el) return;
cfg = cfg || {};
if (t === 'manual') {
el.innerHTML = '';
} else if (t === 'schedule') {
el.innerHTML = `<div class="lbl">INTERVAL (minutes)</div>
<input id="mb-tc-interval" class="inp" type="number" min="1" value="${cfg.interval_minutes||60}" style="width:160px">`;
} else if (t === 'guardian_event') {
el.innerHTML = `<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<div><div class="lbl">SEVERITY (blank=any)</div>
<select id="mb-tc-severity" class="inp">
<option value="">Any</option>
<option value="critical"${cfg.severity==='critical'?' selected':''}>Critical</option>
<option value="warning"${cfg.severity==='warning'?' selected':''}>Warning</option>
<option value="info"${cfg.severity==='info'?' selected':''}>Info</option>
</select></div>
<div><div class="lbl">EVENT TYPE (blank=any)</div>
<input id="mb-tc-etype" class="inp" value="${cfg.event_type||''}" placeholder="e.g. cpu_high"></div>
</div>`;
} else if (t === 'email_keyword') {
el.innerHTML = `<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
<div><div class="lbl">KEYWORDS (comma-separated)</div>
<input id="mb-tc-keywords" class="inp" value="${(cfg.keywords||[]).join(', ')}" placeholder="urgent, invoice, CEO"></div>
<div><div class="lbl">CATEGORY (blank=any)</div>
<select id="mb-tc-category" class="inp">
<option value="">Any</option>
<option value="urgent"${cfg.category==='urgent'?' selected':''}>Urgent</option>
<option value="action"${cfg.category==='action'?' selected':''}>Action</option>
<option value="reply"${cfg.category==='reply'?' selected':''}>Reply</option>
<option value="meeting"${cfg.category==='meeting'?' selected':''}>Meeting</option>
</select></div>
</div>`;
}
}
function _readTriggerConfig() {
const t = document.getElementById('mb-trigger')?.value;
if (t === 'schedule') {
return {interval_minutes: parseInt(document.getElementById('mb-tc-interval')?.value||60)};
} else if (t === 'guardian_event') {
return {
severity: document.getElementById('mb-tc-severity')?.value || '',
event_type: document.getElementById('mb-tc-etype')?.value || '',
};
} else if (t === 'email_keyword') {
const kw = (document.getElementById('mb-tc-keywords')?.value||'').split(',').map(s=>s.trim()).filter(Boolean);
return {keywords: kw, category: document.getElementById('mb-tc-category')?.value||''};
}
return {};
}
function missionAddStep() {
_missionBuilderStepIdx++;
_missionBuilderSteps.push({id: _missionBuilderStepIdx, label:'', job_type:'ping', payload:'{}', continue_on_failure:0});
_renderBuilderSteps();
}
function missionRemoveStep(sid) {
_missionBuilderSteps = _missionBuilderSteps.filter(s => s.id !== sid);
_renderBuilderSteps();
}
function missionMoveStep(sid, dir) {
const idx = _missionBuilderSteps.findIndex(s => s.id === sid);
if (idx < 0) return;
const newIdx = idx + dir;
if (newIdx < 0 || newIdx >= _missionBuilderSteps.length) return;
[_missionBuilderSteps[idx], _missionBuilderSteps[newIdx]] = [_missionBuilderSteps[newIdx], _missionBuilderSteps[idx]];
_renderBuilderSteps();
}
function _renderBuilderSteps() {
const el = document.getElementById('mb-steps');
if (!el) return;
if (!_missionBuilderSteps.length) {
el.innerHTML = '<div style="font-size:0.6rem;color:var(--dim);padding:8px 0">No steps yet. Click + ADD STEP.</div>';
return;
}
const typeOpts = JOB_TYPES.map(t => `<option value="${t}">${t}</option>`).join('');
el.innerHTML = _missionBuilderSteps.map((s, i) => `
<div id="step-card-${s.id}" style="border:1px solid var(--border);border-radius:4px;padding:10px 12px;margin-bottom:8px;background:rgba(0,212,255,0.02)">
<div style="display:flex;gap:6px;align-items:center;margin-bottom:8px">
<span style="font-family:var(--mono);font-size:0.6rem;color:var(--dim);min-width:24px">0${i+1}</span>
<input class="inp" style="flex:2" placeholder="Step label" value="${esc(s.label)}" oninput="_stepUpdate(${s.id},'label',this.value)">
<select class="inp" style="flex:2" onchange="_stepUpdate(${s.id},'job_type',this.value)">
${JOB_TYPES.map(t=>`<option value="${t}"${s.job_type===t?' selected':''}>${t}</option>`).join('')}
</select>
<label style="display:flex;align-items:center;gap:4px;font-size:0.55rem;color:var(--dim);white-space:nowrap">
<input type="checkbox" ${s.continue_on_failure?'checked':''} onchange="_stepUpdate(${s.id},'continue_on_failure',this.checked?1:0)"> CONTINUE IF FAIL
</label>
<button class="btn btn-xs" onclick="missionMoveStep(${s.id},-1)">↑</button>
<button class="btn btn-xs" onclick="missionMoveStep(${s.id},1)">↓</button>
<button class="btn btn-xs btn-red" onclick="missionRemoveStep(${s.id})">✗</button>
</div>
<div class="lbl">PAYLOAD (JSON — use {{step_0.field}} for prior results)</div>
<textarea class="inp" rows="3" style="font-family:var(--mono);font-size:0.6rem;resize:vertical" oninput="_stepUpdate(${s.id},'payload',this.value)">${esc(s.payload||'{}')}</textarea>
</div>
`).join('');
}
function _stepUpdate(sid, field, value) {
const s = _missionBuilderSteps.find(x => x.id === sid);
if (s) s[field] = value;
}
async function missionSave() {
const id = parseInt(document.getElementById('mb-mission-id')?.value || 0) || null;
const name = document.getElementById('mb-name')?.value.trim();
const desc = document.getElementById('mb-desc')?.value.trim();
const ttype = document.getElementById('mb-trigger')?.value;
const tcfg = _readTriggerConfig();
const status = document.getElementById('mb-status');
if (!name) { if (status) status.textContent = '✗ Mission name is required'; return; }
const steps = _missionBuilderSteps.map((s, i) => {
let payload = {};
try { payload = JSON.parse(s.payload || '{}'); } catch(e) { payload = {}; }
return {label: s.label, job_type: s.job_type, payload, continue_on_failure: s.continue_on_failure};
});
const body = {name, description: desc, trigger_type: ttype, trigger_config: tcfg, enabled: 1, steps};
if (status) status.textContent = '◈ SAVING…';
const url = id ? `admin?action=mission_save&id=${id}` : 'admin?action=mission_save';
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
}).then(r => r.json()).catch(() => ({error: 'request failed'}));
if (res.ok || res.id) {
if (status) status.textContent = '◈ SAVED ✓';
if (!id && res.id) document.getElementById('mb-mission-id').value = res.id;
document.getElementById('mb-run-btn').style.display = '';
document.getElementById('mb-del-btn').style.display = '';
toast('Mission saved', 'ok');
loadMissions();
} else {
if (status) status.textContent = '✗ Save failed: ' + (res.error || 'unknown');
toast('Save failed: ' + (res.error || ''), 'err');
}
}
async function missionRunNow(id) {
const d = await api('mission_run', {id});
if (d.run_id || d.status) {
const s = d.status || 'running';
const color = s === 'done' ? 'ok' : s === 'failed' ? 'err' : 'ok';
toast(`Mission run ${s} — Run #${d.run_id||'?'} (${d.steps||0} steps)`, color);
loadMissions();
} else {
toast('Run failed: ' + (d.error || 'Arc Reactor offline'), 'err');
}
}
async function missionRunFromBuilder() {
const id = parseInt(document.getElementById('mb-mission-id')?.value || 0);
if (!id) { toast('Save the mission first', 'err'); return; }
const status = document.getElementById('mb-status');
if (status) status.textContent = '◈ RUNNING…';
await missionRunNow(id);
if (status) status.textContent = '◈ Run dispatched — check HISTORY';
setTimeout(() => missionViewRuns(id), 1500);
}
async function missionDeleteFromBuilder() {
const id = parseInt(document.getElementById('mb-mission-id')?.value || 0);
if (!id || !confirm('Delete this mission and all its run history?')) return;
const d = await api('mission_delete', {id});
if (d.ok) {
toast('Mission deleted', 'ok');
document.getElementById('mission-builder').style.display = 'none';
document.getElementById('mission-run-history').style.display = 'none';
loadMissions();
} else {
toast('Delete failed', 'err');
}
}
async function missionViewRuns(id) {
const el = document.getElementById('mission-runs-tbl');
const box = document.getElementById('mission-run-history');
if (!el || !box) return;
box.style.display = 'block';
const runs = await api('mission_runs', {id, limit: 20});
const list = Array.isArray(runs) ? runs : [];
if (!list.length) { el.innerHTML = '<div class="loading">No runs yet.</div>'; return; }
const sColor = {done:'var(--green)', failed:'var(--red)', running:'var(--orange)', cancelled:'var(--dim)'};
const rows = list.map(r => {
const sc = r.status || 'done';
const ts = r.started_at ? new Date(r.started_at+'Z').toLocaleString() : '—';
const dur = r.completed_at && r.started_at
? Math.round((new Date(r.completed_at) - new Date(r.started_at)) / 1000) + 's'
: '—';
return `<tr>
<td style="font-family:var(--mono);font-size:0.65rem">#${r.id}</td>
<td style="color:${sColor[sc]||'var(--text)'};font-size:0.6rem;font-weight:700">${sc.toUpperCase()}</td>
<td style="font-size:0.6rem;color:var(--dim)">${esc(r.trigger_source||'manual')}</td>
<td style="font-size:0.6rem;color:var(--dim)">${ts}</td>
<td style="font-size:0.6rem;color:var(--dim)">${dur}</td>
<td><button class="btn btn-xs" onclick="missionRunDetail(${r.id})">STEPS</button></td>
</tr>`;
}).join('');
el.innerHTML = `<table><thead><tr><th>RUN</th><th>STATUS</th><th>TRIGGER</th><th>STARTED</th><th>DURATION</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
box.scrollIntoView({behavior:'smooth'});
}
async function missionRunDetail(runId) {
// Fetch from DB via a direct approach — get all runs and find this one
// We'll just show steps_log from the run using the mission_runs table
const res = await fetch(`admin?action=mission_runs&id=0&limit=200&run_id=${runId}`)
.then(r => r.json()).catch(() => ({}));
// Fallback: fetch all runs for the currently edited mission
const mid = parseInt(document.getElementById('mb-mission-id')?.value || 0);
const runs = mid ? await api('mission_runs', {id: mid, limit: 50}) : [];
const run = Array.isArray(runs) ? runs.find(r => r.id == runId) : null;
if (!run) { toast('Run details not available', 'err'); return; }
const steps = run.steps_log;
const list = Array.isArray(steps) ? steps : (typeof steps === 'string' ? JSON.parse(steps||'[]') : []);
const rows = list.map(s => {
const sc = s.status || 'done';
const sc_color = sc==='done'?'var(--green)':sc==='failed'?'var(--red)':'var(--orange)';
const result = s.result ? JSON.stringify(s.result).substring(0,120) : s.error || '—';
return `<tr>
<td style="font-family:var(--mono);font-size:0.6rem">${s.step+1}</td>
<td style="font-size:0.6rem">${esc(s.label||s.job_type)}</td>
<td style="font-size:0.6rem;color:var(--dim)">${esc(s.job_type)}</td>
<td style="color:${sc_color};font-size:0.6rem;font-weight:700">${sc.toUpperCase()}</td>
<td style="font-size:0.58rem;color:var(--dim);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(result)}</td>
</tr>`;
}).join('');
openModal(`RUN #${runId} STEP LOG`, `
<table style="width:100%">
<thead><tr><th>#</th><th>LABEL</th><th>TYPE</th><th>STATUS</th><th>RESULT</th></tr></thead>
<tbody>${rows}</tbody>
</table>
`, null, null);
}
async function missionToggle(id, enabled) {
const d = await api('mission_toggle', {id, enabled});
if (d.ok) loadMissions();
else toast('Toggle failed', 'err');
}
// ── DIRECTIVES ───────────────────────────────────────────────────────────────
let _dirKRs = [];
let _dirKRIdx = 0;
const CAT_COLORS = {work:'var(--cyan)',personal:'#a78bfa',health:'#00ff88',finance:'#ffd700',home:'var(--orange)',other:'var(--text-dim)'};
async function loadDirectives() {
const el = document.getElementById('directives-list');
if (!el) return;
const status = document.getElementById('dir-status-filter')?.value || 'active';
const category = document.getElementById('dir-cat-filter')?.value || '';
const params = {status};
if (category) params.category = category;
const d = await api('directive_list', params);
const list = d.directives || [];
document.getElementById('directives-count').textContent = list.length + ' DIRECTIVES';
if (!list.length) {
el.innerHTML = '<div class="loading">No directives found. Click + NEW DIRECTIVE to create one.</div>';
return;
}
const rows = list.map(dir => {
const pct = Math.min(100, Math.round(dir.progress || 0));
const catColor = CAT_COLORS[dir.category] || 'var(--text-dim)';
const daysLeft = dir.target_date
? Math.ceil((new Date(dir.target_date) - new Date()) / 86400000)
: null;
const dueBadge = daysLeft !== null
? `<span style="font-family:var(--mono);font-size:0.55rem;color:${daysLeft<0?'var(--red)':daysLeft<14?'var(--orange)':'var(--text-dim)'}">
${daysLeft<0?'OVERDUE '+Math.abs(daysLeft)+'d':daysLeft+'d left'}</span>`
: '';
const statusBadge = dir.status !== 'active'
? `<span style="font-size:0.55rem;color:var(--dim);margin-left:4px">[${dir.status.toUpperCase()}]</span>`
: '';
return `<tr>
<td style="min-width:200px">
<div style="font-size:0.7rem;font-family:var(--mono)">${esc(dir.title)}${statusBadge}</div>
<div style="font-size:0.58rem;color:${catColor};margin-top:2px">${dir.category.toUpperCase()} · P${dir.priority}</div>
</td>
<td style="min-width:160px">
<div style="display:flex;align-items:center;gap:6px">
<div style="flex:1;height:6px;background:rgba(255,255,255,0.08);border-radius:3px">
<div style="width:${pct}%;height:100%;background:${pct>=80?'var(--green)':pct>=40?'var(--orange)':'var(--red)'};border-radius:3px"></div>
</div>
<span style="font-family:var(--mono);font-size:0.6rem;min-width:32px">${pct}%</span>
</div>
</td>
<td>${dueBadge}</td>
<td style="font-family:var(--mono);font-size:0.58rem;color:var(--dim)">${dir.kr_count||0} KRs · ${dir.link_count||0} links</td>
<td style="white-space:nowrap">
<button class="btn btn-xs btn-green" onclick="directiveEdit(${dir.id})">EDIT</button>
<button class="btn btn-xs" onclick="directiveReviewSingle(${dir.id})">◈ AI REVIEW</button>
</td>
</tr>`;
}).join('');
el.innerHTML = `<table><thead><tr><th>OBJECTIVE</th><th>PROGRESS</th><th>DUE</th><th>DETAILS</th><th>ACTIONS</th></tr></thead><tbody>${rows}</tbody></table>`;
}
function directiveNew() {
_dirKRs = []; _dirKRIdx = 0;
document.getElementById('dir-id').value = '';
document.getElementById('dir-title').value = '';
document.getElementById('dir-desc').value = '';
document.getElementById('dir-category').value = 'work';
document.getElementById('dir-status').value = 'active';
document.getElementById('dir-priority').value = 5;
document.getElementById('dir-target-date').value = '';
document.getElementById('dir-editor-title').textContent = '◈ NEW DIRECTIVE';
document.getElementById('dir-del-btn').style.display = 'none';
document.getElementById('dir-save-status').textContent = '';
_renderDirKRs();
document.getElementById('directive-editor').style.display = 'block';
document.getElementById('directive-editor').scrollIntoView({behavior:'smooth'});
}
async function directiveEdit(id) {
const d = await api('directive_get', {id});
if (d.error) { toast('Load failed: ' + d.error, 'err'); return; }
document.getElementById('dir-id').value = d.id;
document.getElementById('dir-title').value = d.title || '';
document.getElementById('dir-desc').value = d.description || '';
document.getElementById('dir-category').value = d.category || 'work';
document.getElementById('dir-status').value = d.status || 'active';
document.getElementById('dir-priority').value = d.priority || 5;
document.getElementById('dir-target-date').value = d.target_date || '';
document.getElementById('dir-editor-title').textContent = '◈ EDIT — ' + esc(d.title);
document.getElementById('dir-del-btn').style.display = '';
document.getElementById('dir-save-status').textContent = '';
_dirKRs = (d.key_results || []).map(kr => ({
id: ++_dirKRIdx, dbid: kr.id,
title: kr.title, current_value: kr.current_value,
target_value: kr.target_value, unit: kr.unit || '%',
}));
_renderDirKRs();
document.getElementById('directive-editor').style.display = 'block';
document.getElementById('directive-editor').scrollIntoView({behavior:'smooth'});
}
function dirAddKR() {
_dirKRIdx++;
_dirKRs.push({id: _dirKRIdx, dbid: null, title:'', current_value:0, target_value:100, unit:'%'});
_renderDirKRs();
}
function dirRemoveKR(sid) {
_dirKRs = _dirKRs.filter(k => k.id !== sid);
_renderDirKRs();
}
function _krUpdate(sid, field, val) {
const k = _dirKRs.find(x => x.id === sid);
if (k) k[field] = val;
}
function _renderDirKRs() {
const el = document.getElementById('dir-kr-list');
if (!el) return;
if (!_dirKRs.length) {
el.innerHTML = '<div style="font-size:0.6rem;color:var(--dim)">No key results yet — click + ADD KEY RESULT</div>';
return;
}
el.innerHTML = _dirKRs.map(k => `
<div style="display:grid;grid-template-columns:2fr 80px 80px 60px 28px;gap:6px;align-items:center;margin-bottom:6px">
<input class="inp" value="${esc(k.title)}" placeholder="Key result title" oninput="_krUpdate(${k.id},'title',this.value)">
<input class="inp" type="number" step="0.1" value="${k.current_value}" placeholder="Current" title="Current value" oninput="_krUpdate(${k.id},'current_value',parseFloat(this.value)||0)">
<input class="inp" type="number" step="0.1" value="${k.target_value}" placeholder="Target" title="Target value" oninput="_krUpdate(${k.id},'target_value',parseFloat(this.value)||1)">
<input class="inp" value="${esc(k.unit)}" placeholder="Unit" title="Unit (%, $, hrs...)" oninput="_krUpdate(${k.id},'unit',this.value)">
<button class="btn btn-xs btn-red" onclick="dirRemoveKR(${k.id})">✗</button>
</div>
`).join('');
}
async function directiveSave() {
const id = parseInt(document.getElementById('dir-id')?.value || 0) || null;
const title = document.getElementById('dir-title')?.value.trim();
const desc = document.getElementById('dir-desc')?.value.trim();
const category = document.getElementById('dir-category')?.value;
const status = document.getElementById('dir-status')?.value;
const priority = parseInt(document.getElementById('dir-priority')?.value || 5);
const target_date = document.getElementById('dir-target-date')?.value || '';
const stat = document.getElementById('dir-save-status');
if (!title) { if (stat) stat.textContent = '✗ Title required'; return; }
const key_results = _dirKRs.map(k => ({
title: k.title, current_value: parseFloat(k.current_value)||0,
target_value: parseFloat(k.target_value)||1, unit: k.unit||'%',
})).filter(k => k.title.trim());
if (stat) stat.textContent = '◈ SAVING…';
const d = await fetch(`admin?action=directive_save${id?'&id='+id:''}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({title, description:desc, category, status, priority, target_date, key_results}),
}).then(r => r.json()).catch(() => ({error: 'request failed'}));
if (d.ok) {
if (stat) stat.textContent = '◈ SAVED ✓';
toast('Directive saved', 'ok');
loadDirectives();
} else {
if (stat) stat.textContent = '✗ ' + (d.error || 'Save failed');
toast('Save failed', 'err');
}
}
async function directiveDelete() {
const id = parseInt(document.getElementById('dir-id')?.value || 0);
if (!id || !confirm('Delete this directive and all its key results?')) return;
const d = await api('directive_delete', {id});
if (d.ok) {
toast('Directive deleted', 'ok');
document.getElementById('directive-editor').style.display = 'none';
loadDirectives();
} else toast('Delete failed', 'err');
}
async function directiveReviewAI(id) {
toast('◈ Dispatching AI directive review…', 'ok');
const payload = id ? {directive_id: id, provider: 'claude'} : {provider: 'claude'};
const res = await fetch('admin?action=arc_action', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({action:'job_create', type:'directive_review', payload, priority: 6}),
}).then(r => r.json()).catch(() => ({}));
if (res.job_id) toast('Review job #' + res.job_id + ' started — results will appear in JARVIS chat', 'ok');
else toast('Failed: ' + (res.error||'Arc offline'), 'err');
}
async function directiveReviewSingle(id) { return directiveReviewAI(id); }
// ── 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';
});
}
// ── MEMORY CORE ───────────────────────────────────────────────────────────────
const MEM_CAT_COLORS = {
preference:'var(--cyan)', person:'#a78bfa', place:'#00ff88',
routine:'#ffd700', goal:'#ff9900', fact:'var(--text)', instruction:'#ff6680'
};
async function loadMemory() {
const el = document.getElementById('memory-list');
const cat = document.getElementById('mem-cat-filter').value;
const search = document.getElementById('mem-search').value;
el.innerHTML = '<div class="loading">LOADING...</div>';
const [facts, stats] = await Promise.all([
api('memory_list' + (cat ? '&category='+encodeURIComponent(cat) : '') + (search ? '&search='+encodeURIComponent(search) : '') + '&limit=200'),
api('memory_stats'),
]);
const list = Array.isArray(facts) ? facts : [];
const s = stats || {};
const bar = document.getElementById('memory-stats-bar');
if (bar) {
const cats = (s.by_category||[]).map(c=>`${c.cnt} ${c.category}`).join(' · ');
bar.textContent = `${s.total||0} FACTS${cats ? ' — ' + cats : ''}`;
}
if (!list.length) {
el.innerHTML = '<div class="empty-state">No memory facts yet. Start chatting with JARVIS — I auto-learn from conversations.</div>';
return;
}
// Group by category
const grouped = {};
for (const f of list) {
if (!grouped[f.category]) grouped[f.category] = [];
grouped[f.category].push(f);
}
let html = '';
const catOrder = ['instruction','preference','person','place','routine','goal','fact'];
const allCats = [...new Set([...catOrder, ...Object.keys(grouped)])];
for (const cat of allCats) {
if (!grouped[cat]) continue;
const color = MEM_CAT_COLORS[cat] || 'var(--text)';
html += `<div style="margin-bottom:16px">
<div style="font-family:var(--mono);font-size:0.65rem;letter-spacing:2px;color:${color};margin-bottom:6px;display:flex;align-items:center;gap:8px">
${cat.toUpperCase()} <span style="color:var(--dim)">(${grouped[cat].length})</span>
<button class="btn btn-xs btn-red" onclick="memoryClearCategory('${cat}')" style="margin-left:auto">CLEAR</button>
</div>
<table class="tbl"><thead><tr><th>SUBJECT</th><th>PREDICATE</th><th>VALUE</th><th>CONFIDENCE</th><th>CONFIRMED</th><th>SOURCE</th><th>LAST SEEN</th><th></th></tr></thead><tbody>`;
for (const f of grouped[cat]) {
const conf = (parseFloat(f.confidence)*100).toFixed(0)+'%';
const ts = f.last_confirmed_at ? new Date(f.last_confirmed_at).toLocaleDateString() : '';
const srcColor = f.source === 'explicit' ? 'var(--green)' : f.source === 'inference' ? 'var(--yellow)' : 'var(--dim)';
html += `<tr>
<td style="font-family:var(--mono);font-size:0.65rem;color:${color}">${esc(f.subject)}</td>
<td style="font-family:var(--mono);font-size:0.6rem;color:var(--dim)">${esc(f.predicate)}</td>
<td style="font-family:var(--mono);font-size:0.6rem">${esc(f.object)}</td>
<td style="font-family:var(--mono);font-size:0.6rem">${conf}</td>
<td style="font-family:var(--mono);font-size:0.6rem;text-align:center">${f.confirmed_count}</td>
<td style="font-family:var(--mono);font-size:0.55rem;color:${srcColor}">${f.source}</td>
<td style="font-family:var(--mono);font-size:0.55rem;color:var(--dim)">${ts}</td>
<td><button class="btn btn-xs btn-red" onclick="memoryDelete(${f.id})">DEL</button></td>
</tr>`;
}
html += '</tbody></table></div>';
}
el.innerHTML = html;
}
function memoryNew() {
document.getElementById('memory-editor').style.display = 'block';
document.getElementById('mem-new-subject').focus();
}
async function memorySave() {
const body = {
category: document.getElementById('mem-new-cat').value,
subject: document.getElementById('mem-new-subject').value.trim(),
predicate: document.getElementById('mem-new-predicate').value.trim() || 'is',
object: document.getElementById('mem-new-object').value.trim(),
};
if (!body.subject || !body.object) { toast('Subject and value required','err'); return; }
try {
const r = await fetch(location.href + '?action=memory_store', {
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body)
});
const d = await r.json();
if (d.ok) {
toast('Fact stored', 'ok');
document.getElementById('mem-new-subject').value = '';
document.getElementById('mem-new-predicate').value = '';
document.getElementById('mem-new-object').value = '';
document.getElementById('memory-editor').style.display = 'none';
loadMemory();
} else { toast('Error: '+(d.detail||d.error||'unknown'),'err'); }
} catch(e) { toast('Failed','err'); }
}
async function memoryDelete(id) {
if (!confirm('Delete this memory fact?')) return;
await fetch(location.href + '?action=memory_delete&id=' + id, {method:'POST'});
toast('Deleted','ok');
loadMemory();
}
async function memoryClearCategory(cat) {
if (!confirm('Clear all ' + cat + ' memories?')) return;
await fetch(location.href + '?action=memory_clear&category=' + encodeURIComponent(cat), {method:'POST'});
toast('Cleared ' + cat + ' memories', 'ok');
loadMemory();
}
async function memoryClearAll() {
if (!confirm('Clear ALL memory facts? This cannot be undone.')) return;
if (!confirm('Are you absolutely sure? All JARVIS memories will be deleted.')) return;
await fetch(location.href + '?action=memory_clear', {method:'POST'});
toast('All memories cleared', 'ok');
loadMemory();
}
// ── CLEARANCE PROTOCOL ────────────────────────────────────────────────────────
async function loadClearance() {
const [pending, rules, history] = await Promise.all([
api('clearance_pending'),
api('clearance_rules'),
api('clearance_history&limit=30'),
]);
const pList = Array.isArray(pending) ? pending : [];
const rList = Array.isArray(rules) ? rules : [];
const hList = Array.isArray(history) ? history : [];
document.getElementById('clearance-badge').textContent =
pList.length ? pList.length + ' PENDING' : 'ALL CLEAR';
// Pending
const pelEl = document.getElementById('clearance-pending-list');
if (!pList.length) {
pelEl.innerHTML = '<div class="empty-state">No pending requests</div>';
} else {
pelEl.innerHTML = pList.map(cr => {
const pl = typeof cr.job_payload === 'string' ? JSON.parse(cr.job_payload||'{}') : (cr.job_payload||{});
const ts = cr.created_at ? new Date(cr.created_at).toLocaleString() : '';
const riskColor = {critical:'var(--red)',high:'var(--yellow)',medium:'var(--orange)'}[cr.risk_level] || 'var(--dim)';
return `<div style="border:1px solid ${riskColor};border-radius:6px;padding:12px;margin-bottom:8px;background:rgba(255,34,68,0.03)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<span style="font-family:var(--mono);font-size:0.7rem;color:${riskColor};font-weight:bold">#${cr.id} ${esc(cr.job_type.toUpperCase().replace(/_/g,' '))}</span>
<span style="margin-left:auto;font-family:var(--mono);font-size:0.6rem;color:var(--dim)">${ts}</span>
</div>
<div style="font-family:var(--mono);font-size:0.6rem;color:var(--text);margin-bottom:6px">${esc(cr.description||'No description')}</div>
<div style="font-family:var(--mono);font-size:0.55rem;color:var(--dim);margin-bottom:8px;word-break:break-all">Payload: ${esc(JSON.stringify(pl))}</div>
<div style="display:flex;gap:6px">
<button class="btn btn-sm btn-green" onclick="clearanceDecide(${cr.id},'approve')">◈ AUTHORIZE</button>
<button class="btn btn-sm btn-red" onclick="clearanceDecide(${cr.id},'deny')">✕ DENY</button>
</div>
</div>`;
}).join('');
}
// Rules
const rElEl = document.getElementById('clearance-rules-list');
if (!rList.length) {
rElEl.innerHTML = '<div class="empty-state">No rules configured</div>';
} else {
rElEl.innerHTML = '<table class="tbl"><thead><tr><th>JOB TYPE</th><th>RISK</th><th>APPROVAL</th><th>AUTO (MIN)</th><th>ENABLED</th><th></th></tr></thead><tbody>' +
rList.map(r => {
const enLabel = r.enabled ? '<span style="color:var(--green)">ON</span>' : '<span style="color:var(--dim)">OFF</span>';
const reqLabel = r.require_approval ? 'REQUIRED' : 'BYPASS';
const riskColor = {critical:'var(--red)',high:'var(--yellow)',medium:'var(--orange)'}[r.risk_level] || 'var(--dim)';
return `<tr>
<td style="font-family:var(--mono);font-size:0.65rem">${esc(r.job_type)}</td>
<td style="color:${riskColor};font-family:var(--mono);font-size:0.6rem">${r.risk_level.toUpperCase()}</td>
<td style="font-family:var(--mono);font-size:0.6rem">${reqLabel}</td>
<td style="font-family:var(--mono);font-size:0.6rem">${r.auto_approve_after_min || '—'}</td>
<td>${enLabel}</td>
<td><div style="display:flex;gap:4px">
<button class="btn btn-xs" onclick="clearanceRuleToggle(${r.id},${r.enabled?0:1})">${r.enabled?'DISABLE':'ENABLE'}</button>
<button class="btn btn-xs btn-yellow" onclick="clearanceRuleEdit(${r.id})">EDIT</button>
</div></td>
</tr>`;
}).join('') + '</tbody></table>';
}
// History
const hEl = document.getElementById('clearance-history-list');
const decided = hList.filter(h => h.status !== 'pending').slice(0,20);
if (!decided.length) {
hEl.innerHTML = '<div class="empty-state">No history yet</div>';
} else {
const statusColor = {approved:'var(--green)',denied:'var(--red)',expired:'var(--dim)',auto_approved:'var(--cyan)'};
hEl.innerHTML = '<table class="tbl"><thead><tr><th>#</th><th>JOB TYPE</th><th>RISK</th><th>STATUS</th><th>DECIDED BY</th><th>DECIDED AT</th><th>NOTE</th></tr></thead><tbody>' +
decided.map(h => {
const sc = statusColor[h.status] || 'var(--dim)';
const ts = h.decided_at ? new Date(h.decided_at).toLocaleString() : '';
return `<tr>
<td style="font-family:var(--mono)">${h.id}</td>
<td style="font-family:var(--mono);font-size:0.6rem">${esc(h.job_type)}</td>
<td style="font-family:var(--mono);font-size:0.6rem">${h.risk_level}</td>
<td style="color:${sc};font-family:var(--mono);font-size:0.6rem;font-weight:bold">${h.status.toUpperCase()}</td>
<td style="font-family:var(--mono);font-size:0.6rem">${esc(h.decided_by||'—')}</td>
<td style="font-family:var(--mono);font-size:0.55rem;color:var(--dim)">${ts}</td>
<td style="font-family:var(--mono);font-size:0.55rem;color:var(--dim)">${esc(h.decision_note||'')}</td>
</tr>`;
}).join('') + '</tbody></table>';
}
}
async function clearanceDecide(id, action) {
const label = action === 'approve' ? 'AUTHORIZE' : 'DENY';
if (!confirm(`${label} clearance request #${id}?`)) return;
let note = '';
if (action === 'deny') note = prompt('Reason for denial (optional):') || '';
const body = {decided_by: 'admin'};
if (note) body.note = note;
try {
const r = await fetch(location.href + '?action=clearance_' + action + '&id=' + id, {
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body)
});
const d = await r.json();
if (d.ok || d.job_id) {
toast(label + 'D clearance #' + id, 'ok');
loadClearance();
} else {
toast('Error: ' + (d.error || d.detail || 'unknown'), 'err');
}
} catch(e) { toast('Request failed', 'err'); }
}
async function clearanceRuleToggle(id, newEnabled) {
try {
await fetch(location.href + '?action=clearance_rule_update&id=' + id, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({enabled: newEnabled})
});
toast(newEnabled ? 'Rule enabled' : 'Rule disabled', 'ok');
loadClearance();
} catch(e) { toast('Failed', 'err'); }
}
async function clearanceRuleEdit(id) {
// Open a simple prompt-based edit for auto_approve_after_min
const mins = prompt('Auto-approve after N minutes (blank = never require auto-approval):');
if (mins === null) return;
const body = {auto_approve_after_min: mins === '' ? null : parseInt(mins)};
try {
await fetch(location.href + '?action=clearance_rule_update&id=' + id, {
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body)
});
toast('Rule updated', 'ok');
loadClearance();
} catch(e) { toast('Failed', 'err'); }
}
async function clearanceRuleCreate() {
const jobType = document.getElementById('clr-new-type').value.trim();
if (!jobType) { toast('Job type required', 'err'); return; }
const body = {
job_type: jobType,
risk_level: document.getElementById('clr-new-risk').value,
require_approval: parseInt(document.getElementById('clr-new-req').value),
auto_approve_after_min: document.getElementById('clr-new-auto').value || null,
description: document.getElementById('clr-new-desc').value.trim(),
enabled: 1,
};
try {
const r = await fetch(location.href + '?action=clearance_rule_create', {
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body)
});
const d = await r.json();
if (d.ok) {
toast('Rule created', 'ok');
document.getElementById('clr-new-type').value = '';
document.getElementById('clr-new-desc').value = '';
document.getElementById('clr-new-auto').value = '';
loadClearance();
} else {
toast('Error: ' + (d.detail || 'unknown'), 'err');
}
} catch(e) { toast('Failed', 'err'); }
}
// ── ARC REACTOR ──────────────────────────────────────────────────────────────
async function loadArc() {
const tbl = document.getElementById('arc-jobs-tbl');
if (!tbl) return;
tbl.innerHTML = '<div class="loading">LOADING...</div>';
const status = document.getElementById('arc-job-filter')?.value || '';
const [s, jobs] = await Promise.all([
api('arc_status'),
api('arc_jobs', {status, limit: 100}),
]);
// status bar
const online = s?.online;
document.getElementById('arc-status-val').textContent = online ? '● ONLINE' : '○ OFFLINE';
document.getElementById('arc-status-val').style.color = online ? 'var(--green)' : 'var(--red)';
document.getElementById('arc-version-val').textContent = s?.version || '—';
document.getElementById('arc-done-val').textContent = s?.jobs_done ?? s?.stats?.done ?? '—';
document.getElementById('arc-fail-val').textContent = s?.jobs_failed ?? s?.stats?.failed ?? '—';
document.getElementById('arc-hb-val').textContent = s?.last_heartbeat ? ts(s.last_heartbeat) : (online ? 'ALIVE' : '—');
const caps = s?.capabilities || s?.handlers;
document.getElementById('arc-caps-val').textContent = Array.isArray(caps) ? caps.join(' · ') : (caps || '—');
const list = Array.isArray(jobs) ? jobs : (jobs?.jobs || []);
if (!list.length) {
tbl.innerHTML = '<div class="empty">No jobs found.</div>';
return;
}
const STATUS_COLOR = {queued:'var(--cyan)',running:'var(--yellow)',done:'var(--green)',failed:'var(--red)',cancelled:'var(--dim)'};
const rows = list.map(j => {
const sc = STATUS_COLOR[j.status] || 'var(--text)';
return `<tr>
<td style="width:50px;font-family:var(--mono);font-size:0.65rem;color:var(--dim)">#${j.id}</td>
<td style="width:80px"><span style="color:${sc};font-size:0.62rem;font-weight:700">${esc(j.status||'').toUpperCase()}</span></td>
<td style="width:120px;font-size:0.65rem">${esc(j.type||'—')}</td>
<td style="font-size:0.62rem;color:var(--dim)">${esc(j.created_by||'—')}</td>
<td style="font-size:0.62rem;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${j.result ? esc(JSON.stringify(j.result).substring(0,120)) : '—'}</td>
<td style="font-size:0.6rem;color:var(--dim);white-space:nowrap">${ts(j.created_at)}</td>
</tr>`;
}).join('');
tbl.innerHTML = `<table><thead><tr>
<th>ID</th><th>STATUS</th><th>TYPE</th><th>BY</th><th>RESULT</th><th>CREATED</th>
</tr></thead><tbody>${rows}</tbody></table>`;
}
async function arcTestPing() {
const d = await api('arc_ping');
if (d?.job_id || d?.id) {
toast('Ping job queued — ID #' + (d.job_id || d.id), 'ok');
setTimeout(loadArc, 1200);
} else {
toast('Ping failed: ' + (d?.error || 'no response'), 'err');
}
}
async function arcPurge() {
if (!confirm('Purge completed/failed jobs older than 24h?')) return;
const d = await api('arc_purge');
toast(d?.purged != null ? `Purged ${d.purged} jobs` : 'Purge complete', 'ok');
loadArc();
}
</script>
</body>
</html>