mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
90e4ded7c9
- 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
4744 lines
264 KiB
PHP
4744 lines
264 KiB
PHP
<?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)">⚙ 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">⚙ JARVIS AGENT WORKERS
|
||
<button onclick="loadWorkers()" style="font-size:0.6rem;padding:4px 12px">↻ 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>
|
||
<span class="lbl" id="net-count" style="color:var(--cyan)"></span>
|
||
</div>
|
||
<div class="tbl-wrap" id="network-tbl"><div class="loading">SCANNING...</div></div>
|
||
</div>
|
||
|
||
<!-- ALERTS -->
|
||
<div class="tab" id="tab-alerts">
|
||
<div class="page-title">ALERTS
|
||
<div class="actions">
|
||
<button class="btn btn-sm btn-green" onclick="alertModal()">+ CREATE</button>
|
||
<button class="btn btn-sm btn-yellow" onclick="apiPost('alerts_resolve_all',{},()=>{toast('All resolved','ok');loadAlerts()})">RESOLVE ALL</button>
|
||
<button class="btn btn-sm btn-red" onclick="apiPost('alerts_purge_resolved',{},()=>{toast('Purged','ok');loadAlerts()})">PURGE RESOLVED</button>
|
||
<button class="btn btn-sm" onclick="loadAlerts()">REFRESH</button>
|
||
</div>
|
||
</div>
|
||
<div class="filters">
|
||
<span class="lbl">FILTER:</span>
|
||
<button class="filter-btn active" onclick="setAlertFilter('active',this)">ACTIVE</button>
|
||
<button class="filter-btn" onclick="setAlertFilter('all',this)">ALL</button>
|
||
<button class="filter-btn" onclick="setAlertFilter('resolved',this)">RESOLVED</button>
|
||
</div>
|
||
<div class="tbl-wrap" id="alerts-tbl"><div class="loading">SCANNING...</div></div>
|
||
</div>
|
||
|
||
<!-- KB FACTS -->
|
||
<div class="tab" id="tab-facts">
|
||
<div class="page-title">KB FACTS <span id="facts-count" style="color:var(--cyan);font-size:0.6rem;letter-spacing:2px"></span>
|
||
<div class="actions">
|
||
<button class="btn btn-sm btn-green" onclick="factModal()">+ ADD FACT</button>
|
||
<button class="btn btn-sm" onclick="loadFacts()">REFRESH</button>
|
||
</div>
|
||
</div>
|
||
<div class="filters">
|
||
<span class="lbl">CATEGORY:</span>
|
||
<select class="filter-sel" id="factCat" onchange="loadFacts()">
|
||
<option value="__all__">ALL</option>
|
||
</select>
|
||
<button class="filter-btn active" id="fact-hide-unavail" onclick="toggleFactUnavail(this)" title="Hide unavailable/empty values">HIDE UNAVAIL</button>
|
||
</div>
|
||
<div class="tbl-wrap" id="facts-tbl"><div class="loading">SCANNING...</div></div>
|
||
</div>
|
||
|
||
<!-- KB INTENTS -->
|
||
<div class="tab" id="tab-intents">
|
||
<div class="page-title">KB INTENTS <span id="intents-count" style="color:var(--cyan);font-size:0.6rem;letter-spacing:2px"></span>
|
||
<div class="actions">
|
||
<button class="btn btn-sm btn-green" onclick="intentModal()">+ ADD INTENT</button>
|
||
<button class="btn btn-sm btn-yellow" onclick="intentTestModal()">TEST PATTERN</button>
|
||
<button class="btn btn-sm" onclick="loadIntents()">REFRESH</button>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;margin-bottom:10px;align-items:center">
|
||
<input id="intents-search" type="text" placeholder="Filter by name, pattern, or response…" style="flex:1;background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:6px 10px;font-size:0.75rem;border-radius:3px;font-family:inherit" oninput="filterIntents(this.value)">
|
||
<select id="intents-filter-type" style="background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:6px 8px;font-size:0.75rem;border-radius:3px;font-family:inherit" onchange="filterIntents(document.getElementById('intents-search').value)">
|
||
<option value="">ALL TYPES</option>
|
||
<option value="response">RESPONSE</option>
|
||
<option value="action">ACTION</option>
|
||
</select>
|
||
<select id="intents-filter-status" style="background:var(--bg2);border:1px solid var(--border);color:var(--fg);padding:6px 8px;font-size:0.75rem;border-radius:3px;font-family:inherit" onchange="filterIntents(document.getElementById('intents-search').value)">
|
||
<option value="">ALL STATUS</option>
|
||
<option value="1">ACTIVE</option>
|
||
<option value="0">DISABLED</option>
|
||
</select>
|
||
</div>
|
||
<div class="tbl-wrap" id="intents-tbl"><div class="loading">SCANNING...</div></div>
|
||
</div>
|
||
|
||
<!-- HOME ASSISTANT -->
|
||
<div class="tab" id="tab-ha">
|
||
<div class="page-title">HOME ASSISTANT ENTITIES <span id="ha-title-count" style="color:var(--cyan);font-size:0.6rem;letter-spacing:2px"></span>
|
||
<div class="actions"><button class="btn btn-sm" onclick="loadHA()">REFRESH</button></div>
|
||
</div>
|
||
<div class="filters">
|
||
<span class="lbl">DOMAIN:</span>
|
||
<select class="filter-sel" id="ha-domain" onchange="loadHA()"><option value="">ALL</option></select>
|
||
<button class="filter-btn active" id="ha-all-btn" onclick="setHAOnlyOn(false,this)">ALL</button>
|
||
<button class="filter-btn" id="ha-on-btn" onclick="setHAOnlyOn(true,this)">ON ONLY</button>
|
||
<input id="ha-search" placeholder="search by name..." style="background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:4px 8px;font-family:var(--font);font-size:0.65rem;width:200px;outline:none" oninput="filterHATable()" onchange="filterHATable()">
|
||
<span class="lbl" id="ha-count" style="color:var(--cyan)"></span>
|
||
</div>
|
||
<div class="tbl-wrap" id="ha-tbl"><div class="loading">SCANNING...</div></div>
|
||
</div>
|
||
|
||
<!-- NEWS -->
|
||
<div class="tab" id="tab-news">
|
||
<div class="page-title">NEWS MANAGEMENT
|
||
<div class="actions">
|
||
<button class="btn btn-sm btn-green" onclick="newsCustomModal()">+ ADD CUSTOM</button>
|
||
<button class="btn btn-sm" onclick="loadNews()">REFRESH</button>
|
||
</div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
|
||
<div>
|
||
<div style="color:var(--cyan);font-size:0.65rem;letter-spacing:2px;margin-bottom:10px;border-bottom:1px solid var(--border);padding-bottom:6px">PINNED / CUSTOM NEWS</div>
|
||
<div id="news-custom"><div class="loading">SCANNING...</div></div>
|
||
</div>
|
||
<div>
|
||
<div style="color:var(--dim);font-size:0.65rem;letter-spacing:2px;margin-bottom:10px;border-bottom:1px solid var(--border);padding-bottom:6px">LIVE FEED (auto-refreshed)</div>
|
||
<div id="news-live"><div class="loading">SCANNING...</div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- PROXMOX VMs -->
|
||
<div class="tab" id="tab-vms">
|
||
<div class="page-title">PROXMOX VMs <span id="vms-count" style="color:var(--cyan);font-size:0.6rem;letter-spacing:2px"></span>
|
||
<div class="actions"><button class="btn btn-sm" onclick="loadVMs()">REFRESH</button></div>
|
||
</div>
|
||
<div class="tbl-wrap" id="vms-tbl"><div class="loading">SCANNING...</div></div>
|
||
</div>
|
||
|
||
<!-- BACKUPS -->
|
||
<div class="tab" id="tab-backups">
|
||
<div class="page-title">BACKUPS
|
||
<div class="actions">
|
||
<button class="btn btn-sm btn-green" id="backupRunBtn" onclick="triggerBackup()">▶ RUN BACKUP NOW</button>
|
||
<button class="btn btn-sm" onclick="loadBackups()">REFRESH</button>
|
||
</div>
|
||
</div>
|
||
<div id="backup-status-bar" style="display:none;background:var(--panel);border:1px solid var(--border2);padding:12px 16px;margin-bottom:16px;font-size:0.7rem">
|
||
<span style="color:var(--yellow);letter-spacing:1px" id="backup-status-msg">BACKUP RUNNING...</span>
|
||
<div style="margin-top:6px;height:3px;background:var(--border)"><div id="backup-progress-bar" style="height:100%;background:var(--yellow);width:0%;transition:width 1s"></div></div>
|
||
<div style="color:var(--dim);font-size:0.65rem;margin-top:6px" id="backup-log-tail"></div>
|
||
</div>
|
||
<div style="color:var(--dim);font-size:0.65rem;margin-bottom:16px">
|
||
Daily automatic backup runs at 2:00 AM. Files + all databases. Last 7 days retained. Stored on server — download anytime.
|
||
</div>
|
||
<div id="backups-list"><div class="loading">SCANNING...</div></div>
|
||
</div>
|
||
|
||
<!-- 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 & 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 & 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
function ts(s){ if(!s) return '—'; const d=new Date(s); return d.toLocaleString('en-US',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}); }
|
||
function ago(s){ if(!s) return '—'; const sec=Math.floor((Date.now()-new Date(s))/1000); if(sec<60) return sec+'s ago'; if(sec<3600) return Math.floor(sec/60)+'m ago'; return Math.floor(sec/3600)+'h ago'; }
|
||
function fmtUp(s){ const d=Math.floor(s/86400),h=Math.floor((s%86400)/3600),m=Math.floor((s%3600)/60); return (d>0?d+'d ':'')+h+'h '+m+'m'; }
|
||
|
||
async function api(action, params={}) {
|
||
const url = new URL(location.href);
|
||
url.searchParams.set('action', action);
|
||
Object.entries(params).forEach(([k,v]) => url.searchParams.set(k,v));
|
||
const r = await fetch(url.toString());
|
||
return r.json();
|
||
}
|
||
|
||
async function apiPost(action, data={}, cb=null) {
|
||
const fd = new FormData();
|
||
fd.append('action', action);
|
||
Object.entries(data).forEach(([k,v]) => fd.append(k,v));
|
||
try {
|
||
const r = await fetch(location.href, {method:'POST', body:fd});
|
||
const d = await r.json();
|
||
if (d.error) { toast(d.error,'err'); return; }
|
||
if (cb) cb(d);
|
||
} catch(e) { toast('Request failed','err'); }
|
||
}
|
||
|
||
function toast(msg, type='ok') {
|
||
const el = document.createElement('div');
|
||
el.className = 'toast-msg '+(type==='err'?'err':'ok');
|
||
el.textContent = msg;
|
||
document.getElementById('toast').appendChild(el);
|
||
setTimeout(() => el.remove(), 3000);
|
||
}
|
||
|
||
function sevBadge(s) {
|
||
const m = {critical:'badge-red',warning:'badge-yellow',info:'badge-cyan'};
|
||
return `<span class="badge ${m[s]||'badge-dim'}">${esc(s).toUpperCase()}</span>`;
|
||
}
|
||
function statusBadge(s) {
|
||
if (s==='online') return `<span class="badge badge-green">ONLINE</span>`;
|
||
if (s==='offline') return `<span class="badge badge-red">OFFLINE</span>`;
|
||
return `<span class="badge badge-dim">${esc(s).toUpperCase()}</span>`;
|
||
}
|
||
|
||
// ── AUTH ──────────────────────────────────────────────────────────────────────
|
||
async function doLogin() {
|
||
const u=document.getElementById('lu').value.trim(), p=document.getElementById('lp').value;
|
||
const fd=new FormData(); fd.append('action','login'); fd.append('username',u); fd.append('password',p);
|
||
const r = await fetch(location.href,{method:'POST',body:fd});
|
||
const d = await r.json();
|
||
if (d.error) { document.getElementById('loginErr').textContent=d.error; return; }
|
||
document.getElementById('loginWrap').style.display='none';
|
||
document.getElementById('app').style.display='flex';
|
||
document.getElementById('adminUser').textContent = (d.name||u).toUpperCase();
|
||
initApp();
|
||
}
|
||
|
||
async function doLogout() {
|
||
await apiPost('logout');
|
||
location.reload();
|
||
}
|
||
|
||
document.getElementById('lp').addEventListener('keydown', e => { if(e.key==='Enter') doLogin(); });
|
||
document.getElementById('lu').addEventListener('keydown', e => { if(e.key==='Enter') document.getElementById('lp').focus(); });
|
||
|
||
// ── NAV ───────────────────────────────────────────────────────────────────────
|
||
function nav(el) {
|
||
document.querySelectorAll('.nav-item').forEach(n=>n.classList.remove('active'));
|
||
el.classList.add('active');
|
||
const tab = el.dataset.tab;
|
||
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
|
||
document.getElementById('tab-'+tab).classList.add('active');
|
||
loadTab(tab);
|
||
}
|
||
|
||
function loadTab(tab) {
|
||
// Stop any existing network auto-refresh when leaving
|
||
if (_netAutoRefresh) { clearInterval(_netAutoRefresh); _netAutoRefresh = null; }
|
||
({
|
||
backups: loadBackups,
|
||
dashboard: loadDashboard,
|
||
agents: loadAgents,
|
||
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')}">◆ 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||'—'}</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')}">▶ RUN</button>`:'<span class="ts">—</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 · ${(d.arc_counts||{}).done||0} jobs done (24h) · ${(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')}">↻ 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> (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 · 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,'"');
|
||
return `<tr><td ${td}>${esc(t.title)}</td><td>${t.category}</td><td>${pri}</td><td>${due}</td>
|
||
<td style="font-size:0.6rem;color:var(--text-dim)">${t.status.replace('_',' ').toUpperCase()}</td>
|
||
<td style="white-space:nowrap">${doneBtnHtml}<button class="btn btn-xs" onclick='taskModal(${tJson})'>EDIT</button> <button class="btn btn-xs btn-red" onclick="taskDel(${t.id})">DEL</button></td></tr>`;
|
||
}).join('');
|
||
el.innerHTML = `<table><thead><tr><th>TITLE</th><th>CAT</th><th>PRI</th><th>DUE</th><th>STATUS</th><th>ACTIONS</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||
}
|
||
|
||
function taskModal(t={}) {
|
||
const id = t.id||0;
|
||
document.body.insertAdjacentHTML('beforeend',`<div class="modal-overlay" onclick="this.remove()">
|
||
<div class="modal" onclick="event.stopPropagation()">
|
||
<div class="modal-title">${id?'EDIT':'NEW'} TASK</div>
|
||
<label>TITLE *<input id="tm-title" value="${(t.title||'').replace(/"/g,'"')}" style="width:100%;margin-top:4px"></label>
|
||
<label style="margin-top:8px;display:block">NOTES<textarea id="tm-notes" rows="2" style="width:100%;margin-top:4px;background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:6px;resize:vertical">${esc(t.notes||'')}</textarea></label>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px">
|
||
<label>CATEGORY<select id="tm-cat" style="width:100%;margin-top:4px">
|
||
<option value="personal"${t.category==='personal'?' selected':''}>Personal</option>
|
||
<option value="work"${t.category==='work'?' selected':''}>Work</option>
|
||
<option value="todo"${t.category==='todo'?' selected':''}>Todo</option>
|
||
</select></label>
|
||
<label>PRIORITY<select id="tm-pri" style="width:100%;margin-top:4px">
|
||
<option value="low"${t.priority==='low'?' selected':''}>Low</option>
|
||
<option value="normal"${!t.priority||t.priority==='normal'?' selected':''}>Normal</option>
|
||
<option value="high"${t.priority==='high'?' selected':''}>High</option>
|
||
<option value="urgent"${t.priority==='urgent'?' selected':''}>Urgent</option>
|
||
</select></label>
|
||
<label>DUE DATE<input type="date" id="tm-due" value="${t.due_date||''}" style="width:100%;margin-top:4px"></label>
|
||
<label>STATUS<select id="tm-stat" style="width:100%;margin-top:4px">
|
||
<option value="pending"${!t.status||t.status==='pending'?' selected':''}>Pending</option>
|
||
<option value="in_progress"${t.status==='in_progress'?' selected':''}>In Progress</option>
|
||
<option value="done"${t.status==='done'?' selected':''}>Done</option>
|
||
<option value="cancelled"${t.status==='cancelled'?' selected':''}>Cancelled</option>
|
||
</select></label>
|
||
</div>
|
||
<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">
|
||
<button class="btn" onclick="this.closest('.modal-overlay').remove()">CANCEL</button>
|
||
<button class="btn btn-green" onclick="taskSave(${id})">SAVE</button>
|
||
</div>
|
||
</div></div>`);
|
||
}
|
||
|
||
function taskSave(id) {
|
||
apiPost('task_save',{id,title:document.getElementById('tm-title').value,notes:document.getElementById('tm-notes').value,
|
||
category:document.getElementById('tm-cat').value,priority:document.getElementById('tm-pri').value,
|
||
status:document.getElementById('tm-stat').value,due_date:document.getElementById('tm-due').value},
|
||
()=>{ document.querySelector('.modal-overlay')?.remove(); toast('Saved','ok'); loadTasks(); });
|
||
}
|
||
function taskDone(id){ apiPost('task_done',{id},()=>{toast('Marked done','ok');loadTasks();}); }
|
||
function taskDel(id){ if(!confirm('Delete task?'))return; apiPost('task_delete',{id},()=>{toast('Deleted','ok');loadTasks();}); }
|
||
|
||
async function loadAppts() {
|
||
const from = new Date().toISOString().slice(0,10);
|
||
const to = new Date(Date.now()+90*86400000).toISOString().slice(0,10);
|
||
const d = await api('appt_list', {from, to});
|
||
const appts = d.appointments || [];
|
||
const el = document.getElementById('appts-tbl');
|
||
if (!appts.length) { el.innerHTML='<div class="loading">No upcoming appointments.</div>'; return; }
|
||
const rows = appts.map(a => {
|
||
const start = new Date(a.start_at).toLocaleString('en-US',{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
|
||
const aJson = JSON.stringify(a).replace(/"/g,'"');
|
||
return `<tr><td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.title)}</td>
|
||
<td>${a.category}</td><td>${start}</td>
|
||
<td style="max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-dim)">${esc(a.location||'—')}</td>
|
||
<td style="white-space:nowrap"><button class="btn btn-xs" onclick='apptModal(${aJson})'>EDIT</button> <button class="btn btn-xs btn-red" onclick="apptDel(${a.id})">DEL</button></td></tr>`;
|
||
}).join('');
|
||
el.innerHTML = `<table><thead><tr><th>TITLE</th><th>CAT</th><th>START</th><th>LOCATION</th><th>ACTIONS</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||
}
|
||
|
||
function apptModal(a={}) {
|
||
const id=a.id||0;
|
||
const sv=a.start_at?a.start_at.slice(0,16):''; const ev=a.end_at?a.end_at.slice(0,16):'';
|
||
document.body.insertAdjacentHTML('beforeend',`<div class="modal-overlay" onclick="this.remove()">
|
||
<div class="modal" onclick="event.stopPropagation()">
|
||
<div class="modal-title">${id?'EDIT':'NEW'} APPOINTMENT</div>
|
||
<label>TITLE *<input id="am-title" value="${(a.title||'').replace(/"/g,'"')}" style="width:100%;margin-top:4px"></label>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px">
|
||
<label>START *<input type="datetime-local" id="am-start" value="${sv}" style="width:100%;margin-top:4px"></label>
|
||
<label>END<input type="datetime-local" id="am-end" value="${ev}" style="width:100%;margin-top:4px"></label>
|
||
<label>CATEGORY<select id="am-cat" style="width:100%;margin-top:4px">
|
||
<option value="personal"${!a.category||a.category==='personal'?' selected':''}>Personal</option>
|
||
<option value="work"${a.category==='work'?' selected':''}>Work</option>
|
||
<option value="medical"${a.category==='medical'?' selected':''}>Medical</option>
|
||
<option value="other"${a.category==='other'?' selected':''}>Other</option>
|
||
</select></label>
|
||
<label>LOCATION<input id="am-loc" value="${(a.location||'').replace(/"/g,'"')}" placeholder="optional" style="width:100%;margin-top:4px"></label>
|
||
</div>
|
||
<label style="margin-top:8px;display:block">NOTES<textarea id="am-desc" rows="2" style="width:100%;margin-top:4px;background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:6px;resize:vertical">${esc(a.description||'')}</textarea></label>
|
||
<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">
|
||
<button class="btn" onclick="this.closest('.modal-overlay').remove()">CANCEL</button>
|
||
<button class="btn btn-green" onclick="apptSave(${id})">SAVE</button>
|
||
</div>
|
||
</div></div>`);
|
||
}
|
||
|
||
function apptSave(id){ apiPost('appt_save',{id,title:document.getElementById('am-title').value,description:document.getElementById('am-desc').value,
|
||
category:document.getElementById('am-cat').value,location:document.getElementById('am-loc').value,
|
||
start_at:document.getElementById('am-start').value,end_at:document.getElementById('am-end').value},
|
||
()=>{ document.querySelector('.modal-overlay')?.remove(); toast('Saved','ok'); loadAppts(); }); }
|
||
function apptDel(id){ if(!confirm('Delete appointment?'))return; apiPost('appt_delete',{id},()=>{toast('Deleted','ok');loadAppts();}); }
|
||
|
||
|
||
// ── CALENDAR FEEDS ────────────────────────────────────────────────────────────
|
||
async function loadCalFeeds() {
|
||
const feeds = await api('cal_feeds_list');
|
||
const el = document.getElementById('cal-feeds-tbl');
|
||
if (!feeds || !feeds.length) {
|
||
el.innerHTML = '<div class="loading">No calendar feeds configured. iCloud syncs via config.php credentials.</div>';
|
||
return;
|
||
}
|
||
const srcLabel = {google:'Google',icloud:'iCloud',outlook:'Outlook',caldav:'CalDAV',ics:'ICS URL'};
|
||
el.innerHTML = `<table class="data-tbl"><thead><tr>
|
||
<th>NAME</th><th>SOURCE</th><th>ICS URL</th><th>LAST SYNC</th><th>COUNT</th><th>STATUS</th><th>ACTIONS</th>
|
||
</tr></thead><tbody>${feeds.map(f=>`<tr>
|
||
<td>${esc(f.name)}</td>
|
||
<td><span class="badge badge-dim">${srcLabel[f.source]||f.source}</span></td>
|
||
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.6rem">${esc(f.ics_url||'—')}</td>
|
||
<td>${ts(f.last_sync)}</td>
|
||
<td>${f.last_count||0}</td>
|
||
<td>${f.active?'<span class="badge badge-green">ACTIVE</span>':'<span class="badge badge-red">PAUSED</span>'}</td>
|
||
<td><button class="btn btn-xs" onclick='calFeedModal(${JSON.stringify(f)})'>EDIT</button>
|
||
<button class="btn btn-xs btn-red" onclick="calFeedDel(${f.id})">DEL</button></td>
|
||
</tr>`).join('')}</tbody></table>`;
|
||
}
|
||
|
||
function calFeedModal(f={}) {
|
||
const id = f.id||0;
|
||
openModal(id?'EDIT CALENDAR FEED':'ADD CALENDAR FEED', `
|
||
<div class="form-row"><label>NAME</label><input id="cf-name" class="inp" value="${esc(f.name||'')}"></div>
|
||
<div class="form-row"><label>SOURCE</label>
|
||
<select id="cf-source" class="inp">
|
||
<option value="google"${f.source==='google'?' selected':''}>Google Calendar (ICS)</option>
|
||
<option value="ics"${f.source==='ics'||!f.source?' selected':''}>ICS URL</option>
|
||
<option value="caldav"${f.source==='caldav'?' selected':''}>CalDAV</option>
|
||
<option value="outlook"${f.source==='outlook'?' selected':''}>Outlook (ICS)</option>
|
||
</select></div>
|
||
<div class="form-row"><label>ICS URL</label><input id="cf-ics" class="inp" value="${esc(f.ics_url||'')}" placeholder="https://..."></div>
|
||
<div class="form-row"><label>USERNAME (optional)</label><input id="cf-user" class="inp" value="${esc(f.username||'')}"></div>
|
||
<div class="form-row"><label>PASSWORD (optional)</label><input id="cf-pass" class="inp" type="password" placeholder="leave blank to keep"></div>
|
||
<div class="form-row"><label>ACTIVE</label>
|
||
<select id="cf-active" class="inp"><option value="1"${f.active!==0?' selected':''}>Yes</option><option value="0"${f.active===0?' selected':''}>No</option></select></div>
|
||
`, () => calFeedSave(id));
|
||
}
|
||
|
||
async function calFeedSave(id) {
|
||
await apiPost('cal_feed_save', {
|
||
id, name: document.getElementById('cf-name').value,
|
||
source: document.getElementById('cf-source').value,
|
||
ics_url: document.getElementById('cf-ics').value,
|
||
username: document.getElementById('cf-user').value,
|
||
password: document.getElementById('cf-pass').value,
|
||
active: document.getElementById('cf-active').value
|
||
}, () => { toast('Saved','ok'); closeModal(); loadCalFeeds(); });
|
||
}
|
||
|
||
function calFeedDel(id) {
|
||
if (!confirm('Delete this calendar feed?')) return;
|
||
apiPost('cal_feed_delete', {id}, () => { toast('Deleted','ok'); loadCalFeeds(); });
|
||
}
|
||
|
||
async function syncCalNow() {
|
||
const btn = document.getElementById('calSyncBtn');
|
||
const status = document.getElementById('cal-sync-status');
|
||
btn.disabled = true; btn.textContent = '⟳ SYNCING...';
|
||
status.textContent = 'Syncing...';
|
||
apiPost('cal_sync_now', {}, d => {
|
||
status.textContent = d.ok ? Object.entries(d.results||{}).map(([k,v])=>k+': '+v).join(' | ') || 'Sync complete' : 'Error';
|
||
toast('Calendar sync complete','ok');
|
||
loadCalFeeds();
|
||
btn.disabled = false; btn.textContent = '⟳ SYNC NOW';
|
||
});
|
||
}
|
||
|
||
// ── 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>
|