mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
Add /admin portal: full JARVIS management UI (agents, network, alerts, KB, sites, users)
This commit is contained in:
@@ -0,0 +1,934 @@
|
|||||||
|
<?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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
function ts(s){ if(!s) return '—'; const d=new Date(s); return d.toLocaleString('en-US',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}); }
|
||||||
|
function ago(s){ if(!s) return '—'; const sec=Math.floor((Date.now()-new Date(s))/1000); if(sec<60) return sec+'s ago'; if(sec<3600) return Math.floor(sec/60)+'m ago'; return Math.floor(sec/3600)+'h ago'; }
|
||||||
|
function fmtUp(s){ const d=Math.floor(s/86400),h=Math.floor((s%86400)/3600),m=Math.floor((s%3600)/60); return (d>0?d+'d ':'')+h+'h '+m+'m'; }
|
||||||
|
|
||||||
|
async function api(action, params={}) {
|
||||||
|
const url = new URL(location.href);
|
||||||
|
url.searchParams.set('action', action);
|
||||||
|
Object.entries(params).forEach(([k,v]) => url.searchParams.set(k,v));
|
||||||
|
const r = await fetch(url.toString());
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiPost(action, data={}, cb=null) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('action', action);
|
||||||
|
Object.entries(data).forEach(([k,v]) => fd.append(k,v));
|
||||||
|
try {
|
||||||
|
const r = await fetch(location.href, {method:'POST', body:fd});
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.error) { toast(d.error,'err'); return; }
|
||||||
|
if (cb) cb(d);
|
||||||
|
} catch(e) { toast('Request failed','err'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(msg, type='ok') {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'toast-msg '+(type==='err'?'err':'ok');
|
||||||
|
el.textContent = msg;
|
||||||
|
document.getElementById('toast').appendChild(el);
|
||||||
|
setTimeout(() => el.remove(), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sevBadge(s) {
|
||||||
|
const m = {critical:'badge-red',warning:'badge-yellow',info:'badge-cyan'};
|
||||||
|
return `<span class="badge ${m[s]||'badge-dim'}">${esc(s).toUpperCase()}</span>`;
|
||||||
|
}
|
||||||
|
function statusBadge(s) {
|
||||||
|
if (s==='online') return `<span class="badge badge-green">ONLINE</span>`;
|
||||||
|
if (s==='offline') return `<span class="badge badge-red">OFFLINE</span>`;
|
||||||
|
return `<span class="badge badge-dim">${esc(s).toUpperCase()}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AUTH ──────────────────────────────────────────────────────────────────────
|
||||||
|
async function doLogin() {
|
||||||
|
const u=document.getElementById('lu').value.trim(), p=document.getElementById('lp').value;
|
||||||
|
const fd=new FormData(); fd.append('action','login'); fd.append('username',u); fd.append('password',p);
|
||||||
|
const r = await fetch(location.href,{method:'POST',body:fd});
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.error) { document.getElementById('loginErr').textContent=d.error; return; }
|
||||||
|
document.getElementById('loginWrap').style.display='none';
|
||||||
|
document.getElementById('app').style.display='flex';
|
||||||
|
document.getElementById('adminUser').textContent = (d.name||u).toUpperCase();
|
||||||
|
initApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogout() {
|
||||||
|
await apiPost('logout');
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('lp').addEventListener('keydown', e => { if(e.key==='Enter') doLogin(); });
|
||||||
|
document.getElementById('lu').addEventListener('keydown', e => { if(e.key==='Enter') document.getElementById('lp').focus(); });
|
||||||
|
|
||||||
|
// ── NAV ───────────────────────────────────────────────────────────────────────
|
||||||
|
function nav(el) {
|
||||||
|
document.querySelectorAll('.nav-item').forEach(n=>n.classList.remove('active'));
|
||||||
|
el.classList.add('active');
|
||||||
|
const tab = el.dataset.tab;
|
||||||
|
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
|
||||||
|
document.getElementById('tab-'+tab).classList.add('active');
|
||||||
|
loadTab(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTab(tab) {
|
||||||
|
({
|
||||||
|
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>
|
||||||
Reference in New Issue
Block a user