Files
jarvis/public_html/admin/index.php
T
myron 5d5eb2fdac feat: complete calendar integration + planner widget + 298 new KB intents
- Add calendar sync route to api.php (/api/calendar → calendar_sync.php)
- Add CALENDAR SYNC tab to admin with feed CRUD (add/edit/delete Google/ICS feeds)
- Add cal_sync_now action to admin for on-demand iCloud/Google sync
- Add cron: calendar_sync.php every 15 min (iCloud CalDAV + ICS feeds)
- Add PLANNER mini panel to index.html (left panel, shows today tasks + appointments)
- Update loadPlannerSummary() to render tasks/appts with priority dots and times
- Seed 298 new KB intents across 37 categories: science, history, tech, geography,
  math, health, food, space, philosophy, psychology, sports, music, film, travel,
  language, literature, finance, productivity, nature, facts, home automation,
  architecture, geopolitics, and more — 543 total intents
2026-05-31 22:47:35 +00:00

2054 lines
118 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
require_once __DIR__ . '/../../api/config.php';
require_once __DIR__ . '/../../api/lib/db.php';
session_name('jarvis_admin');
session_start();
// ── AUTH HELPERS ──────────────────────────────────────────────────────────────
function loggedIn(): bool { return !empty($_SESSION['admin_user']); }
function j(mixed $d): never { header('Content-Type: application/json'); echo json_encode($d); exit; }
function bad(string $msg, int $code = 400): never { http_response_code($code); j(['error' => $msg]); }
function self_upsert_device(array $d): void {
JarvisDB::execute(
'INSERT INTO network_devices (ip,mac,hostname,status,last_seen) VALUES (?,?,?,"online",NOW())
ON DUPLICATE KEY UPDATE mac=VALUES(mac), hostname=COALESCE(VALUES(hostname),hostname), status="online", last_seen=NOW()',
[$d['ip'], $d['mac'], $d['hostname'] ?? $d['vendor']]
);
if (!empty($d['vendor'])) {
JarvisDB::execute('UPDATE network_devices SET device_type=? WHERE ip=? AND (device_type IS NULL OR device_type="")', [$d['vendor'], $d['ip']]);
}
}
// ── BACKEND API ───────────────────────────────────────────────────────────────
$action = $_GET['action'] ?? $_POST['action'] ?? '';
if ($action) {
// Login doesn't require session
if ($action === 'login') {
$u = trim($_POST['username'] ?? '');
$p = $_POST['password'] ?? '';
$row = JarvisDB::single('SELECT * FROM users WHERE username = ?', [$u]);
if ($row && password_verify($p, $row['password_hash'])) {
$_SESSION['admin_user'] = $row['username'];
$_SESSION['admin_name'] = $row['display_name'];
j(['ok' => true, 'name' => $row['display_name']]);
}
bad('Invalid credentials', 401);
}
if ($action === 'logout') { session_destroy(); j(['ok' => true]); }
if (!loggedIn()) bad('Not authenticated', 401);
switch ($action) {
// ── DASHBOARD ─────────────────────────────────────────────────────────
case 'dashboard':
$mi = [];
foreach (file('/proc/meminfo') as $l) {
[$k,$v] = explode(':', $l, 2) + [null,null];
if ($k) $mi[trim($k)] = (int)trim($v);
}
$mt = $mi['MemTotal'] ?? 0; $mf = $mi['MemAvailable'] ?? 0;
$up = (int)explode(' ', file_get_contents('/proc/uptime'))[0];
$la = explode(' ', file_get_contents('/proc/loadavg'));
$disk = trim(shell_exec("df / | tail -1 | awk '{print $5}'") ?? '');
j([
'sys' => [
'mem_pct' => $mt > 0 ? round(($mt-$mf)/$mt*100,1) : 0,
'mem_used_mb' => round(($mt-$mf)/1024),
'mem_total_mb' => round($mt/1024),
'uptime_s' => $up,
'load_1m' => (float)$la[0],
'disk_pct' => $disk,
],
'agents' => JarvisDB::single('SELECT COUNT(*) total, SUM(status="online") online FROM registered_agents'),
'alerts' => JarvisDB::single('SELECT COUNT(*) total, SUM(resolved=0) active FROM alerts'),
'devices' => JarvisDB::single('SELECT COUNT(*) total, SUM(status="online") online FROM network_devices WHERE alias IS NOT NULL'),
'facts' => JarvisDB::single('SELECT COUNT(*) total FROM kb_facts'),
'intents' => JarvisDB::single('SELECT COUNT(*) total, SUM(active=1) active FROM kb_intents'),
]);
// ── AGENTS ───────────────────────────────────────────────────────────
case 'agents_list':
$agents = JarvisDB::query('SELECT agent_id, hostname, agent_type, ip_address, status, last_seen, created_at FROM registered_agents ORDER BY status="online" DESC, hostname');
$metrics = JarvisDB::query(
"SELECT agent_id,
ROUND(JSON_EXTRACT(metric_data,'$.cpu_percent'),1) AS cpu_pct,
ROUND(JSON_EXTRACT(metric_data,'$.memory.percent'),1) AS mem_pct,
ROUND(JSON_EXTRACT(metric_data,'$.disk[0].percent'),1) AS disk_pct
FROM agent_metrics
WHERE metric_type='system' AND recorded_at > DATE_SUB(NOW(), INTERVAL 5 MINUTE)
GROUP BY agent_id ORDER BY recorded_at DESC"
);
$mm = array_column($metrics, null, 'agent_id');
foreach ($agents as &$a) $a['metrics'] = $mm[$a['agent_id']] ?? null;
j($agents);
case 'agents_delete':
$id = $_POST['agent_id'] ?? ''; if (!$id) bad('Missing agent_id');
JarvisDB::execute('DELETE FROM registered_agents WHERE agent_id=?', [$id]);
JarvisDB::execute('DELETE FROM agent_metrics WHERE agent_id=?', [$id]);
JarvisDB::execute('DELETE FROM agent_commands WHERE agent_id=?', [$id]);
j(['ok' => true]);
// ── NETWORK ──────────────────────────────────────────────────────────
case 'network_list':
j(JarvisDB::query('SELECT id,ip,mac,hostname,alias,device_type,status,last_seen FROM network_devices ORDER BY status="online" DESC, COALESCE(alias,hostname,ip)'));
case 'network_scan':
// Queue shell command to PVE1 agent — it runs jarvis-netscan.sh and pushes results back
$pve1 = JarvisDB::single('SELECT agent_id FROM registered_agents WHERE ip_address="10.48.200.90" AND status="online" LIMIT 1');
if (!$pve1) {
$pve1 = JarvisDB::single('SELECT agent_id FROM registered_agents WHERE hostname LIKE "%pve%" AND status="online" LIMIT 1');
}
if ($pve1) {
JarvisDB::execute(
'INSERT INTO agent_commands (agent_id, command_type, command_data, status) VALUES (?,?,?,?)',
[$pve1['agent_id'], 'shell', json_encode(['command'=>'/usr/local/bin/jarvis-netscan.sh','allowed'=>true]), 'pending']
);
j(['ok' => true, 'queued' => true, 'note' => 'Scan command sent to PVE1 agent — results in ~40 seconds']);
} else {
j(['ok' => false, 'note' => 'PVE1 agent offline — scan will run automatically via cron in < 3 minutes']);
}
case 'network_save':
$id = (int)($_POST['id'] ?? 0);
$ip = trim($_POST['ip'] ?? ''); $alias = trim($_POST['alias'] ?? '');
$type = trim($_POST['device_type'] ?? 'device');
if (!$ip || !$alias) bad('IP and alias required');
if ($id) {
JarvisDB::execute('UPDATE network_devices SET ip=?,alias=?,device_type=? WHERE id=?', [$ip,$alias,$type,$id]);
} else {
JarvisDB::execute('INSERT INTO network_devices (ip,alias,device_type,status) VALUES (?,?,?,"unknown") ON DUPLICATE KEY UPDATE alias=?,device_type=?', [$ip,$alias,$type,$alias,$type]);
}
j(['ok' => true]);
case 'network_delete':
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
JarvisDB::execute('DELETE FROM network_devices WHERE id=?', [$id]);
j(['ok' => true]);
case 'network_ping':
$ip = trim($_POST['ip'] ?? ''); if (!$ip) bad('Missing IP');
$out = shell_exec('ping -c 2 -W 2 '.escapeshellarg($ip).' 2>/dev/null');
$alive = $out && (strpos($out,'2 received')!==false || strpos($out,'1 received')!==false);
$lat = null;
if ($alive && preg_match('/time=([\d.]+)/', $out, $m)) $lat = (float)$m[1];
j(['alive'=>$alive,'latency_ms'=>$lat]);
// ── ALERTS ───────────────────────────────────────────────────────────
case 'alerts_list':
$f = $_GET['filter'] ?? 'all';
$w = $f === 'active' ? 'WHERE resolved=0' : ($f === 'resolved' ? 'WHERE resolved=1' : '');
j(JarvisDB::query("SELECT id,alert_type,title,message,severity,resolved,created_at,resolved_at,source_key FROM alerts $w ORDER BY created_at DESC LIMIT 300"));
case 'alerts_resolve':
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
JarvisDB::execute('UPDATE alerts SET resolved=1,resolved_at=NOW() WHERE id=?', [$id]);
j(['ok' => true]);
case 'alerts_resolve_all':
JarvisDB::execute('UPDATE alerts SET resolved=1,resolved_at=NOW() WHERE resolved=0');
j(['ok' => true]);
case 'alerts_delete':
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
JarvisDB::execute('DELETE FROM alerts WHERE id=?', [$id]);
j(['ok' => true]);
case 'alerts_purge_resolved':
JarvisDB::execute('DELETE FROM alerts WHERE resolved=1');
j(['ok' => true]);
case 'alerts_save':
$id = (int)($_POST['id'] ?? 0);
$t = trim($_POST['title'] ?? ''); if (!$t) bad('Title required');
$typ = trim($_POST['alert_type'] ?? 'manual');
$msg = trim($_POST['message'] ?? '');
$sev = trim($_POST['severity'] ?? 'info');
if ($id) {
JarvisDB::execute('UPDATE alerts SET alert_type=?,title=?,message=?,severity=? WHERE id=?', [$typ,$t,$msg,$sev,$id]);
} else {
JarvisDB::execute('INSERT INTO alerts (alert_type,title,message,severity,resolved) VALUES (?,?,?,?,0)', [$typ,$t,$msg,$sev]);
}
j(['ok' => true]);
// ── KB FACTS ─────────────────────────────────────────────────────────
case 'facts_categories':
j(JarvisDB::query('SELECT category, COUNT(*) cnt FROM kb_facts GROUP BY category ORDER BY cnt DESC'));
case 'facts_list':
$cat = $_GET['category'] ?? '';
if ($cat === '__all__') {
j(JarvisDB::query('SELECT id,category,fact_key,fact_value,host,updated_at FROM kb_facts ORDER BY category,fact_key LIMIT 1000'));
}
j(JarvisDB::query('SELECT id,category,fact_key,fact_value,host,updated_at FROM kb_facts WHERE category=? ORDER BY fact_key', [$cat]));
case 'facts_save':
$id = (int)($_POST['id'] ?? 0);
$cat = trim($_POST['category'] ?? ''); $key = trim($_POST['fact_key'] ?? ''); $val = trim($_POST['fact_value'] ?? '');
if (!$cat||!$key) bad('Category and key required');
if ($id) {
JarvisDB::execute('UPDATE kb_facts SET category=?,fact_key=?,fact_value=? WHERE id=?', [$cat,$key,$val,$id]);
} else {
JarvisDB::execute('INSERT INTO kb_facts (category,fact_key,fact_value) VALUES (?,?,?)', [$cat,$key,$val]);
}
j(['ok' => true]);
case 'facts_delete':
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
JarvisDB::execute('DELETE FROM kb_facts WHERE id=?', [$id]);
j(['ok' => true]);
// ── KB INTENTS ───────────────────────────────────────────────────────
case 'intents_list':
j(JarvisDB::query('SELECT id,intent_name,pattern,response_template,action_type,priority,active FROM kb_intents ORDER BY priority DESC,intent_name'));
case 'intents_save':
$id = (int)($_POST['id'] ?? 0);
$name = trim($_POST['intent_name'] ?? ''); $pat = trim($_POST['pattern'] ?? '');
$resp = trim($_POST['response_template'] ?? '');
$typ = trim($_POST['action_type'] ?? 'response');
$pri = (int)($_POST['priority'] ?? 5); $act = (int)($_POST['active'] ?? 1);
if (!$name||!$pat) bad('Name and pattern required');
if ($id) {
JarvisDB::execute('UPDATE kb_intents SET intent_name=?,pattern=?,response_template=?,action_type=?,priority=?,active=? WHERE id=?', [$name,$pat,$resp,$typ,$pri,$act,$id]);
} else {
JarvisDB::execute('INSERT INTO kb_intents (intent_name,pattern,response_template,action_type,priority,active) VALUES (?,?,?,?,?,?)', [$name,$pat,$resp,$typ,$pri,$act]);
}
j(['ok' => true]);
case 'intents_delete':
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
JarvisDB::execute('DELETE FROM kb_intents WHERE id=?', [$id]);
j(['ok' => true]);
case 'intents_toggle':
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
JarvisDB::execute('UPDATE kb_intents SET active=NOT active WHERE id=?', [$id]);
j(['ok' => true]);
// ── SITES ────────────────────────────────────────────────────────────
case 'sites_list':
j(JarvisDB::query("SELECT fact_key,fact_value,updated_at FROM kb_facts WHERE category='sites' ORDER BY fact_key"));
// ── HOME ASSISTANT ENTITIES ───────────────────────────────────────────
case 'ha_list':
// Read from ha_entities table (real-time pushes from jarvis_agent custom component)
$domain = $_GET['domain'] ?? '';
$search = strtolower(trim($_GET['search'] ?? ''));
$skipDomains = ['sensor','binary_sensor','button','update','select','number',
'device_tracker','event','image','person','zone','tts','conversation',
'assist_satellite','input_button'];
$skipKeywords = ['pre_release','_record','_ftp_','_push_','_hub_ringtone',
'_siren_on','_email_on','_manual_record','_infrared_',
'do_not_disturb','matter_server','zerotier','mariadb',
'spotify_connect','file_editor','ssh_web','uptime_kuma',
'folding_home','music_assistant','get_hacs','mealie',
'mosquitto','social_to','esphome_device','motion_detection',
'front_yard_record','down_hill_record','camera1_record',
'back_yard_record','nvr_','assist_microphone','cec_scanner'];
$where = "state NOT IN ('unavailable','unknown')";
$params = [];
if ($domain) { $where .= " AND domain=?"; $params[] = $domain; }
$rows = JarvisDB::query(
"SELECT entity_id, entity_name name, domain, state, updated_at
FROM ha_entities WHERE $where ORDER BY domain, entity_name LIMIT 500",
$params
) ?? [];
$all = []; $domains = [];
foreach ($rows as $e) {
$dom = $e['domain'];
if (in_array($dom, $skipDomains)) continue;
$skip = false;
if ($dom === 'switch') {
foreach ($skipKeywords as $kw) {
if (strpos($e['entity_id'], $kw) !== false) { $skip = true; break; }
}
}
if ($skip) continue;
if ($search && strpos(strtolower($e['name']??''), $search) === false) continue;
$all[] = $e;
$domains[$dom] = true;
}
j(['entities'=>$all,'domains'=>array_keys($domains),'total'=>count($all),'ts'=>time()]);
case 'ha_toggle':
$eid = trim($_POST['entity_id'] ?? ''); if (!$eid) bad('Missing entity_id');
$state = trim($_POST['state'] ?? '');
if (!defined('HA_URL')||!defined('HA_TOKEN')) bad('HA not configured');
$domain = explode('.',$eid)[0];
$svc = match($domain) {
'light','switch','input_boolean','fan' => ($state==='on'?'turn_off':'turn_on'),
default => ($state==='on'?'turn_off':'turn_on')
};
$ch = curl_init(HA_URL.'/api/services/'.$domain.'/'.$svc);
curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_POST=>true,
CURLOPT_HTTPHEADER=>['Authorization: Bearer '.HA_TOKEN,'Content-Type: application/json'],
CURLOPT_POSTFIELDS=>json_encode(['entity_id'=>$eid]),CURLOPT_TIMEOUT=>8]);
$res = curl_exec($ch); $code = curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch);
j(['ok'=>$code<300,'code'=>$code]);
// ── NEWS ─────────────────────────────────────────────────────────────
case 'news_list':
$cached = JarvisDB::single("SELECT data,UNIX_TIMESTAMP(updated_at) ts FROM api_cache WHERE cache_key='news'");
$news = $cached ? (json_decode($cached['data'],true)??[]) : [];
$custom = JarvisDB::query("SELECT id,fact_key title,fact_value url,updated_at FROM kb_facts WHERE category='custom_news' ORDER BY id DESC");
j(['news'=>$news,'custom'=>$custom,'cache_age'=>$cached?time()-(int)$cached['ts']:null]);
case 'news_custom_save':
$id = (int)($_POST['id']??0);
$t = trim($_POST['title']??''); if(!$t) bad('Title required');
$url = trim($_POST['url']??'');
if($id) {
JarvisDB::execute('UPDATE kb_facts SET fact_key=?,fact_value=? WHERE id=? AND category="custom_news"',[$t,$url,$id]);
} else {
JarvisDB::execute('INSERT INTO kb_facts (category,fact_key,fact_value) VALUES ("custom_news",?,?)',[$t,$url]);
}
j(['ok'=>true]);
case 'news_custom_delete':
$id=(int)($_POST['id']??0); if(!$id) bad('Missing id');
JarvisDB::execute('DELETE FROM kb_facts WHERE id=? AND category="custom_news"',[$id]);
j(['ok'=>true]);
// ── PROXMOX VMs ───────────────────────────────────────────────────────
case 'vms_list':
$raw = JarvisDB::single("SELECT data,UNIX_TIMESTAMP(updated_at) ts FROM api_cache WHERE cache_key='proxmox'");
if (!$raw) j(['vms'=>[],'containers'=>[],'node_info'=>[],'ts'=>null]);
$pve = json_decode($raw['data'],true) ?? [];
j(['vms'=>$pve['vms']??[],'containers'=>$pve['containers']??[],'node_info'=>$pve['node_info']??[],'node_status'=>$pve['node_status']??null,'ts'=>$raw['ts']]);
// ── USERS ────────────────────────────────────────────────────────────
case 'email_inbox':
// Call via server's own IP — REMOTE_ADDR matches JARVIS_IP so auth bypass applies
$acct = $_GET['account'] ?? 'all';
$force = !empty($_GET['force']) ? '&force=1' : '';
$ch = curl_init('https://165.22.1.228/api/email?account=' . $acct . $force);
curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>25,
CURLOPT_SSL_VERIFYPEER=>false,CURLOPT_SSL_VERIFYHOST=>false,
CURLOPT_HTTPHEADER=>['Host: jarvis.orbishosting.com']]);
$r = curl_exec($ch); $code = curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch);
if($code===200 && $r) j(json_decode($r,true));
else j(['error'=>'Email fetch failed (HTTP '.$code.')']);
case 'email_action_items':
$rows = JarvisDB::query("SELECT * FROM email_actions WHERE dismissed=0 AND task_id IS NULL AND appointment_id IS NULL ORDER BY received_at DESC LIMIT 100") ?? [];
j(['action_items'=>$rows]);
case 'email_create_task':
$id=(int)($_POST['id']??0); if(!$id) bad('No id');
$ea=JarvisDB::single('SELECT * FROM email_actions WHERE id=?',[$id]); if(!$ea) bad('Not found');
$title=trim($_POST['title']??$ea['suggested_title']);
$due=trim($_POST['due_date']??$ea['suggested_date']??'');
$notes="From: {$ea['from_name']} <{$ea['from_email']}>\nSubject: {$ea['subject']}";
$tid=JarvisDB::insert('INSERT INTO tasks(title,notes,category,priority,due_date)VALUES(?,?,?,?,?)',
[$title,$notes,'work','normal',$due?:null]);
JarvisDB::execute('UPDATE email_actions SET task_id=?,dismissed=1 WHERE id=?',[$tid,$id]);
j(['ok'=>true,'task_id'=>$tid]);
case 'email_create_appt':
$id=(int)($_POST['id']??0); if(!$id) bad('No id');
$ea=JarvisDB::single('SELECT * FROM email_actions WHERE id=?',[$id]); if(!$ea) bad('Not found');
$title=trim($_POST['title']??$ea['suggested_title']);
$start=trim($_POST['start_at']??'');
if(!$start) $start=($ea['suggested_date']??date('Y-m-d')).' 09:00:00';
$aid=JarvisDB::insert('INSERT INTO appointments(title,description,category,start_at)VALUES(?,?,?,?)',
[$title,"From: {$ea['from_name']} <{$ea['from_email']}>",'work',$start]);
JarvisDB::execute('UPDATE email_actions SET appointment_id=?,dismissed=1 WHERE id=?',[$aid,$id]);
j(['ok'=>true,'appointment_id'=>$aid]);
case 'email_dismiss':
$id=(int)($_POST['id']??0);
if($id) JarvisDB::execute('UPDATE email_actions SET dismissed=1 WHERE id=?',[$id]);
j(['ok'=>true]);
case 'task_list':
$status = trim($_GET['status'] ?? '');
$category = trim($_GET['category'] ?? '');
$where = '1=1'; $params = [];
if ($status) { $where .= ' AND status=?'; $params[] = $status; }
if ($category) { $where .= ' AND category=?'; $params[] = $category; }
else if (!$status) { $where .= " AND status NOT IN ('done','cancelled')"; }
$rows = JarvisDB::query("SELECT * FROM tasks WHERE {$where} ORDER BY FIELD(priority,'urgent','high','normal','low'),due_date ASC,created_at DESC LIMIT 200",$params) ?? [];
j(['tasks'=>$rows]);
case 'task_save':
$id=$_POST['id']??0; $title=trim($_POST['title']??'');
$notes=trim($_POST['notes']??''); $cat=$_POST['category']??'personal';
$pri=$_POST['priority']??'normal'; $stat=$_POST['status']??'pending';
$due=!empty($_POST['due_date'])?$_POST['due_date']:null;
$dtime=!empty($_POST['due_time'])?$_POST['due_time']:null;
if(!$title) bad('Title required');
if($id){
JarvisDB::execute('UPDATE tasks SET title=?,notes=?,category=?,priority=?,status=?,due_date=?,due_time=?,updated_at=NOW() WHERE id=?',[$title,$notes,$cat,$pri,$stat,$due,$dtime,$id]);
j(['ok'=>true,'id'=>(int)$id]);
} else {
$newId=JarvisDB::insert('INSERT INTO tasks(title,notes,category,priority,status,due_date,due_time)VALUES(?,?,?,?,?,?,?)',[$title,$notes,$cat,$pri,$stat,$due,$dtime]);
j(['ok'=>true,'id'=>$newId]);
}
case 'task_done':
$id=(int)($_POST['id']??0); if(!$id) bad('No id');
JarvisDB::execute("UPDATE tasks SET status='done',completed_at=NOW() WHERE id=?",[$id]);
j(['ok'=>true]);
case 'task_delete':
$id=(int)($_POST['id']??0); if(!$id) bad('No id');
JarvisDB::execute('DELETE FROM tasks WHERE id=?',[$id]);
j(['ok'=>true]);
case 'appt_list':
$from=$_GET['from']??date('Y-m-d'); $to=$_GET['to']??date('Y-m-d',strtotime('+90 days'));
$rows=JarvisDB::query("SELECT * FROM appointments WHERE DATE(start_at) BETWEEN ? AND ? ORDER BY start_at ASC LIMIT 200",[$from,$to]) ?? [];
j(['appointments'=>$rows]);
case 'appt_save':
$id=$_POST['id']??0; $title=trim($_POST['title']??''); $desc=trim($_POST['description']??'');
$cat=$_POST['category']??'personal'; $loc=trim($_POST['location']??'');
$all_day=(int)($_POST['all_day']??0); $rem=(int)($_POST['reminder_min']??30);
$start=trim($_POST['start_at']??''); $end=trim($_POST['end_at']??'');
if(!$title||!$start) bad('Title and start required');
$ts=strtotime($start); if(!$ts) bad('Invalid start datetime');
$startDt=date('Y-m-d H:i:s',$ts);
$endDt=($end&&strtotime($end))?date('Y-m-d H:i:s',strtotime($end)):null;
if($id){
JarvisDB::execute('UPDATE appointments SET title=?,description=?,category=?,start_at=?,end_at=?,location=?,all_day=?,reminder_min=?,alerted=0,updated_at=NOW() WHERE id=?',[$title,$desc,$cat,$startDt,$endDt,$loc,$all_day,$rem,$id]);
j(['ok'=>true,'id'=>(int)$id]);
} else {
$newId=JarvisDB::insert('INSERT INTO appointments(title,description,category,start_at,end_at,location,all_day,reminder_min)VALUES(?,?,?,?,?,?,?,?)',[$title,$desc,$cat,$startDt,$endDt,$loc,$all_day,$rem]);
j(['ok'=>true,'id'=>$newId]);
}
case 'appt_delete':
$id=(int)($_POST['id']??0); if(!$id) bad('No id');
JarvisDB::execute('DELETE FROM appointments WHERE id=?',[$id]);
j(['ok'=>true]);
case 'cal_feeds_list':
j(JarvisDB::query("SELECT * FROM calendar_feeds ORDER BY source,name") ?? []);
case 'cal_feed_save':
$id = (int)($_POST['id'] ?? 0);
$name = trim($_POST['name'] ?? '');
$source = $_POST['source'] ?? 'ics';
$ics = trim($_POST['ics_url'] ?? '');
$user = trim($_POST['username'] ?? '');
$pass = trim($_POST['password'] ?? '');
$active = (int)($_POST['active'] ?? 1);
if (!$name) bad('Name required');
if ($id) {
JarvisDB::execute("UPDATE calendar_feeds SET name=?,source=?,ics_url=?,username=?,password=?,active=? WHERE id=?",
[$name,$source,$ics,$user,$pass,$active,$id]);
j(['ok'=>true,'id'=>$id]);
} else {
$nid = JarvisDB::insert("INSERT INTO calendar_feeds(name,source,ics_url,username,password,active) VALUES(?,?,?,?,?,?)",
[$name,$source,$ics,$user,$pass,$active]);
j(['ok'=>true,'id'=>$nid]);
}
case 'cal_feed_delete':
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('No id');
JarvisDB::execute("DELETE FROM calendar_feeds WHERE id=?", [$id]);
j(['ok'=>true]);
case 'cal_sync_now':
if (!class_exists('JarvisDB')) require_once __DIR__ . '/../../api/lib/db.php';
require_once __DIR__ . '/../../api/endpoints/calendar_sync.php';
$r = runSync();
j(['ok'=>true,'results'=>$r]);
case 'users_list':
j(JarvisDB::query('SELECT id,username,display_name,last_seen,created_at FROM users ORDER BY username'));
case 'users_save':
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
$dn = trim($_POST['display_name'] ?? '');
$pw = trim($_POST['password'] ?? '');
if ($pw) {
JarvisDB::execute('UPDATE users SET display_name=?,password_hash=? WHERE id=?', [$dn, password_hash($pw, PASSWORD_BCRYPT), $id]);
} else {
JarvisDB::execute('UPDATE users SET display_name=? WHERE id=?', [$dn, $id]);
}
j(['ok' => true]);
// ── BACKUPS ───────────────────────────────────────────────────────────
case 'backups_list':
$dir = '/var/backups/jarvis';
$lock = "$dir/backup.lock";
$log = "$dir/backup.log";
$running = file_exists($lock) && (time() - filemtime($lock)) < 3600;
$files = [];
foreach (glob("$dir/jarvis_backup_*.tar.gz") ?: [] as $f) {
$files[] = [
'file' => basename($f),
'size' => filesize($f),
'size_mb' => round(filesize($f)/1048576, 1),
'date' => date('Y-m-d H:i:s', filemtime($f)),
];
}
usort($files, fn($a,$b) => strcmp($b['date'], $a['date']));
$lastLog = $log && file_exists($log) ? trim(shell_exec("tail -3 " . escapeshellarg($log))) : '';
j(['running' => $running, 'files' => $files, 'last_log' => $lastLog]);
case 'backup_trigger':
$lock = '/var/backups/jarvis/backup.lock';
if (file_exists($lock) && (time() - filemtime($lock)) < 3600) {
j(['ok' => false, 'message' => 'Backup already running']);
}
shell_exec('nohup /usr/local/bin/jarvis-backup.sh > /dev/null 2>&1 &');
sleep(1);
j(['ok' => true, 'message' => 'Backup started']);
case 'backup_download':
$file = basename($_GET['file'] ?? '');
if (!preg_match('/^jarvis_backup_[\d_-]+\.tar\.gz$/', $file)) bad('Invalid filename');
$path = '/var/backups/jarvis/' . $file;
if (!file_exists($path)) bad('File not found', 404);
header('Content-Type: application/gzip');
header('Content-Disposition: attachment; filename="' . $file . '"');
header('Content-Length: ' . filesize($path));
header('X-Accel-Buffering: no');
ob_end_clean();
readfile($path);
exit;
default: bad('Unknown action');
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>JARVIS ADMIN</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#080b0f;--surface:#0d1117;--panel:#111820;--border:#1a2535;--border2:#243040;
--cyan:#00d4ff;--green:#39ff14;--red:#ff3333;--yellow:#ffcc00;--orange:#ff8800;
--text:#c8d8e8;--dim:#4a6080;--font:'Share Tech Mono',monospace;
}
body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:13px;min-height:100vh;display:flex;flex-direction:column}
a{color:var(--cyan);text-decoration:none}
/* ── LOGIN ── */
#loginWrap{display:flex;align-items:center;justify-content:center;min-height:100vh;flex-direction:column;gap:24px}
#loginBox{background:var(--surface);border:1px solid var(--border2);padding:36px 40px;width:360px}
#loginBox h1{color:var(--cyan);font-size:1.4rem;letter-spacing:4px;margin-bottom:6px;text-align:center}
#loginBox p{color:var(--dim);font-size:0.7rem;letter-spacing:2px;text-align:center;margin-bottom:28px}
.field{margin-bottom:16px}
.field label{display:block;color:var(--dim);font-size:0.65rem;letter-spacing:2px;margin-bottom:6px}
.field input{width:100%;background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:10px 12px;font-family:var(--font);font-size:13px;outline:none}
.field input:focus{border-color:var(--cyan)}
#loginErr{color:var(--red);font-size:0.7rem;text-align:center;min-height:16px;margin-bottom:8px}
.btn{background:transparent;border:1px solid var(--cyan);color:var(--cyan);padding:10px 20px;font-family:var(--font);font-size:0.75rem;letter-spacing:2px;cursor:pointer;transition:all .15s}
.btn:hover{background:var(--cyan);color:#000}
.btn-red{border-color:var(--red);color:var(--red)} .btn-red:hover{background:var(--red);color:#fff}
.btn-green{border-color:var(--green);color:var(--green)} .btn-green:hover{background:var(--green);color:#000}
.btn-yellow{border-color:var(--yellow);color:var(--yellow)} .btn-yellow:hover{background:var(--yellow);color:#000}
.btn-sm{padding:4px 10px;font-size:0.65rem;letter-spacing:1px}
.btn-xs{padding:2px 7px;font-size:0.6rem;letter-spacing:1px}
.btn-full{width:100%;display:block;text-align:center}
/* ── LAYOUT ── */
#app{display:none;flex:1;flex-direction:column}
#topbar{background:var(--surface);border-bottom:1px solid var(--border2);padding:10px 20px;display:flex;align-items:center;justify-content:space-between}
#topbar .logo{color:var(--cyan);font-size:1rem;letter-spacing:5px}
#topbar .sub{color:var(--dim);font-size:0.6rem;letter-spacing:3px;margin-left:12px}
#topbar .right{display:flex;align-items:center;gap:16px}
#topbar .user{color:var(--dim);font-size:0.65rem;letter-spacing:1px}
#main{display:flex;flex:1;overflow:hidden}
/* ── SIDEBAR ── */
#sidebar{width:180px;background:var(--surface);border-right:1px solid var(--border2);padding:16px 0;flex-shrink:0}
.nav-item{display:block;padding:10px 20px;color:var(--dim);font-size:0.7rem;letter-spacing:2px;cursor:pointer;border-left:2px solid transparent;transition:all .15s}
.nav-item:hover{color:var(--text);background:var(--panel)}
.nav-item.active{color:var(--cyan);border-left-color:var(--cyan);background:var(--panel)}
.nav-section{padding:16px 20px 6px;color:var(--border2);font-size:0.55rem;letter-spacing:3px}
/* ── CONTENT ── */
#content{flex:1;overflow-y:auto;padding:24px}
.tab{display:none}.tab.active{display:block}
.page-title{color:var(--cyan);font-size:0.9rem;letter-spacing:4px;margin-bottom:20px;border-bottom:1px solid var(--border);padding-bottom:10px;display:flex;align-items:center;justify-content:space-between}
.page-title .actions{display:flex;gap:8px}
/* ── STAT CARDS ── */
.stat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;margin-bottom:24px}
.stat-card{background:var(--surface);border:1px solid var(--border);padding:16px}
.stat-card .lbl{color:var(--dim);font-size:0.6rem;letter-spacing:2px;margin-bottom:8px}
.stat-card .val{font-size:1.6rem;color:var(--cyan)}
.stat-card .sub{color:var(--dim);font-size:0.65rem;margin-top:4px}
.stat-card .ok{color:var(--green)}.stat-card .warn{color:var(--yellow)}.stat-card .danger{color:var(--red)}
/* ── TABLE ── */
.tbl-wrap{background:var(--surface);border:1px solid var(--border);overflow-x:auto}
table{width:100%;border-collapse:collapse}
th{color:var(--dim);font-size:0.6rem;letter-spacing:2px;padding:10px 12px;text-align:left;border-bottom:1px solid var(--border2);white-space:nowrap}
td{padding:9px 12px;border-bottom:1px solid var(--border);font-size:0.75rem;vertical-align:middle}
tr:last-child td{border-bottom:none}
tr:hover td{background:rgba(0,212,255,0.03)}
.badge{display:inline-block;padding:2px 8px;font-size:0.6rem;letter-spacing:1px}
.badge-green{background:rgba(57,255,20,0.1);color:var(--green);border:1px solid rgba(57,255,20,0.3)}
.badge-red{background:rgba(255,51,51,0.1);color:var(--red);border:1px solid rgba(255,51,51,0.3)}
.badge-yellow{background:rgba(255,204,0,0.1);color:var(--yellow);border:1px solid rgba(255,204,0,0.3)}
.badge-cyan{background:rgba(0,212,255,0.1);color:var(--cyan);border:1px solid rgba(0,212,255,0.3)}
.badge-dim{background:rgba(74,96,128,0.1);color:var(--dim);border:1px solid rgba(74,96,128,0.3)}
.actions-col{white-space:nowrap;display:flex;gap:4px;flex-wrap:wrap}
.dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:6px}
.dot-green{background:var(--green);box-shadow:0 0 6px var(--green)}
.dot-red{background:var(--red)}
.dot-dim{background:var(--dim)}
/* ── MODAL ── */
#modalBg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:1000;align-items:center;justify-content:center}
#modalBg.open{display:flex}
#modal{background:var(--surface);border:1px solid var(--border2);width:500px;max-width:95vw;max-height:90vh;display:flex;flex-direction:column}
#modalHead{padding:16px 20px;border-bottom:1px solid var(--border2);display:flex;justify-content:space-between;align-items:center}
#modalHead h3{color:var(--cyan);font-size:0.8rem;letter-spacing:3px}
#modalClose{background:none;border:none;color:var(--dim);cursor:pointer;font-size:1.2rem}
#modalClose:hover{color:var(--red)}
#modalBody{padding:20px;overflow-y:auto;flex:1}
#modalFoot{padding:12px 20px;border-top:1px solid var(--border2);display:flex;justify-content:flex-end;gap:8px}
.form-row{margin-bottom:14px}
.form-row label{display:block;color:var(--dim);font-size:0.6rem;letter-spacing:2px;margin-bottom:5px}
.form-row input,.form-row select,.form-row textarea{width:100%;background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:8px 10px;font-family:var(--font);font-size:12px;outline:none;resize:vertical}
.form-row input:focus,.form-row select:focus,.form-row textarea:focus{border-color:var(--cyan)}
.form-row textarea{min-height:80px}
/* ── FILTERS ── */
.filters{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap;align-items:center}
.filters .lbl{color:var(--dim);font-size:0.6rem;letter-spacing:2px;margin-right:4px}
.filter-btn{background:none;border:1px solid var(--border2);color:var(--dim);padding:4px 12px;font-family:var(--font);font-size:0.65rem;letter-spacing:1px;cursor:pointer}
.filter-btn.active,.filter-btn:hover{border-color:var(--cyan);color:var(--cyan)}
select.filter-sel{background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:4px 8px;font-family:var(--font);font-size:0.65rem;outline:none}
select.filter-sel:focus{border-color:var(--cyan)}
/* ── TOAST ── */
#toast{position:fixed;bottom:24px;right:24px;z-index:2000;display:flex;flex-direction:column;gap:8px}
.toast-msg{background:var(--panel);border:1px solid var(--border2);padding:10px 16px;font-size:0.7rem;letter-spacing:1px;animation:slideIn .2s ease;border-left:3px solid var(--cyan)}
.toast-msg.err{border-left-color:var(--red);color:var(--red)}
.toast-msg.ok{border-left-color:var(--green);color:var(--green)}
@keyframes slideIn{from{transform:translateX(40px);opacity:0}to{transform:translateX(0);opacity:1}}
/* ── MISC ── */
.empty{color:var(--dim);font-size:0.7rem;letter-spacing:1px;padding:30px;text-align:center}
@keyframes agentIn{from{opacity:0;transform:translateX(-12px)}to{opacity:1;transform:translateX(0)}}
.agent-row{animation:agentIn .18s ease forwards;opacity:0}
.loading{color:var(--dim);font-size:0.7rem;letter-spacing:2px;padding:30px;text-align:center;animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.meter{height:4px;background:var(--border);margin-top:4px;position:relative}
.meter-bar{height:100%;background:var(--cyan);transition:width .3s}
.meter-bar.warn{background:var(--yellow)}.meter-bar.danger{background:var(--red)}
.ts{color:var(--dim);font-size:0.65rem}
.monospace{font-family:var(--font)}
.trunc{max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
</style>
</head>
<body>
<!-- LOGIN -->
<div id="loginWrap">
<div id="loginBox">
<h1>JARVIS</h1>
<p>ADMIN PORTAL</p>
<div class="field"><label>USERNAME</label><input id="lu" type="text" autofocus></div>
<div class="field"><label>PASSWORD</label><input id="lp" type="password"></div>
<div id="loginErr"></div>
<button class="btn btn-full" onclick="doLogin()">AUTHENTICATE</button>
</div>
</div>
<!-- APP -->
<div id="app">
<div id="topbar">
<div style="display:flex;align-items:center">
<span class="logo">JARVIS</span>
<span class="sub">ADMIN PORTAL</span>
</div>
<div class="right">
<span class="user" id="adminUser"></span>
<button class="btn btn-sm btn-red" onclick="doLogout()">LOGOUT</button>
</div>
</div>
<div id="main">
<div id="sidebar">
<div class="nav-section">OVERVIEW</div>
<div class="nav-item active" data-tab="dashboard" onclick="nav(this)">DASHBOARD</div>
<div class="nav-item" data-tab="backups" onclick="nav(this)">💾 BACKUPS</div>
<div class="nav-section">MANAGE</div>
<div class="nav-item" data-tab="agents" onclick="nav(this)">AGENTS</div>
<div class="nav-item" data-tab="network" onclick="nav(this)">NETWORK</div>
<div class="nav-item" data-tab="alerts" onclick="nav(this)">ALERTS</div>
<div class="nav-section">KNOWLEDGE</div>
<div class="nav-item" data-tab="facts" onclick="nav(this)">KB FACTS</div>
<div class="nav-item" data-tab="intents" onclick="nav(this)">KB INTENTS</div>
<div class="nav-section">LIVE</div>
<div class="nav-item" data-tab="ha" onclick="nav(this)">HOME ASSISTANT</div>
<div class="nav-item" data-tab="news" onclick="nav(this)">NEWS</div>
<div class="nav-item" data-tab="vms" onclick="nav(this)">PROXMOX VMs</div>
<div class="nav-section">COMMUNICATIONS</div>
<div class="nav-item" data-tab="email" onclick="nav(this)">📧 EMAIL</div>
<div class="nav-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">INFO</div>
<div class="nav-item" data-tab="sites" onclick="nav(this)">SITES</div>
<div class="nav-item" data-tab="users" onclick="nav(this)">USERS</div>
</div>
<div id="content">
<!-- DASHBOARD -->
<div class="tab active" id="tab-dashboard">
<div class="page-title">DASHBOARD</div>
<div class="stat-grid" id="dash-cards"><div class="loading">SCANNING...</div></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px" id="dash-bottom"></div>
</div>
<!-- AGENTS -->
<div class="tab" id="tab-agents">
<div class="page-title"><span id="agents-title">AGENTS</span>
<div class="actions"><button class="btn btn-sm" onclick="loadAgents()">REFRESH</button></div>
</div>
<div class="tbl-wrap" id="agents-tbl"></div>
</div>
<!-- NETWORK -->
<div class="tab" id="tab-network">
<div class="page-title">NETWORK DEVICES
<div class="actions">
<button class="btn btn-sm btn-green" onclick="netModal()">+ ADD DEVICE</button>
<button class="btn btn-sm btn-yellow" id="scanBtn" onclick="scanNow()">SCAN NOW</button>
<button class="btn btn-sm" onclick="loadNetwork()">REFRESH</button>
</div>
</div>
<div class="filters" style="margin-bottom:12px">
<span class="lbl">FILTER:</span>
<button class="filter-btn active" id="nf-all" onclick="setNetFilter('all',this)">ALL</button>
<button class="filter-btn" id="nf-online" onclick="setNetFilter('online',this)">ONLINE</button>
<button class="filter-btn" id="nf-offline" onclick="setNetFilter('offline',this)">OFFLINE</button>
<button class="filter-btn" id="nf-named" onclick="setNetFilter('named',this)">NAMED</button>
&nbsp;<span class="lbl" id="net-count" style="color:var(--cyan)"></span>
</div>
<div class="tbl-wrap" id="network-tbl"><div class="loading">SCANNING...</div></div>
</div>
<!-- ALERTS -->
<div class="tab" id="tab-alerts">
<div class="page-title">ALERTS
<div class="actions">
<button class="btn btn-sm btn-green" onclick="alertModal()">+ CREATE</button>
<button class="btn btn-sm btn-yellow" onclick="apiPost('alerts_resolve_all',{},()=>{toast('All resolved','ok');loadAlerts()})">RESOLVE ALL</button>
<button class="btn btn-sm btn-red" onclick="apiPost('alerts_purge_resolved',{},()=>{toast('Purged','ok');loadAlerts()})">PURGE RESOLVED</button>
<button class="btn btn-sm" onclick="loadAlerts()">REFRESH</button>
</div>
</div>
<div class="filters">
<span class="lbl">FILTER:</span>
<button class="filter-btn active" onclick="setAlertFilter('active',this)">ACTIVE</button>
<button class="filter-btn" onclick="setAlertFilter('all',this)">ALL</button>
<button class="filter-btn" onclick="setAlertFilter('resolved',this)">RESOLVED</button>
</div>
<div class="tbl-wrap" id="alerts-tbl"><div class="loading">SCANNING...</div></div>
</div>
<!-- KB FACTS -->
<div class="tab" id="tab-facts">
<div class="page-title">KB FACTS
<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>
</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
<div class="actions">
<button class="btn btn-sm btn-green" onclick="intentModal()">+ ADD INTENT</button>
<button class="btn btn-sm" onclick="loadIntents()">REFRESH</button>
</div>
</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
<div class="actions"><button class="btn btn-sm" onclick="loadHA()">REFRESH</button></div>
</div>
<div class="filters">
<span class="lbl">DOMAIN:</span>
<select class="filter-sel" id="ha-domain" onchange="loadHA()"><option value="">ALL</option></select>
&nbsp;<button class="filter-btn active" id="ha-all-btn" onclick="setHAOnlyOn(false,this)">ALL</button>
<button class="filter-btn" id="ha-on-btn" onclick="setHAOnlyOn(true,this)">ON ONLY</button>
&nbsp;<input id="ha-search" placeholder="search by name..." style="background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:4px 8px;font-family:var(--font);font-size:0.65rem;width:200px;outline:none" oninput="filterHATable()" onchange="filterHATable()">
<span class="lbl" id="ha-count" style="color:var(--cyan)"></span>
</div>
<div class="tbl-wrap" id="ha-tbl"><div class="loading">SCANNING...</div></div>
</div>
<!-- NEWS -->
<div class="tab" id="tab-news">
<div class="page-title">NEWS MANAGEMENT
<div class="actions">
<button class="btn btn-sm btn-green" onclick="newsCustomModal()">+ ADD CUSTOM</button>
<button class="btn btn-sm" onclick="loadNews()">REFRESH</button>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div>
<div style="color:var(--cyan);font-size:0.65rem;letter-spacing:2px;margin-bottom:10px;border-bottom:1px solid var(--border);padding-bottom:6px">PINNED / CUSTOM NEWS</div>
<div id="news-custom"><div class="loading">SCANNING...</div></div>
</div>
<div>
<div style="color:var(--dim);font-size:0.65rem;letter-spacing:2px;margin-bottom:10px;border-bottom:1px solid var(--border);padding-bottom:6px">LIVE FEED (auto-refreshed)</div>
<div id="news-live"><div class="loading">SCANNING...</div></div>
</div>
</div>
</div>
<!-- PROXMOX VMs -->
<div class="tab" id="tab-vms">
<div class="page-title">PROXMOX VMs
<div class="actions"><button class="btn btn-sm" onclick="loadVMs()">REFRESH</button></div>
</div>
<div class="tbl-wrap" id="vms-tbl"><div class="loading">SCANNING...</div></div>
</div>
<!-- BACKUPS -->
<div class="tab" id="tab-backups">
<div class="page-title">BACKUPS
<div class="actions">
<button class="btn btn-sm btn-green" id="backupRunBtn" onclick="triggerBackup()">▶ RUN BACKUP NOW</button>
<button class="btn btn-sm" onclick="loadBackups()">REFRESH</button>
</div>
</div>
<div id="backup-status-bar" style="display:none;background:var(--panel);border:1px solid var(--border2);padding:12px 16px;margin-bottom:16px;font-size:0.7rem">
<span style="color:var(--yellow);letter-spacing:1px" id="backup-status-msg">BACKUP RUNNING...</span>
<div style="margin-top:6px;height:3px;background:var(--border)"><div id="backup-progress-bar" style="height:100%;background:var(--yellow);width:0%;transition:width 1s"></div></div>
<div style="color:var(--dim);font-size:0.65rem;margin-top:6px" id="backup-log-tail"></div>
</div>
<div style="color:var(--dim);font-size:0.65rem;margin-bottom:16px">
Daily automatic backup runs at 2:00 AM. Files + all databases. Last 7 days retained. Stored on server — download anytime.
</div>
<div id="backups-list"><div class="loading">SCANNING...</div></div>
</div>
<!-- SITES -->
<div class="tab" id="tab-sites">
<div class="page-title">SITE HEALTH</div>
<div id="sites-content"><div class="loading">SCANNING...</div></div>
</div>
<!-- EMAIL -->
<div class="tab" id="tab-email">
<div class="page-title">EMAIL INTELLIGENCE
<div class="actions">
<button class="btn btn-sm" id="email-tab-inbox" onclick="emailShowTab('inbox')" style="background:rgba(0,212,255,0.15)">📥 INBOX</button>
<button class="btn btn-sm" id="email-tab-actions" onclick="emailShowTab('actions')">⚡ ACTION ITEMS <span id="email-ai-badge" style="background:var(--orange);color:#000;border-radius:10px;padding:0 5px;font-size:0.6rem;margin-left:4px"></span></button>
<select id="email-acct-filter" onchange="loadEmailInbox()" class="filter-sel">
<option value="all">ALL ACCOUNTS</option>
<option value="gmail">Gmail</option>
<option value="outlook">Outlook</option>
<option value="icloud">iCloud</option>
</select>
<button class="btn btn-sm" onclick="loadEmailInbox(true)">↺ REFRESH</button>
</div>
</div>
<div id="email-inbox-view">
<div class="tbl-wrap" id="email-tbl"></div>
</div>
<div id="email-actions-view" style="display:none">
<div class="tbl-wrap" id="email-actions-tbl"></div>
</div>
</div>
<!-- TASKS -->
<div class="tab" id="tab-tasks">
<div class="page-title">TASKS
<div class="actions">
<select id="task-status-filter" onchange="loadTasks()" class="filter-sel">
<option value="">ACTIVE</option><option value="pending">PENDING</option>
<option value="in_progress">IN PROGRESS</option><option value="done">DONE</option>
<option value="cancelled">CANCELLED</option>
</select>
<select id="task-cat-filter" onchange="loadTasks()" class="filter-sel">
<option value="">ALL CATEGORIES</option><option value="personal">PERSONAL</option>
<option value="work">WORK</option><option value="todo">TODO</option>
</select>
<button class="btn btn-sm btn-green" onclick="taskModal()">+ ADD TASK</button>
<button class="btn btn-sm" onclick="loadTasks()">REFRESH</button>
</div>
</div>
<div class="tbl-wrap" id="tasks-tbl"></div>
</div>
<!-- APPOINTMENTS -->
<div class="tab" id="tab-appointments">
<div class="page-title">APPOINTMENTS
<div class="actions">
<button class="btn btn-sm btn-green" onclick="apptModal()">+ ADD APPOINTMENT</button>
<button class="btn btn-sm" onclick="loadAppts()">REFRESH</button>
</div>
</div>
<div class="tbl-wrap" id="appts-tbl"></div>
</div>
<!-- CALENDAR SYNC -->
<div class="tab" id="tab-calendar">
<div class="page-title">CALENDAR SYNC
<div class="actions">
<button class="btn btn-sm btn-green" onclick="calFeedModal()">+ ADD FEED</button>
<button class="btn btn-sm" onclick="syncCalNow()" id="calSyncBtn">⟳ SYNC NOW</button>
<button class="btn btn-sm" onclick="loadCalFeeds()">REFRESH</button>
</div>
</div>
<div style="font-size:0.65rem;color:var(--dim);margin-bottom:10px">
iCloud CalDAV syncs automatically every 15 min. Add Google Calendar or ICS feeds below.
<span id="cal-sync-status" style="margin-left:12px;color:var(--cyan)"></span>
</div>
<div class="tbl-wrap" id="cal-feeds-tbl"></div>
</div>
<!-- USERS -->
<div class="tab" id="tab-users">
<div class="page-title">USERS</div>
<div class="tbl-wrap" id="users-tbl"><div class="loading">SCANNING...</div></div>
</div>
</div><!-- /content -->
</div><!-- /main -->
</div><!-- /app -->
<!-- MODAL -->
<div id="modalBg">
<div id="modal">
<div id="modalHead"><h3 id="modalTitle">EDIT</h3><button id="modalClose" onclick="closeModal()">×</button></div>
<div id="modalBody"></div>
<div id="modalFoot"><button class="btn btn-sm" onclick="closeModal()">CANCEL</button><button class="btn btn-sm btn-green" id="modalSave" onclick="modalSave()">SAVE</button></div>
</div>
</div>
<div id="toast"></div>
<script>
// ── UTILS ─────────────────────────────────────────────────────────────────────
let _alertFilter = 'active';
let _modalCb = null;
function esc(s){ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function ts(s){ if(!s) return '—'; const d=new Date(s); return d.toLocaleString('en-US',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}); }
function ago(s){ if(!s) return '—'; const sec=Math.floor((Date.now()-new Date(s))/1000); if(sec<60) return sec+'s ago'; if(sec<3600) return Math.floor(sec/60)+'m ago'; return Math.floor(sec/3600)+'h ago'; }
function fmtUp(s){ const d=Math.floor(s/86400),h=Math.floor((s%86400)/3600),m=Math.floor((s%3600)/60); return (d>0?d+'d ':'')+h+'h '+m+'m'; }
async function api(action, params={}) {
const url = new URL(location.href);
url.searchParams.set('action', action);
Object.entries(params).forEach(([k,v]) => url.searchParams.set(k,v));
const r = await fetch(url.toString());
return r.json();
}
async function apiPost(action, data={}, cb=null) {
const fd = new FormData();
fd.append('action', action);
Object.entries(data).forEach(([k,v]) => fd.append(k,v));
try {
const r = await fetch(location.href, {method:'POST', body:fd});
const d = await r.json();
if (d.error) { toast(d.error,'err'); return; }
if (cb) cb(d);
} catch(e) { toast('Request failed','err'); }
}
function toast(msg, type='ok') {
const el = document.createElement('div');
el.className = 'toast-msg '+(type==='err'?'err':'ok');
el.textContent = msg;
document.getElementById('toast').appendChild(el);
setTimeout(() => el.remove(), 3000);
}
function sevBadge(s) {
const m = {critical:'badge-red',warning:'badge-yellow',info:'badge-cyan'};
return `<span class="badge ${m[s]||'badge-dim'}">${esc(s).toUpperCase()}</span>`;
}
function statusBadge(s) {
if (s==='online') return `<span class="badge badge-green">ONLINE</span>`;
if (s==='offline') return `<span class="badge badge-red">OFFLINE</span>`;
return `<span class="badge badge-dim">${esc(s).toUpperCase()}</span>`;
}
// ── AUTH ──────────────────────────────────────────────────────────────────────
async function doLogin() {
const u=document.getElementById('lu').value.trim(), p=document.getElementById('lp').value;
const fd=new FormData(); fd.append('action','login'); fd.append('username',u); fd.append('password',p);
const r = await fetch(location.href,{method:'POST',body:fd});
const d = await r.json();
if (d.error) { document.getElementById('loginErr').textContent=d.error; return; }
document.getElementById('loginWrap').style.display='none';
document.getElementById('app').style.display='flex';
document.getElementById('adminUser').textContent = (d.name||u).toUpperCase();
initApp();
}
async function doLogout() {
await apiPost('logout');
location.reload();
}
document.getElementById('lp').addEventListener('keydown', e => { if(e.key==='Enter') doLogin(); });
document.getElementById('lu').addEventListener('keydown', e => { if(e.key==='Enter') document.getElementById('lp').focus(); });
// ── NAV ───────────────────────────────────────────────────────────────────────
function nav(el) {
document.querySelectorAll('.nav-item').forEach(n=>n.classList.remove('active'));
el.classList.add('active');
const tab = el.dataset.tab;
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
document.getElementById('tab-'+tab).classList.add('active');
loadTab(tab);
}
function loadTab(tab) {
// Stop any existing network auto-refresh when leaving
if (_netAutoRefresh) { clearInterval(_netAutoRefresh); _netAutoRefresh = null; }
({
backups: loadBackups,
dashboard: loadDashboard,
agents: loadAgents,
network: ()=>{ loadNetwork(); _netAutoRefresh = setInterval(loadNetwork, 30000); },
alerts: loadAlerts,
facts: ()=>{ loadFactCategories(); loadFacts(); },
intents: loadIntents,
ha: loadHA,
news: loadNews,
vms: loadVMs,
sites: loadSites,
users: loadUsers,
email: ()=>{ loadEmailInbox(); loadEmailActionItems(); },
tasks: loadTasks,
appointments: loadAppts,
calendar: loadCalFeeds,
})[tab]?.();
}
function initApp() { loadDashboard(); setInterval(loadDashboard, 15000); }
// ── DASHBOARD ─────────────────────────────────────────────────────────────────
async function loadDashboard() {
const d = await api('dashboard');
const s = d.sys;
const mp = s.mem_pct, mc = mp>80?'danger':mp>60?'warn':'';
const lc = s.load_1m>2?'danger':s.load_1m>1?'warn':'';
const dc = (parseInt(s.disk_pct)>88)?'danger':(parseInt(s.disk_pct)>75)?'warn':'';
document.getElementById('dash-cards').innerHTML = `
<div class="stat-card">
<div class="lbl">MEMORY</div>
<div class="val ${mc}">${mp}%</div>
<div class="sub">${s.mem_used_mb} / ${s.mem_total_mb} MB</div>
<div class="meter"><div class="meter-bar ${mc}" style="width:${mp}%"></div></div>
</div>
<div class="stat-card">
<div class="lbl">LOAD AVG</div>
<div class="val ${lc}">${s.load_1m}</div>
<div class="sub">1-minute average</div>
</div>
<div class="stat-card">
<div class="lbl">DISK USAGE</div>
<div class="val ${dc}">${s.disk_pct||'—'}</div>
<div class="sub">root filesystem</div>
</div>
<div class="stat-card">
<div class="lbl">UPTIME</div>
<div class="val" style="font-size:1rem;margin-top:4px">${fmtUp(s.uptime_s)}</div>
</div>
<div class="stat-card">
<div class="lbl">AGENTS</div>
<div class="val ${d.agents?.online>0?'':'danger'}">${d.agents?.online||0}<span style="font-size:1rem;color:var(--dim)">/${d.agents?.total||0}</span></div>
<div class="sub">online</div>
</div>
<div class="stat-card">
<div class="lbl">ACTIVE ALERTS</div>
<div class="val ${d.alerts?.active>0?'danger':''}">${d.alerts?.active||0}<span style="font-size:1rem;color:var(--dim)">/${d.alerts?.total||0}</span></div>
<div class="sub">unresolved</div>
</div>
<div class="stat-card">
<div class="lbl">NAMED DEVICES</div>
<div class="val">${d.devices?.total||0}</div>
<div class="sub">${d.devices?.online||0} online</div>
</div>
<div class="stat-card">
<div class="lbl">KB FACTS</div>
<div class="val">${d.facts?.total||0}</div>
<div class="sub">${d.intents?.active||0}/${d.intents?.total||0} intents active</div>
</div>
`;
}
// ── PROGRESSIVE RENDER HELPER ─────────────────────────────────────────────────
// Renders rows one-by-one into a tbody, staggered so the table "fills in" live.
// titleEl: element to show scanning progress. headers: th array. rowFn: item→html string.
function progressiveRender(items, tbodyId, rowFn, titleEl, titleDone) {
const tbody = document.getElementById(tbodyId);
if (!tbody) return;
if (!items.length) {
tbody.closest('table')?.parentElement && (tbody.closest('.tbl-wrap').innerHTML = '<div class="empty">NO DATA</div>');
if (titleEl) titleEl.textContent = titleDone || '';
return;
}
const n = items.length;
const stagger = Math.min(100, Math.max(15, Math.floor(1800 / n))); // cap total at ~1.8s
items.forEach((item, i) => {
setTimeout(() => {
const tr = document.createElement('tr');
tr.className = 'agent-row'; // reuse slide-in animation
tr.innerHTML = rowFn(item, i);
tbody.appendChild(tr);
if (titleEl) {
const done = i + 1;
titleEl.innerHTML = done < n
? `${titleDone.split(' ')[0]} <span style="color:var(--dim);font-size:0.6rem;letter-spacing:2px">SCANNING... ${done}/${n}</span>`
: `${titleDone} <span style="color:var(--cyan);font-size:0.6rem;letter-spacing:2px">${n} TOTAL</span>`;
}
}, i * stagger);
});
}
// Sets up the empty table shell immediately while fetch is in flight
function scanShell(tblWrapId, headers, titleEl, scanLabel) {
const wrap = document.getElementById(tblWrapId);
if (!wrap) return;
const ths = headers.map(h=>`<th>${h}</th>`).join('');
wrap.innerHTML = `<table><thead><tr>${ths}</tr></thead><tbody id="${tblWrapId}-tbody"></tbody></table>`;
if (titleEl) titleEl.innerHTML = `${scanLabel} <span style="color:var(--dim);font-size:0.6rem;letter-spacing:2px">SCANNING...</span>`;
}
// ── AGENTS ────────────────────────────────────────────────────────────────────
const miniBar = (pct, warn=70, crit=85) => {
if (pct == null) return '—';
const c = pct>=crit?'var(--red)':pct>=warn?'var(--yellow)':'var(--green)';
return `<span style="color:${c}">${Math.round(pct)}%</span>`;
};
async function loadAgents() {
const tbl = document.getElementById('agents-tbl');
const title = document.getElementById('agents-title');
tbl.innerHTML = `<table><thead><tr>
<th>HOSTNAME</th><th>STATUS</th><th>TYPE</th><th>IP</th><th>METRICS</th><th>LAST SEEN</th><th>REGISTERED</th><th></th>
</tr></thead><tbody id="agents-tbody"></tbody></table>`;
title.innerHTML = 'AGENTS <span style="color:var(--dim);font-size:0.6rem;letter-spacing:2px">SCANNING...</span>';
let agents;
try { agents = await api('agents_list'); }
catch(e) { tbl.innerHTML='<div class="empty">ERROR LOADING AGENTS</div>'; title.textContent='AGENTS'; return; }
if (!Array.isArray(agents) || !agents.length) {
tbl.innerHTML = '<div class="empty">NO AGENTS REGISTERED</div>';
title.textContent = 'AGENTS';
return;
}
agents.forEach((a, i) => {
setTimeout(() => {
const tbody = document.getElementById('agents-tbody');
if (!tbody) return;
const m = a.metrics;
const online = a.status === 'online';
const lastSeen = a.last_seen ? (Date.now() - new Date(a.last_seen)) / 1000 : null;
const fresh = lastSeen !== null && lastSeen < 30;
const meterCell = m
? `<span style="font-size:0.65rem">CPU ${miniBar(m.cpu_pct)} · RAM ${miniBar(m.mem_pct)} · DISK ${miniBar(m.disk_pct,80,90)}</span>`
: `<span style="color:var(--dim);font-size:0.65rem">no metrics</span>`;
const row = document.createElement('tr');
row.className = 'agent-row';
row.innerHTML = `
<td><span class="dot ${online?'dot-green':'dot-red'}"></span>
<strong>${esc(a.hostname)}</strong>
${fresh&&online?'<span style="font-size:0.55rem;color:var(--green);margin-left:4px">● LIVE</span>':''}
</td>
<td>${statusBadge(a.status)}</td>
<td><span class="badge badge-cyan">${esc(a.agent_type||'linux').toUpperCase()}</span></td>
<td style="font-size:0.72rem">${esc(a.ip_address||'—')}</td>
<td>${meterCell}</td>
<td class="ts">${ago(a.last_seen)}</td>
<td class="ts">${ts(a.created_at)}</td>
<td><button class="btn btn-xs btn-red" onclick="delAgent('${esc(a.agent_id)}','${esc(a.hostname)}')">DEL</button></td>`;
tbody.appendChild(row);
const found = i + 1;
const onlineCt = agents.slice(0, found).filter(x => x.status === 'online').length;
title.innerHTML = found < agents.length
? `AGENTS <span style="color:var(--dim);font-size:0.6rem;letter-spacing:2px">SCANNING... ${found}/${agents.length}</span>`
: `AGENTS <span style="color:var(--cyan);font-size:0.6rem;letter-spacing:2px">${onlineCt} ONLINE / ${agents.length} TOTAL</span>`;
}, i * 120);
});
}
function delAgent(id, name) {
if (!confirm(`Delete agent "${name}"? This cannot be undone.`)) return;
apiPost('agents_delete', {agent_id:id}, ()=>{ toast('Agent deleted','ok'); loadAgents(); });
}
// ── NETWORK ───────────────────────────────────────────────────────────────────
let _netFilter = 'all';
let _allDevices = [];
let _netAutoRefresh = null;
function setNetFilter(f, el) {
_netFilter = f;
document.querySelectorAll('#tab-network .filter-btn').forEach(b=>b.classList.remove('active'));
el.classList.add('active');
renderNetwork();
}
async function loadNetwork() {
scanShell('network-tbl', ['NAME','IP','MAC','VENDOR / TYPE','STATUS','LAST SEEN','ACTIONS'], null, null);
_allDevices = await api('network_list');
renderNetwork();
}
function renderNetwork() {
let devs = _allDevices;
if (_netFilter === 'online') devs = devs.filter(d => d.status === 'online');
if (_netFilter === 'offline') devs = devs.filter(d => d.status === 'offline');
if (_netFilter === 'named') devs = devs.filter(d => d.alias);
const onlineCount = _allDevices.filter(d=>d.status==='online').length;
document.getElementById('net-count').textContent = `${onlineCount}/${_allDevices.length} ONLINE`;
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('');
}
async function loadFacts() {
scanShell('facts-tbl', ['CATEGORY','KEY','VALUE','UPDATED','ACTIONS'], null, null);
const cat = document.getElementById('factCat')?.value || '__all__';
const facts = await api('facts_list', {category: cat});
if (!facts.length) { document.getElementById('facts-tbl').innerHTML='<div class="empty">NO FACTS</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 ────────────────────────────────────────────────────────────────
async function loadIntents() {
scanShell('intents-tbl', ['NAME','PATTERN','RESPONSE','TYPE','PRI','STATUS','ACTIONS'], null, null);
const intents = await api('intents_list');
if (!intents.length) { document.getElementById('intents-tbl').innerHTML='<div class="empty">NO INTENTS</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(); });
});
}
// ── 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) {
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalBody').innerHTML = body;
_modalCb = saveCb;
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; }
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':''}`;
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||[])];
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(); });
}
// ── PLANNER ─────────────────────────────────────────────────────────────────
const _PRI_COLOR = {urgent:'var(--red)',high:'var(--orange)',normal:'var(--text)',low:'var(--border2)'};
async function loadTasks() {
const status = document.getElementById('task-status-filter')?.value || '';
const cat = document.getElementById('task-cat-filter')?.value || '';
const d = await api('task_list', {status, category:cat});
const tasks = d.tasks || [];
const el = document.getElementById('tasks-tbl');
if (!tasks.length) { el.innerHTML='<div class="loading">No tasks found.</div>'; return; }
const rows = tasks.map(t => {
const due = t.due_date ? `<span style="color:${new Date(t.due_date)<new Date()?'var(--red)':'var(--text-dim)'}">${t.due_date}</span>` : '—';
const pri = `<span style="color:${_PRI_COLOR[t.priority]||'var(--text)'};font-size:0.6rem">${t.priority.toUpperCase()}</span>`;
const done = t.status==='done'||t.status==='cancelled';
const doneBtnHtml = done ? '' : `<button class="btn btn-xs btn-green" onclick="taskDone(${t.id})">DONE</button> `;
const td = `style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap${done?';opacity:0.45;text-decoration:line-through':''}"`;
const tJson = JSON.stringify(t).replace(/"/g,'&quot;');
return `<tr><td ${td}>${esc(t.title)}</td><td>${t.category}</td><td>${pri}</td><td>${due}</td>
<td style="font-size:0.6rem;color:var(--text-dim)">${t.status.replace('_',' ').toUpperCase()}</td>
<td style="white-space:nowrap">${doneBtnHtml}<button class="btn btn-xs" onclick='taskModal(${tJson})'>EDIT</button> <button class="btn btn-xs btn-red" onclick="taskDel(${t.id})">DEL</button></td></tr>`;
}).join('');
el.innerHTML = `<table><thead><tr><th>TITLE</th><th>CAT</th><th>PRI</th><th>DUE</th><th>STATUS</th><th>ACTIONS</th></tr></thead><tbody>${rows}</tbody></table>`;
}
function taskModal(t={}) {
const id = t.id||0;
document.body.insertAdjacentHTML('beforeend',`<div class="modal-overlay" onclick="this.remove()">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-title">${id?'EDIT':'NEW'} TASK</div>
<label>TITLE *<input id="tm-title" value="${(t.title||'').replace(/"/g,'&quot;')}" style="width:100%;margin-top:4px"></label>
<label style="margin-top:8px;display:block">NOTES<textarea id="tm-notes" rows="2" style="width:100%;margin-top:4px;background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:6px;resize:vertical">${esc(t.notes||'')}</textarea></label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px">
<label>CATEGORY<select id="tm-cat" style="width:100%;margin-top:4px">
<option value="personal"${t.category==='personal'?' selected':''}>Personal</option>
<option value="work"${t.category==='work'?' selected':''}>Work</option>
<option value="todo"${t.category==='todo'?' selected':''}>Todo</option>
</select></label>
<label>PRIORITY<select id="tm-pri" style="width:100%;margin-top:4px">
<option value="low"${t.priority==='low'?' selected':''}>Low</option>
<option value="normal"${!t.priority||t.priority==='normal'?' selected':''}>Normal</option>
<option value="high"${t.priority==='high'?' selected':''}>High</option>
<option value="urgent"${t.priority==='urgent'?' selected':''}>Urgent</option>
</select></label>
<label>DUE DATE<input type="date" id="tm-due" value="${t.due_date||''}" style="width:100%;margin-top:4px"></label>
<label>STATUS<select id="tm-stat" style="width:100%;margin-top:4px">
<option value="pending"${!t.status||t.status==='pending'?' selected':''}>Pending</option>
<option value="in_progress"${t.status==='in_progress'?' selected':''}>In Progress</option>
<option value="done"${t.status==='done'?' selected':''}>Done</option>
<option value="cancelled"${t.status==='cancelled'?' selected':''}>Cancelled</option>
</select></label>
</div>
<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">
<button class="btn" onclick="this.closest('.modal-overlay').remove()">CANCEL</button>
<button class="btn btn-green" onclick="taskSave(${id})">SAVE</button>
</div>
</div></div>`);
}
function taskSave(id) {
apiPost('task_save',{id,title:document.getElementById('tm-title').value,notes:document.getElementById('tm-notes').value,
category:document.getElementById('tm-cat').value,priority:document.getElementById('tm-pri').value,
status:document.getElementById('tm-stat').value,due_date:document.getElementById('tm-due').value},
()=>{ document.querySelector('.modal-overlay')?.remove(); toast('Saved','ok'); loadTasks(); });
}
function taskDone(id){ apiPost('task_done',{id},()=>{toast('Marked done','ok');loadTasks();}); }
function taskDel(id){ if(!confirm('Delete task?'))return; apiPost('task_delete',{id},()=>{toast('Deleted','ok');loadTasks();}); }
async function loadAppts() {
const from = new Date().toISOString().slice(0,10);
const to = new Date(Date.now()+90*86400000).toISOString().slice(0,10);
const d = await api('appt_list', {from, to});
const appts = d.appointments || [];
const el = document.getElementById('appts-tbl');
if (!appts.length) { el.innerHTML='<div class="loading">No upcoming appointments.</div>'; return; }
const rows = appts.map(a => {
const start = new Date(a.start_at).toLocaleString('en-US',{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
const aJson = JSON.stringify(a).replace(/"/g,'&quot;');
return `<tr><td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.title)}</td>
<td>${a.category}</td><td>${start}</td>
<td style="max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-dim)">${esc(a.location||'—')}</td>
<td style="white-space:nowrap"><button class="btn btn-xs" onclick='apptModal(${aJson})'>EDIT</button> <button class="btn btn-xs btn-red" onclick="apptDel(${a.id})">DEL</button></td></tr>`;
}).join('');
el.innerHTML = `<table><thead><tr><th>TITLE</th><th>CAT</th><th>START</th><th>LOCATION</th><th>ACTIONS</th></tr></thead><tbody>${rows}</tbody></table>`;
}
function apptModal(a={}) {
const id=a.id||0;
const sv=a.start_at?a.start_at.slice(0,16):''; const ev=a.end_at?a.end_at.slice(0,16):'';
document.body.insertAdjacentHTML('beforeend',`<div class="modal-overlay" onclick="this.remove()">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-title">${id?'EDIT':'NEW'} APPOINTMENT</div>
<label>TITLE *<input id="am-title" value="${(a.title||'').replace(/"/g,'&quot;')}" style="width:100%;margin-top:4px"></label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px">
<label>START *<input type="datetime-local" id="am-start" value="${sv}" style="width:100%;margin-top:4px"></label>
<label>END<input type="datetime-local" id="am-end" value="${ev}" style="width:100%;margin-top:4px"></label>
<label>CATEGORY<select id="am-cat" style="width:100%;margin-top:4px">
<option value="personal"${!a.category||a.category==='personal'?' selected':''}>Personal</option>
<option value="work"${a.category==='work'?' selected':''}>Work</option>
<option value="medical"${a.category==='medical'?' selected':''}>Medical</option>
<option value="other"${a.category==='other'?' selected':''}>Other</option>
</select></label>
<label>LOCATION<input id="am-loc" value="${(a.location||'').replace(/"/g,'&quot;')}" placeholder="optional" style="width:100%;margin-top:4px"></label>
</div>
<label style="margin-top:8px;display:block">NOTES<textarea id="am-desc" rows="2" style="width:100%;margin-top:4px;background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:6px;resize:vertical">${esc(a.description||'')}</textarea></label>
<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">
<button class="btn" onclick="this.closest('.modal-overlay').remove()">CANCEL</button>
<button class="btn btn-green" onclick="apptSave(${id})">SAVE</button>
</div>
</div></div>`);
}
function apptSave(id){ apiPost('appt_save',{id,title:document.getElementById('am-title').value,description:document.getElementById('am-desc').value,
category:document.getElementById('am-cat').value,location:document.getElementById('am-loc').value,
start_at:document.getElementById('am-start').value,end_at:document.getElementById('am-end').value},
()=>{ document.querySelector('.modal-overlay')?.remove(); toast('Saved','ok'); loadAppts(); }); }
function apptDel(id){ if(!confirm('Delete appointment?'))return; apiPost('appt_delete',{id},()=>{toast('Deleted','ok');loadAppts();}); }
// ── CALENDAR FEEDS ────────────────────────────────────────────────────────────
async function loadCalFeeds() {
const feeds = await api('cal_feeds_list');
const el = document.getElementById('cal-feeds-tbl');
if (!feeds || !feeds.length) {
el.innerHTML = '<div class="loading">No calendar feeds configured. iCloud syncs via config.php credentials.</div>';
return;
}
const srcLabel = {google:'Google',icloud:'iCloud',outlook:'Outlook',caldav:'CalDAV',ics:'ICS URL'};
el.innerHTML = `<table class="data-tbl"><thead><tr>
<th>NAME</th><th>SOURCE</th><th>ICS URL</th><th>LAST SYNC</th><th>COUNT</th><th>STATUS</th><th>ACTIONS</th>
</tr></thead><tbody>${feeds.map(f=>`<tr>
<td>${esc(f.name)}</td>
<td><span class="badge badge-dim">${srcLabel[f.source]||f.source}</span></td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.6rem">${esc(f.ics_url||'—')}</td>
<td>${ts(f.last_sync)}</td>
<td>${f.last_count||0}</td>
<td>${f.active?'<span class="badge badge-green">ACTIVE</span>':'<span class="badge badge-red">PAUSED</span>'}</td>
<td><button class="btn btn-xs" onclick='calFeedModal(${JSON.stringify(f)})'>EDIT</button>
<button class="btn btn-xs btn-red" onclick="calFeedDel(${f.id})">DEL</button></td>
</tr>`).join('')}</tbody></table>`;
}
function calFeedModal(f={}) {
const id = f.id||0;
openModal(id?'EDIT CALENDAR FEED':'ADD CALENDAR FEED', `
<div class="form-row"><label>NAME</label><input id="cf-name" class="inp" value="${esc(f.name||'')}"></div>
<div class="form-row"><label>SOURCE</label>
<select id="cf-source" class="inp">
<option value="google"${f.source==='google'?' selected':''}>Google Calendar (ICS)</option>
<option value="ics"${f.source==='ics'||!f.source?' selected':''}>ICS URL</option>
<option value="caldav"${f.source==='caldav'?' selected':''}>CalDAV</option>
<option value="outlook"${f.source==='outlook'?' selected':''}>Outlook (ICS)</option>
</select></div>
<div class="form-row"><label>ICS URL</label><input id="cf-ics" class="inp" value="${esc(f.ics_url||'')}" placeholder="https://..."></div>
<div class="form-row"><label>USERNAME (optional)</label><input id="cf-user" class="inp" value="${esc(f.username||'')}"></div>
<div class="form-row"><label>PASSWORD (optional)</label><input id="cf-pass" class="inp" type="password" placeholder="leave blank to keep"></div>
<div class="form-row"><label>ACTIVE</label>
<select id="cf-active" class="inp"><option value="1"${f.active!==0?' selected':''}>Yes</option><option value="0"${f.active===0?' selected':''}>No</option></select></div>
`, () => calFeedSave(id));
}
async function calFeedSave(id) {
await apiPost('cal_feed_save', {
id, name: document.getElementById('cf-name').value,
source: document.getElementById('cf-source').value,
ics_url: document.getElementById('cf-ics').value,
username: document.getElementById('cf-user').value,
password: document.getElementById('cf-pass').value,
active: document.getElementById('cf-active').value
}, () => { toast('Saved','ok'); closeModal(); loadCalFeeds(); });
}
function calFeedDel(id) {
if (!confirm('Delete this calendar feed?')) return;
apiPost('cal_feed_delete', {id}, () => { toast('Deleted','ok'); loadCalFeeds(); });
}
async function syncCalNow() {
const btn = document.getElementById('calSyncBtn');
const status = document.getElementById('cal-sync-status');
btn.disabled = true; btn.textContent = '⟳ SYNCING...';
status.textContent = 'Syncing...';
apiPost('cal_sync_now', {}, d => {
status.textContent = d.ok ? Object.entries(d.results||{}).map(([k,v])=>k+': '+v).join(' | ') || 'Sync complete' : 'Error';
toast('Calendar sync complete','ok');
loadCalFeeds();
btn.disabled = false; btn.textContent = '⟳ SYNC NOW';
});
}
</script>
</body>
</html>