Files
jarvis/public_html/admin/index.php
T

1291 lines
72 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, cpu_pct, mem_pct, disk_pct FROM agent_metrics WHERE recorded_at > DATE_SUB(NOW(), INTERVAL 5 MINUTE) GROUP BY agent_id');
$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':
$raw = JarvisDB::single("SELECT data, UNIX_TIMESTAMP(updated_at) ts FROM api_cache WHERE cache_key='ha_entities'");
if (!$raw) j(['entities'=>[],'domains'=>[],'ts'=>null]);
$cache = json_decode($raw['data'], true) ?? [];
$domain = $_GET['domain'] ?? '';
$search = strtolower(trim($_GET['search'] ?? ''));
$all = [];
foreach ($cache['entities'] ?? [] as $dom => $ents) {
if ($domain && $dom !== $domain) continue;
foreach ($ents as $e) {
if ($search && strpos(strtolower($e['name']??''),$search)===false && strpos(strtolower($e['entity_id']??''),$search)===false) continue;
$e['domain'] = $dom;
$all[] = $e;
}
}
usort($all, fn($a,$b) => strcmp($a['name']??'',$b['name']??''));
j(['entities'=>array_slice($all,0,500),'domains'=>array_keys($cache['entities']??[]),'total'=>count($all),'ts'=>$raw['ts']]);
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 '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]);
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}
.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-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">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">LOADING...</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">AGENTS
<div class="actions"><button class="btn btn-sm" onclick="loadAgents()">REFRESH</button></div>
</div>
<div class="tbl-wrap" id="agents-tbl"><div class="loading">LOADING...</div></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">LOADING...</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">LOADING...</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">LOADING...</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">LOADING...</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 name or entity_id..." style="background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:4px 8px;font-family:var(--font);font-size:0.65rem;width:220px;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">LOADING...</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">LOADING...</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">LOADING...</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">LOADING...</div></div>
</div>
<!-- SITES -->
<div class="tab" id="tab-sites">
<div class="page-title">SITE HEALTH</div>
<div id="sites-content"><div class="loading">LOADING...</div></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">LOADING...</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; }
({
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,
})[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>
`;
}
// ── AGENTS ────────────────────────────────────────────────────────────────────
async function loadAgents() {
document.getElementById('agents-tbl').innerHTML = '<div class="loading">LOADING...</div>';
const agents = await api('agents_list');
if (!agents.length) { document.getElementById('agents-tbl').innerHTML='<div class="empty">NO AGENTS REGISTERED</div>'; return; }
let rows = agents.map(a => {
const m = a.metrics;
const meterCell = m
? `<span style="font-size:0.65rem">${m.cpu_pct??'—'}% CPU · ${m.mem_pct??'—'}% RAM</span>`
: `<span style="color:var(--dim);font-size:0.65rem">—</span>`;
return `<tr>
<td><span class="dot ${a.status==='online'?'dot-green':'dot-red'}"></span>${esc(a.hostname)}</td>
<td>${statusBadge(a.status)}</td>
<td><span class="badge badge-cyan">${esc(a.agent_type).toUpperCase()}</span></td>
<td>${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><div class="actions-col">
<button class="btn btn-xs btn-red" onclick="delAgent('${esc(a.agent_id)}','${esc(a.hostname)}')">DELETE</button>
</div></td>
</tr>`;
}).join('');
document.getElementById('agents-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>ACTIONS</th></tr></thead>
<tbody>${rows}</tbody></table>`;
}
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() {
document.getElementById('network-tbl').innerHTML = '<div class="loading">LOADING...</div>';
_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; }
let rows = devs.map(d => {
const name = d.alias || d.hostname || d.ip;
const isNamed = !!d.alias;
const vendor = d.device_type || '—';
return `<tr>
<td>
<span class="dot ${d.status==='online'?'dot-green':d.status==='offline'?'dot-red':'dot-dim'}"></span>
<strong>${esc(name)}</strong>${isNamed?'':' <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>
</tr>`;
}).join('');
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>${rows}</tbody></table>`;
}
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() {
document.getElementById('alerts-tbl').innerHTML='<div class="loading">LOADING...</div>';
const alerts = await api('alerts_list', {filter:_alertFilter});
if (!alerts.length) { document.getElementById('alerts-tbl').innerHTML='<div class="empty">NO ALERTS</div>'; return; }
let rows = alerts.map(a => `<tr>
<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>
</tr>`).join('');
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>${rows}</tbody></table>`;
}
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() {
document.getElementById('facts-tbl').innerHTML='<div class="loading">LOADING...</div>';
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; }
let rows = facts.map(f => `<tr>
<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>
</tr>`).join('');
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>${rows}</tbody></table>`;
}
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() {
document.getElementById('intents-tbl').innerHTML='<div class="loading">LOADING...</div>';
const intents = await api('intents_list');
if (!intents.length) { document.getElementById('intents-tbl').innerHTML='<div class="empty">NO INTENTS</div>'; return; }
let rows = intents.map(i => `<tr style="${i.active?'':'opacity:0.45'}">
<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" title="${esc(i.response_template||'')}"><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>
</tr>`).join('');
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>${rows}</tbody></table>`;
}
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">LOADING...</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() {
document.getElementById('users-tbl').innerHTML='<div class="loading">LOADING...</div>';
const users = await api('users_list');
let rows = users.map(u => `<tr>
<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>
</tr>`).join('');
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>${rows}</tbody></table>`;
}
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">LOADING...</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) {
if (!entities.length) { document.getElementById('ha-tbl').innerHTML='<div class="empty">NO ENTITIES</div>'; return; }
const domainColors = {light:'#ffcc00',switch:'#00d4ff',binary_sensor:'#39ff14',sensor:'#9b9bff',media_player:'#ff8800',alarm_control_panel:'#ff3333',camera:'#888'};
let rows = entities.map(e => {
const on = ['on','home','open','playing','mowing','active'].includes(e.state);
const dc = domainColors[e.domain] || 'var(--dim)';
return `<tr>
<td><span style="color:${dc};font-size:0.6rem">${esc(e.domain)}</span></td>
<td>${esc(e.name||e.entity_id)}</td>
<td style="font-size:0.65rem;color:var(--dim)">${esc(e.entity_id)}</td>
<td><span class="badge ${on?'badge-green':'badge-dim'}">${esc(e.state)}</span></td>
</tr>`;
}).join('');
document.getElementById('ha-tbl').innerHTML = `<table>
<thead><tr><th>DOMAIN</th><th>NAME</th><th>ENTITY ID</th><th>STATE</th></tr></thead>
<tbody>${rows}</tbody></table>`;
}
// ── NEWS ──────────────────────────────────────────────────────────────────────
async function loadNews() {
document.getElementById('news-custom').innerHTML='<div class="loading">LOADING...</div>';
document.getElementById('news-live').innerHTML='<div class="loading">LOADING...</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">LOADING...</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>`;
}
// ── 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; ?>
</script>
</body>
</html>