Files
jarvis/public_html/admin/index.php
T

935 lines
52 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]); }
// ── 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 WHERE alias IS NOT NULL ORDER BY alias'));
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"));
// ── 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">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" onclick="loadNetwork()">REFRESH</button>
</div>
</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('all',this)">ALL</button>
<button class="filter-btn" onclick="setAlertFilter('active',this)">ACTIVE</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>
<!-- 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 = 'all';
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) {
({
dashboard: loadDashboard,
agents: loadAgents,
network: loadNetwork,
alerts: loadAlerts,
facts: ()=>{ loadFactCategories(); loadFacts(); },
intents: loadIntents,
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 ───────────────────────────────────────────────────────────────────
async function loadNetwork() {
document.getElementById('network-tbl').innerHTML = '<div class="loading">LOADING...</div>';
const devs = await api('network_list');
if (!devs.length) { document.getElementById('network-tbl').innerHTML='<div class="empty">NO NAMED DEVICES</div>'; return; }
let rows = devs.map(d => `<tr>
<td><span class="dot ${d.status==='online'?'dot-green':d.status==='offline'?'dot-red':'dot-dim'}"></span>${esc(d.alias)}</td>
<td>${esc(d.ip)}</td>
<td><span class="badge badge-dim">${esc(d.device_type||'device').toUpperCase()}</span></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(${d.id},'${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||'')}')">EDIT</button>
<button class="btn btn-xs btn-red" onclick="delNet(${d.id},'${esc(d.alias)}')">DEL</button>
</div></td>
</tr>`).join('');
document.getElementById('network-tbl').innerHTML = `<table>
<thead><tr><th>NAME</th><th>IP</th><th>TYPE</th><th>STATUS</th><th>LAST SEEN</th><th>ACTIONS</th></tr></thead>
<tbody>${rows}</tbody></table>`;
}
function netModal(id=0, ip='', alias='', type='device') {
openModal(id?'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(id, ip, btn) {
btn.textContent = '...'; btn.disabled = true;
const res = await apiPost('network_ping', {ip}, null);
// Re-fetch to update status
loadNetwork();
btn.textContent = 'PING'; btn.disabled = false;
}
// Direct ping returning result
(window.pingDev = async function(id, 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(); });
// ── 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>