mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
1256 lines
70 KiB
PHP
1256 lines
70 KiB
PHP
<?php
|
||
require_once __DIR__ . '/../../api/config.php';
|
||
require_once __DIR__ . '/../../api/lib/db.php';
|
||
|
||
session_name('jarvis_admin');
|
||
session_start();
|
||
|
||
// ── AUTH HELPERS ──────────────────────────────────────────────────────────────
|
||
function loggedIn(): bool { return !empty($_SESSION['admin_user']); }
|
||
function j(mixed $d): never { header('Content-Type: application/json'); echo json_encode($d); exit; }
|
||
function bad(string $msg, int $code = 400): never { http_response_code($code); j(['error' => $msg]); }
|
||
|
||
function self_upsert_device(array $d): void {
|
||
JarvisDB::execute(
|
||
'INSERT INTO network_devices (ip,mac,hostname,status,last_seen) VALUES (?,?,?,"online",NOW())
|
||
ON DUPLICATE KEY UPDATE mac=VALUES(mac), hostname=COALESCE(VALUES(hostname),hostname), status="online", last_seen=NOW()',
|
||
[$d['ip'], $d['mac'], $d['hostname'] ?? $d['vendor']]
|
||
);
|
||
if (!empty($d['vendor'])) {
|
||
JarvisDB::execute('UPDATE network_devices SET device_type=? WHERE ip=? AND (device_type IS NULL OR device_type="")', [$d['vendor'], $d['ip']]);
|
||
}
|
||
}
|
||
|
||
// ── BACKEND API ───────────────────────────────────────────────────────────────
|
||
$action = $_GET['action'] ?? $_POST['action'] ?? '';
|
||
if ($action) {
|
||
// Login doesn't require session
|
||
if ($action === 'login') {
|
||
$u = trim($_POST['username'] ?? '');
|
||
$p = $_POST['password'] ?? '';
|
||
$row = JarvisDB::single('SELECT * FROM users WHERE username = ?', [$u]);
|
||
if ($row && password_verify($p, $row['password_hash'])) {
|
||
$_SESSION['admin_user'] = $row['username'];
|
||
$_SESSION['admin_name'] = $row['display_name'];
|
||
j(['ok' => true, 'name' => $row['display_name']]);
|
||
}
|
||
bad('Invalid credentials', 401);
|
||
}
|
||
if ($action === 'logout') { session_destroy(); j(['ok' => true]); }
|
||
if (!loggedIn()) bad('Not authenticated', 401);
|
||
|
||
switch ($action) {
|
||
|
||
// ── DASHBOARD ─────────────────────────────────────────────────────────
|
||
case 'dashboard':
|
||
$mi = [];
|
||
foreach (file('/proc/meminfo') as $l) {
|
||
[$k,$v] = explode(':', $l, 2) + [null,null];
|
||
if ($k) $mi[trim($k)] = (int)trim($v);
|
||
}
|
||
$mt = $mi['MemTotal'] ?? 0; $mf = $mi['MemAvailable'] ?? 0;
|
||
$up = (int)explode(' ', file_get_contents('/proc/uptime'))[0];
|
||
$la = explode(' ', file_get_contents('/proc/loadavg'));
|
||
$disk = trim(shell_exec("df / | tail -1 | awk '{print $5}'") ?? '');
|
||
j([
|
||
'sys' => [
|
||
'mem_pct' => $mt > 0 ? round(($mt-$mf)/$mt*100,1) : 0,
|
||
'mem_used_mb' => round(($mt-$mf)/1024),
|
||
'mem_total_mb' => round($mt/1024),
|
||
'uptime_s' => $up,
|
||
'load_1m' => (float)$la[0],
|
||
'disk_pct' => $disk,
|
||
],
|
||
'agents' => JarvisDB::single('SELECT COUNT(*) total, SUM(status="online") online FROM registered_agents'),
|
||
'alerts' => JarvisDB::single('SELECT COUNT(*) total, SUM(resolved=0) active FROM alerts'),
|
||
'devices' => JarvisDB::single('SELECT COUNT(*) total, SUM(status="online") online FROM network_devices WHERE alias IS NOT NULL'),
|
||
'facts' => JarvisDB::single('SELECT COUNT(*) total FROM kb_facts'),
|
||
'intents' => JarvisDB::single('SELECT COUNT(*) total, SUM(active=1) active FROM kb_intents'),
|
||
]);
|
||
|
||
// ── AGENTS ───────────────────────────────────────────────────────────
|
||
case 'agents_list':
|
||
$agents = JarvisDB::query('SELECT agent_id, hostname, agent_type, ip_address, status, last_seen, created_at FROM registered_agents ORDER BY status="online" DESC, hostname');
|
||
$metrics = JarvisDB::query('SELECT agent_id, cpu_pct, mem_pct, disk_pct FROM agent_metrics WHERE recorded_at > DATE_SUB(NOW(), INTERVAL 5 MINUTE) GROUP BY agent_id');
|
||
$mm = array_column($metrics, null, 'agent_id');
|
||
foreach ($agents as &$a) $a['metrics'] = $mm[$a['agent_id']] ?? null;
|
||
j($agents);
|
||
|
||
case 'agents_delete':
|
||
$id = $_POST['agent_id'] ?? ''; if (!$id) bad('Missing agent_id');
|
||
JarvisDB::execute('DELETE FROM registered_agents WHERE agent_id=?', [$id]);
|
||
JarvisDB::execute('DELETE FROM agent_metrics WHERE agent_id=?', [$id]);
|
||
JarvisDB::execute('DELETE FROM agent_commands WHERE agent_id=?', [$id]);
|
||
j(['ok' => true]);
|
||
|
||
// ── NETWORK ──────────────────────────────────────────────────────────
|
||
case 'network_list':
|
||
j(JarvisDB::query('SELECT id,ip,mac,hostname,alias,device_type,status,last_seen FROM network_devices ORDER BY status="online" DESC, COALESCE(alias,hostname,ip)'));
|
||
|
||
case 'network_scan':
|
||
// Queue shell command to PVE1 agent — it runs jarvis-netscan.sh and pushes results back
|
||
$pve1 = JarvisDB::single('SELECT agent_id FROM registered_agents WHERE ip_address="10.48.200.90" AND status="online" LIMIT 1');
|
||
if (!$pve1) {
|
||
$pve1 = JarvisDB::single('SELECT agent_id FROM registered_agents WHERE hostname LIKE "%pve%" AND status="online" LIMIT 1');
|
||
}
|
||
if ($pve1) {
|
||
JarvisDB::execute(
|
||
'INSERT INTO agent_commands (agent_id, command_type, command_data, status) VALUES (?,?,?,?)',
|
||
[$pve1['agent_id'], 'shell', json_encode(['command'=>'/usr/local/bin/jarvis-netscan.sh','allowed'=>true]), 'pending']
|
||
);
|
||
j(['ok' => true, 'queued' => true, 'note' => 'Scan command sent to PVE1 agent — results in ~40 seconds']);
|
||
} else {
|
||
j(['ok' => false, 'note' => 'PVE1 agent offline — scan will run automatically via cron in < 3 minutes']);
|
||
}
|
||
|
||
case 'network_save':
|
||
$id = (int)($_POST['id'] ?? 0);
|
||
$ip = trim($_POST['ip'] ?? ''); $alias = trim($_POST['alias'] ?? '');
|
||
$type = trim($_POST['device_type'] ?? 'device');
|
||
if (!$ip || !$alias) bad('IP and alias required');
|
||
if ($id) {
|
||
JarvisDB::execute('UPDATE network_devices SET ip=?,alias=?,device_type=? WHERE id=?', [$ip,$alias,$type,$id]);
|
||
} else {
|
||
JarvisDB::execute('INSERT INTO network_devices (ip,alias,device_type,status) VALUES (?,?,?,"unknown") ON DUPLICATE KEY UPDATE alias=?,device_type=?', [$ip,$alias,$type,$alias,$type]);
|
||
}
|
||
j(['ok' => true]);
|
||
|
||
case 'network_delete':
|
||
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
|
||
JarvisDB::execute('DELETE FROM network_devices WHERE id=?', [$id]);
|
||
j(['ok' => true]);
|
||
|
||
case 'network_ping':
|
||
$ip = trim($_POST['ip'] ?? ''); if (!$ip) bad('Missing IP');
|
||
$out = shell_exec('ping -c 2 -W 2 '.escapeshellarg($ip).' 2>/dev/null');
|
||
$alive = $out && (strpos($out,'2 received')!==false || strpos($out,'1 received')!==false);
|
||
$lat = null;
|
||
if ($alive && preg_match('/time=([\d.]+)/', $out, $m)) $lat = (float)$m[1];
|
||
j(['alive'=>$alive,'latency_ms'=>$lat]);
|
||
|
||
// ── ALERTS ───────────────────────────────────────────────────────────
|
||
case 'alerts_list':
|
||
$f = $_GET['filter'] ?? 'all';
|
||
$w = $f === 'active' ? 'WHERE resolved=0' : ($f === 'resolved' ? 'WHERE resolved=1' : '');
|
||
j(JarvisDB::query("SELECT id,alert_type,title,message,severity,resolved,created_at,resolved_at,source_key FROM alerts $w ORDER BY created_at DESC LIMIT 300"));
|
||
|
||
case 'alerts_resolve':
|
||
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
|
||
JarvisDB::execute('UPDATE alerts SET resolved=1,resolved_at=NOW() WHERE id=?', [$id]);
|
||
j(['ok' => true]);
|
||
|
||
case 'alerts_resolve_all':
|
||
JarvisDB::execute('UPDATE alerts SET resolved=1,resolved_at=NOW() WHERE resolved=0');
|
||
j(['ok' => true]);
|
||
|
||
case 'alerts_delete':
|
||
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
|
||
JarvisDB::execute('DELETE FROM alerts WHERE id=?', [$id]);
|
||
j(['ok' => true]);
|
||
|
||
case 'alerts_purge_resolved':
|
||
JarvisDB::execute('DELETE FROM alerts WHERE resolved=1');
|
||
j(['ok' => true]);
|
||
|
||
case 'alerts_save':
|
||
$id = (int)($_POST['id'] ?? 0);
|
||
$t = trim($_POST['title'] ?? ''); if (!$t) bad('Title required');
|
||
$typ = trim($_POST['alert_type'] ?? 'manual');
|
||
$msg = trim($_POST['message'] ?? '');
|
||
$sev = trim($_POST['severity'] ?? 'info');
|
||
if ($id) {
|
||
JarvisDB::execute('UPDATE alerts SET alert_type=?,title=?,message=?,severity=? WHERE id=?', [$typ,$t,$msg,$sev,$id]);
|
||
} else {
|
||
JarvisDB::execute('INSERT INTO alerts (alert_type,title,message,severity,resolved) VALUES (?,?,?,?,0)', [$typ,$t,$msg,$sev]);
|
||
}
|
||
j(['ok' => true]);
|
||
|
||
// ── KB FACTS ─────────────────────────────────────────────────────────
|
||
case 'facts_categories':
|
||
j(JarvisDB::query('SELECT category, COUNT(*) cnt FROM kb_facts GROUP BY category ORDER BY cnt DESC'));
|
||
|
||
case 'facts_list':
|
||
$cat = $_GET['category'] ?? '';
|
||
if ($cat === '__all__') {
|
||
j(JarvisDB::query('SELECT id,category,fact_key,fact_value,host,updated_at FROM kb_facts ORDER BY category,fact_key LIMIT 1000'));
|
||
}
|
||
j(JarvisDB::query('SELECT id,category,fact_key,fact_value,host,updated_at FROM kb_facts WHERE category=? ORDER BY fact_key', [$cat]));
|
||
|
||
case 'facts_save':
|
||
$id = (int)($_POST['id'] ?? 0);
|
||
$cat = trim($_POST['category'] ?? ''); $key = trim($_POST['fact_key'] ?? ''); $val = trim($_POST['fact_value'] ?? '');
|
||
if (!$cat||!$key) bad('Category and key required');
|
||
if ($id) {
|
||
JarvisDB::execute('UPDATE kb_facts SET category=?,fact_key=?,fact_value=? WHERE id=?', [$cat,$key,$val,$id]);
|
||
} else {
|
||
JarvisDB::execute('INSERT INTO kb_facts (category,fact_key,fact_value) VALUES (?,?,?)', [$cat,$key,$val]);
|
||
}
|
||
j(['ok' => true]);
|
||
|
||
case 'facts_delete':
|
||
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
|
||
JarvisDB::execute('DELETE FROM kb_facts WHERE id=?', [$id]);
|
||
j(['ok' => true]);
|
||
|
||
// ── KB INTENTS ───────────────────────────────────────────────────────
|
||
case 'intents_list':
|
||
j(JarvisDB::query('SELECT id,intent_name,pattern,response_template,action_type,priority,active FROM kb_intents ORDER BY priority DESC,intent_name'));
|
||
|
||
case 'intents_save':
|
||
$id = (int)($_POST['id'] ?? 0);
|
||
$name = trim($_POST['intent_name'] ?? ''); $pat = trim($_POST['pattern'] ?? '');
|
||
$resp = trim($_POST['response_template'] ?? '');
|
||
$typ = trim($_POST['action_type'] ?? 'response');
|
||
$pri = (int)($_POST['priority'] ?? 5); $act = (int)($_POST['active'] ?? 1);
|
||
if (!$name||!$pat) bad('Name and pattern required');
|
||
if ($id) {
|
||
JarvisDB::execute('UPDATE kb_intents SET intent_name=?,pattern=?,response_template=?,action_type=?,priority=?,active=? WHERE id=?', [$name,$pat,$resp,$typ,$pri,$act,$id]);
|
||
} else {
|
||
JarvisDB::execute('INSERT INTO kb_intents (intent_name,pattern,response_template,action_type,priority,active) VALUES (?,?,?,?,?,?)', [$name,$pat,$resp,$typ,$pri,$act]);
|
||
}
|
||
j(['ok' => true]);
|
||
|
||
case 'intents_delete':
|
||
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
|
||
JarvisDB::execute('DELETE FROM kb_intents WHERE id=?', [$id]);
|
||
j(['ok' => true]);
|
||
|
||
case 'intents_toggle':
|
||
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
|
||
JarvisDB::execute('UPDATE kb_intents SET active=NOT active WHERE id=?', [$id]);
|
||
j(['ok' => true]);
|
||
|
||
// ── SITES ────────────────────────────────────────────────────────────
|
||
case 'sites_list':
|
||
j(JarvisDB::query("SELECT fact_key,fact_value,updated_at FROM kb_facts WHERE category='sites' ORDER BY fact_key"));
|
||
|
||
// ── HOME ASSISTANT ENTITIES ───────────────────────────────────────────
|
||
case 'ha_list':
|
||
$raw = JarvisDB::single("SELECT data, UNIX_TIMESTAMP(updated_at) ts FROM api_cache WHERE cache_key='ha_entities'");
|
||
if (!$raw) j(['entities'=>[],'domains'=>[],'ts'=>null]);
|
||
$cache = json_decode($raw['data'], true) ?? [];
|
||
$domain = $_GET['domain'] ?? '';
|
||
$search = strtolower(trim($_GET['search'] ?? ''));
|
||
$all = [];
|
||
foreach ($cache['entities'] ?? [] as $dom => $ents) {
|
||
if ($domain && $dom !== $domain) continue;
|
||
foreach ($ents as $e) {
|
||
if ($search && strpos(strtolower($e['name']??''),$search)===false && strpos(strtolower($e['entity_id']??''),$search)===false) continue;
|
||
$e['domain'] = $dom;
|
||
$all[] = $e;
|
||
}
|
||
}
|
||
usort($all, fn($a,$b) => strcmp($a['name']??'',$b['name']??''));
|
||
j(['entities'=>array_slice($all,0,500),'domains'=>array_keys($cache['entities']??[]),'total'=>count($all),'ts'=>$raw['ts']]);
|
||
|
||
case 'ha_toggle':
|
||
$eid = trim($_POST['entity_id'] ?? ''); if (!$eid) bad('Missing entity_id');
|
||
$state = trim($_POST['state'] ?? '');
|
||
if (!defined('HA_URL')||!defined('HA_TOKEN')) bad('HA not configured');
|
||
$domain = explode('.',$eid)[0];
|
||
$svc = match($domain) {
|
||
'light','switch','input_boolean','fan' => ($state==='on'?'turn_off':'turn_on'),
|
||
default => ($state==='on'?'turn_off':'turn_on')
|
||
};
|
||
$ch = curl_init(HA_URL.'/api/services/'.$domain.'/'.$svc);
|
||
curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_POST=>true,
|
||
CURLOPT_HTTPHEADER=>['Authorization: Bearer '.HA_TOKEN,'Content-Type: application/json'],
|
||
CURLOPT_POSTFIELDS=>json_encode(['entity_id'=>$eid]),CURLOPT_TIMEOUT=>8]);
|
||
$res = curl_exec($ch); $code = curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch);
|
||
j(['ok'=>$code<300,'code'=>$code]);
|
||
|
||
// ── NEWS ─────────────────────────────────────────────────────────────
|
||
case 'news_list':
|
||
$cached = JarvisDB::single("SELECT data,UNIX_TIMESTAMP(updated_at) ts FROM api_cache WHERE cache_key='news'");
|
||
$news = $cached ? (json_decode($cached['data'],true)??[]) : [];
|
||
$custom = JarvisDB::query("SELECT id,fact_key title,fact_value url,updated_at FROM kb_facts WHERE category='custom_news' ORDER BY id DESC");
|
||
j(['news'=>$news,'custom'=>$custom,'cache_age'=>$cached?time()-(int)$cached['ts']:null]);
|
||
|
||
case 'news_custom_save':
|
||
$id = (int)($_POST['id']??0);
|
||
$t = trim($_POST['title']??''); if(!$t) bad('Title required');
|
||
$url = trim($_POST['url']??'');
|
||
if($id) {
|
||
JarvisDB::execute('UPDATE kb_facts SET fact_key=?,fact_value=? WHERE id=? AND category="custom_news"',[$t,$url,$id]);
|
||
} else {
|
||
JarvisDB::execute('INSERT INTO kb_facts (category,fact_key,fact_value) VALUES ("custom_news",?,?)',[$t,$url]);
|
||
}
|
||
j(['ok'=>true]);
|
||
|
||
case 'news_custom_delete':
|
||
$id=(int)($_POST['id']??0); if(!$id) bad('Missing id');
|
||
JarvisDB::execute('DELETE FROM kb_facts WHERE id=? AND category="custom_news"',[$id]);
|
||
j(['ok'=>true]);
|
||
|
||
// ── PROXMOX VMs ───────────────────────────────────────────────────────
|
||
case 'vms_list':
|
||
$raw = JarvisDB::single("SELECT data,UNIX_TIMESTAMP(updated_at) ts FROM api_cache WHERE cache_key='proxmox'");
|
||
if (!$raw) j(['vms'=>[],'ts'=>null]);
|
||
$pve = json_decode($raw['data'],true) ?? [];
|
||
$vms = array_merge($pve['vms']??[], $pve['containers']??[]);
|
||
j(['vms'=>$vms,'node_status'=>$pve['node_status']??null,'ts'=>$raw['ts']]);
|
||
|
||
// ── USERS ────────────────────────────────────────────────────────────
|
||
case 'users_list':
|
||
j(JarvisDB::query('SELECT id,username,display_name,last_seen,created_at FROM users ORDER BY username'));
|
||
|
||
case 'users_save':
|
||
$id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id');
|
||
$dn = trim($_POST['display_name'] ?? '');
|
||
$pw = trim($_POST['password'] ?? '');
|
||
if ($pw) {
|
||
JarvisDB::execute('UPDATE users SET display_name=?,password_hash=? WHERE id=?', [$dn, password_hash($pw, PASSWORD_BCRYPT), $id]);
|
||
} else {
|
||
JarvisDB::execute('UPDATE users SET display_name=? WHERE id=?', [$dn, $id]);
|
||
}
|
||
j(['ok' => true]);
|
||
|
||
default: bad('Unknown action');
|
||
}
|
||
}
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>JARVIS ADMIN</title>
|
||
<style>
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
:root{
|
||
--bg:#080b0f;--surface:#0d1117;--panel:#111820;--border:#1a2535;--border2:#243040;
|
||
--cyan:#00d4ff;--green:#39ff14;--red:#ff3333;--yellow:#ffcc00;--orange:#ff8800;
|
||
--text:#c8d8e8;--dim:#4a6080;--font:'Share Tech Mono',monospace;
|
||
}
|
||
body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:13px;min-height:100vh;display:flex;flex-direction:column}
|
||
a{color:var(--cyan);text-decoration:none}
|
||
|
||
/* ── LOGIN ── */
|
||
#loginWrap{display:flex;align-items:center;justify-content:center;min-height:100vh;flex-direction:column;gap:24px}
|
||
#loginBox{background:var(--surface);border:1px solid var(--border2);padding:36px 40px;width:360px}
|
||
#loginBox h1{color:var(--cyan);font-size:1.4rem;letter-spacing:4px;margin-bottom:6px;text-align:center}
|
||
#loginBox p{color:var(--dim);font-size:0.7rem;letter-spacing:2px;text-align:center;margin-bottom:28px}
|
||
.field{margin-bottom:16px}
|
||
.field label{display:block;color:var(--dim);font-size:0.65rem;letter-spacing:2px;margin-bottom:6px}
|
||
.field input{width:100%;background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:10px 12px;font-family:var(--font);font-size:13px;outline:none}
|
||
.field input:focus{border-color:var(--cyan)}
|
||
#loginErr{color:var(--red);font-size:0.7rem;text-align:center;min-height:16px;margin-bottom:8px}
|
||
.btn{background:transparent;border:1px solid var(--cyan);color:var(--cyan);padding:10px 20px;font-family:var(--font);font-size:0.75rem;letter-spacing:2px;cursor:pointer;transition:all .15s}
|
||
.btn:hover{background:var(--cyan);color:#000}
|
||
.btn-red{border-color:var(--red);color:var(--red)} .btn-red:hover{background:var(--red);color:#fff}
|
||
.btn-green{border-color:var(--green);color:var(--green)} .btn-green:hover{background:var(--green);color:#000}
|
||
.btn-yellow{border-color:var(--yellow);color:var(--yellow)} .btn-yellow:hover{background:var(--yellow);color:#000}
|
||
.btn-sm{padding:4px 10px;font-size:0.65rem;letter-spacing:1px}
|
||
.btn-xs{padding:2px 7px;font-size:0.6rem;letter-spacing:1px}
|
||
.btn-full{width:100%;display:block;text-align:center}
|
||
|
||
/* ── LAYOUT ── */
|
||
#app{display:none;flex:1;flex-direction:column}
|
||
#topbar{background:var(--surface);border-bottom:1px solid var(--border2);padding:10px 20px;display:flex;align-items:center;justify-content:space-between}
|
||
#topbar .logo{color:var(--cyan);font-size:1rem;letter-spacing:5px}
|
||
#topbar .sub{color:var(--dim);font-size:0.6rem;letter-spacing:3px;margin-left:12px}
|
||
#topbar .right{display:flex;align-items:center;gap:16px}
|
||
#topbar .user{color:var(--dim);font-size:0.65rem;letter-spacing:1px}
|
||
#main{display:flex;flex:1;overflow:hidden}
|
||
|
||
/* ── SIDEBAR ── */
|
||
#sidebar{width:180px;background:var(--surface);border-right:1px solid var(--border2);padding:16px 0;flex-shrink:0}
|
||
.nav-item{display:block;padding:10px 20px;color:var(--dim);font-size:0.7rem;letter-spacing:2px;cursor:pointer;border-left:2px solid transparent;transition:all .15s}
|
||
.nav-item:hover{color:var(--text);background:var(--panel)}
|
||
.nav-item.active{color:var(--cyan);border-left-color:var(--cyan);background:var(--panel)}
|
||
.nav-section{padding:16px 20px 6px;color:var(--border2);font-size:0.55rem;letter-spacing:3px}
|
||
|
||
/* ── CONTENT ── */
|
||
#content{flex:1;overflow-y:auto;padding:24px}
|
||
.tab{display:none}.tab.active{display:block}
|
||
.page-title{color:var(--cyan);font-size:0.9rem;letter-spacing:4px;margin-bottom:20px;border-bottom:1px solid var(--border);padding-bottom:10px;display:flex;align-items:center;justify-content:space-between}
|
||
.page-title .actions{display:flex;gap:8px}
|
||
|
||
/* ── STAT CARDS ── */
|
||
.stat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px;margin-bottom:24px}
|
||
.stat-card{background:var(--surface);border:1px solid var(--border);padding:16px}
|
||
.stat-card .lbl{color:var(--dim);font-size:0.6rem;letter-spacing:2px;margin-bottom:8px}
|
||
.stat-card .val{font-size:1.6rem;color:var(--cyan)}
|
||
.stat-card .sub{color:var(--dim);font-size:0.65rem;margin-top:4px}
|
||
.stat-card .ok{color:var(--green)}.stat-card .warn{color:var(--yellow)}.stat-card .danger{color:var(--red)}
|
||
|
||
/* ── TABLE ── */
|
||
.tbl-wrap{background:var(--surface);border:1px solid var(--border);overflow-x:auto}
|
||
table{width:100%;border-collapse:collapse}
|
||
th{color:var(--dim);font-size:0.6rem;letter-spacing:2px;padding:10px 12px;text-align:left;border-bottom:1px solid var(--border2);white-space:nowrap}
|
||
td{padding:9px 12px;border-bottom:1px solid var(--border);font-size:0.75rem;vertical-align:middle}
|
||
tr:last-child td{border-bottom:none}
|
||
tr:hover td{background:rgba(0,212,255,0.03)}
|
||
.badge{display:inline-block;padding:2px 8px;font-size:0.6rem;letter-spacing:1px}
|
||
.badge-green{background:rgba(57,255,20,0.1);color:var(--green);border:1px solid rgba(57,255,20,0.3)}
|
||
.badge-red{background:rgba(255,51,51,0.1);color:var(--red);border:1px solid rgba(255,51,51,0.3)}
|
||
.badge-yellow{background:rgba(255,204,0,0.1);color:var(--yellow);border:1px solid rgba(255,204,0,0.3)}
|
||
.badge-cyan{background:rgba(0,212,255,0.1);color:var(--cyan);border:1px solid rgba(0,212,255,0.3)}
|
||
.badge-dim{background:rgba(74,96,128,0.1);color:var(--dim);border:1px solid rgba(74,96,128,0.3)}
|
||
.actions-col{white-space:nowrap;display:flex;gap:4px;flex-wrap:wrap}
|
||
.dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:6px}
|
||
.dot-green{background:var(--green);box-shadow:0 0 6px var(--green)}
|
||
.dot-red{background:var(--red)}
|
||
.dot-dim{background:var(--dim)}
|
||
|
||
/* ── MODAL ── */
|
||
#modalBg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:1000;align-items:center;justify-content:center}
|
||
#modalBg.open{display:flex}
|
||
#modal{background:var(--surface);border:1px solid var(--border2);width:500px;max-width:95vw;max-height:90vh;display:flex;flex-direction:column}
|
||
#modalHead{padding:16px 20px;border-bottom:1px solid var(--border2);display:flex;justify-content:space-between;align-items:center}
|
||
#modalHead h3{color:var(--cyan);font-size:0.8rem;letter-spacing:3px}
|
||
#modalClose{background:none;border:none;color:var(--dim);cursor:pointer;font-size:1.2rem}
|
||
#modalClose:hover{color:var(--red)}
|
||
#modalBody{padding:20px;overflow-y:auto;flex:1}
|
||
#modalFoot{padding:12px 20px;border-top:1px solid var(--border2);display:flex;justify-content:flex-end;gap:8px}
|
||
.form-row{margin-bottom:14px}
|
||
.form-row label{display:block;color:var(--dim);font-size:0.6rem;letter-spacing:2px;margin-bottom:5px}
|
||
.form-row input,.form-row select,.form-row textarea{width:100%;background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:8px 10px;font-family:var(--font);font-size:12px;outline:none;resize:vertical}
|
||
.form-row input:focus,.form-row select:focus,.form-row textarea:focus{border-color:var(--cyan)}
|
||
.form-row textarea{min-height:80px}
|
||
|
||
/* ── FILTERS ── */
|
||
.filters{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap;align-items:center}
|
||
.filters .lbl{color:var(--dim);font-size:0.6rem;letter-spacing:2px;margin-right:4px}
|
||
.filter-btn{background:none;border:1px solid var(--border2);color:var(--dim);padding:4px 12px;font-family:var(--font);font-size:0.65rem;letter-spacing:1px;cursor:pointer}
|
||
.filter-btn.active,.filter-btn:hover{border-color:var(--cyan);color:var(--cyan)}
|
||
select.filter-sel{background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:4px 8px;font-family:var(--font);font-size:0.65rem;outline:none}
|
||
select.filter-sel:focus{border-color:var(--cyan)}
|
||
|
||
/* ── TOAST ── */
|
||
#toast{position:fixed;bottom:24px;right:24px;z-index:2000;display:flex;flex-direction:column;gap:8px}
|
||
.toast-msg{background:var(--panel);border:1px solid var(--border2);padding:10px 16px;font-size:0.7rem;letter-spacing:1px;animation:slideIn .2s ease;border-left:3px solid var(--cyan)}
|
||
.toast-msg.err{border-left-color:var(--red);color:var(--red)}
|
||
.toast-msg.ok{border-left-color:var(--green);color:var(--green)}
|
||
@keyframes slideIn{from{transform:translateX(40px);opacity:0}to{transform:translateX(0);opacity:1}}
|
||
|
||
/* ── MISC ── */
|
||
.empty{color:var(--dim);font-size:0.7rem;letter-spacing:1px;padding:30px;text-align:center}
|
||
.loading{color:var(--dim);font-size:0.7rem;letter-spacing:2px;padding:30px;text-align:center;animation:pulse 1s infinite}
|
||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
||
.meter{height:4px;background:var(--border);margin-top:4px;position:relative}
|
||
.meter-bar{height:100%;background:var(--cyan);transition:width .3s}
|
||
.meter-bar.warn{background:var(--yellow)}.meter-bar.danger{background:var(--red)}
|
||
.ts{color:var(--dim);font-size:0.65rem}
|
||
.monospace{font-family:var(--font)}
|
||
.trunc{max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- LOGIN -->
|
||
<div id="loginWrap">
|
||
<div id="loginBox">
|
||
<h1>JARVIS</h1>
|
||
<p>ADMIN PORTAL</p>
|
||
<div class="field"><label>USERNAME</label><input id="lu" type="text" autofocus></div>
|
||
<div class="field"><label>PASSWORD</label><input id="lp" type="password"></div>
|
||
<div id="loginErr"></div>
|
||
<button class="btn btn-full" onclick="doLogin()">AUTHENTICATE</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- APP -->
|
||
<div id="app">
|
||
<div id="topbar">
|
||
<div style="display:flex;align-items:center">
|
||
<span class="logo">JARVIS</span>
|
||
<span class="sub">ADMIN PORTAL</span>
|
||
</div>
|
||
<div class="right">
|
||
<span class="user" id="adminUser"></span>
|
||
<button class="btn btn-sm btn-red" onclick="doLogout()">LOGOUT</button>
|
||
</div>
|
||
</div>
|
||
<div id="main">
|
||
<div id="sidebar">
|
||
<div class="nav-section">OVERVIEW</div>
|
||
<div class="nav-item active" data-tab="dashboard" onclick="nav(this)">DASHBOARD</div>
|
||
<div class="nav-section">MANAGE</div>
|
||
<div class="nav-item" data-tab="agents" onclick="nav(this)">AGENTS</div>
|
||
<div class="nav-item" data-tab="network" onclick="nav(this)">NETWORK</div>
|
||
<div class="nav-item" data-tab="alerts" onclick="nav(this)">ALERTS</div>
|
||
<div class="nav-section">KNOWLEDGE</div>
|
||
<div class="nav-item" data-tab="facts" onclick="nav(this)">KB FACTS</div>
|
||
<div class="nav-item" data-tab="intents" onclick="nav(this)">KB INTENTS</div>
|
||
<div class="nav-section">LIVE</div>
|
||
<div class="nav-item" data-tab="ha" onclick="nav(this)">HOME ASSISTANT</div>
|
||
<div class="nav-item" data-tab="news" onclick="nav(this)">NEWS</div>
|
||
<div class="nav-item" data-tab="vms" onclick="nav(this)">PROXMOX VMs</div>
|
||
<div class="nav-section">INFO</div>
|
||
<div class="nav-item" data-tab="sites" onclick="nav(this)">SITES</div>
|
||
<div class="nav-item" data-tab="users" onclick="nav(this)">USERS</div>
|
||
</div>
|
||
<div id="content">
|
||
|
||
<!-- DASHBOARD -->
|
||
<div class="tab active" id="tab-dashboard">
|
||
<div class="page-title">DASHBOARD</div>
|
||
<div class="stat-grid" id="dash-cards"><div class="loading">LOADING...</div></div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px" id="dash-bottom"></div>
|
||
</div>
|
||
|
||
<!-- AGENTS -->
|
||
<div class="tab" id="tab-agents">
|
||
<div class="page-title">AGENTS
|
||
<div class="actions"><button class="btn btn-sm" onclick="loadAgents()">REFRESH</button></div>
|
||
</div>
|
||
<div class="tbl-wrap" id="agents-tbl"><div class="loading">LOADING...</div></div>
|
||
</div>
|
||
|
||
<!-- NETWORK -->
|
||
<div class="tab" id="tab-network">
|
||
<div class="page-title">NETWORK DEVICES
|
||
<div class="actions">
|
||
<button class="btn btn-sm btn-green" onclick="netModal()">+ ADD DEVICE</button>
|
||
<button class="btn btn-sm btn-yellow" id="scanBtn" onclick="scanNow()">SCAN NOW</button>
|
||
<button class="btn btn-sm" onclick="loadNetwork()">REFRESH</button>
|
||
</div>
|
||
</div>
|
||
<div class="filters" style="margin-bottom:12px">
|
||
<span class="lbl">FILTER:</span>
|
||
<button class="filter-btn active" id="nf-all" onclick="setNetFilter('all',this)">ALL</button>
|
||
<button class="filter-btn" id="nf-online" onclick="setNetFilter('online',this)">ONLINE</button>
|
||
<button class="filter-btn" id="nf-offline" onclick="setNetFilter('offline',this)">OFFLINE</button>
|
||
<button class="filter-btn" id="nf-named" onclick="setNetFilter('named',this)">NAMED</button>
|
||
<span class="lbl" id="net-count" style="color:var(--cyan)"></span>
|
||
</div>
|
||
<div class="tbl-wrap" id="network-tbl"><div class="loading">LOADING...</div></div>
|
||
</div>
|
||
|
||
<!-- ALERTS -->
|
||
<div class="tab" id="tab-alerts">
|
||
<div class="page-title">ALERTS
|
||
<div class="actions">
|
||
<button class="btn btn-sm btn-green" onclick="alertModal()">+ CREATE</button>
|
||
<button class="btn btn-sm btn-yellow" onclick="apiPost('alerts_resolve_all',{},()=>{toast('All resolved','ok');loadAlerts()})">RESOLVE ALL</button>
|
||
<button class="btn btn-sm btn-red" onclick="apiPost('alerts_purge_resolved',{},()=>{toast('Purged','ok');loadAlerts()})">PURGE RESOLVED</button>
|
||
<button class="btn btn-sm" onclick="loadAlerts()">REFRESH</button>
|
||
</div>
|
||
</div>
|
||
<div class="filters">
|
||
<span class="lbl">FILTER:</span>
|
||
<button class="filter-btn active" onclick="setAlertFilter('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>
|
||
|
||
<!-- HOME ASSISTANT -->
|
||
<div class="tab" id="tab-ha">
|
||
<div class="page-title">HOME ASSISTANT ENTITIES
|
||
<div class="actions"><button class="btn btn-sm" onclick="loadHA()">REFRESH</button></div>
|
||
</div>
|
||
<div class="filters">
|
||
<span class="lbl">DOMAIN:</span>
|
||
<select class="filter-sel" id="ha-domain" onchange="loadHA()"><option value="">ALL</option></select>
|
||
<input id="ha-search" placeholder="search name or entity_id..." style="background:#060a0e;border:1px solid var(--border2);color:var(--text);padding:4px 8px;font-family:var(--font);font-size:0.65rem;width:220px;outline:none" oninput="filterHATable()" onchange="filterHATable()">
|
||
<span class="lbl" id="ha-count" style="color:var(--cyan)"></span>
|
||
</div>
|
||
<div class="tbl-wrap" id="ha-tbl"><div class="loading">LOADING...</div></div>
|
||
</div>
|
||
|
||
<!-- NEWS -->
|
||
<div class="tab" id="tab-news">
|
||
<div class="page-title">NEWS MANAGEMENT
|
||
<div class="actions">
|
||
<button class="btn btn-sm btn-green" onclick="newsCustomModal()">+ ADD CUSTOM</button>
|
||
<button class="btn btn-sm" onclick="loadNews()">REFRESH</button>
|
||
</div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
|
||
<div>
|
||
<div style="color:var(--cyan);font-size:0.65rem;letter-spacing:2px;margin-bottom:10px;border-bottom:1px solid var(--border);padding-bottom:6px">PINNED / CUSTOM NEWS</div>
|
||
<div id="news-custom"><div class="loading">LOADING...</div></div>
|
||
</div>
|
||
<div>
|
||
<div style="color:var(--dim);font-size:0.65rem;letter-spacing:2px;margin-bottom:10px;border-bottom:1px solid var(--border);padding-bottom:6px">LIVE FEED (auto-refreshed)</div>
|
||
<div id="news-live"><div class="loading">LOADING...</div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- PROXMOX VMs -->
|
||
<div class="tab" id="tab-vms">
|
||
<div class="page-title">PROXMOX VMs
|
||
<div class="actions"><button class="btn btn-sm" onclick="loadVMs()">REFRESH</button></div>
|
||
</div>
|
||
<div class="tbl-wrap" id="vms-tbl"><div class="loading">LOADING...</div></div>
|
||
</div>
|
||
|
||
<!-- SITES -->
|
||
<div class="tab" id="tab-sites">
|
||
<div class="page-title">SITE HEALTH</div>
|
||
<div id="sites-content"><div class="loading">LOADING...</div></div>
|
||
</div>
|
||
|
||
<!-- USERS -->
|
||
<div class="tab" id="tab-users">
|
||
<div class="page-title">USERS</div>
|
||
<div class="tbl-wrap" id="users-tbl"><div class="loading">LOADING...</div></div>
|
||
</div>
|
||
|
||
</div><!-- /content -->
|
||
</div><!-- /main -->
|
||
</div><!-- /app -->
|
||
|
||
<!-- MODAL -->
|
||
<div id="modalBg">
|
||
<div id="modal">
|
||
<div id="modalHead"><h3 id="modalTitle">EDIT</h3><button id="modalClose" onclick="closeModal()">×</button></div>
|
||
<div id="modalBody"></div>
|
||
<div id="modalFoot"><button class="btn btn-sm" onclick="closeModal()">CANCEL</button><button class="btn btn-sm btn-green" id="modalSave" onclick="modalSave()">SAVE</button></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="toast"></div>
|
||
|
||
<script>
|
||
// ── UTILS ─────────────────────────────────────────────────────────────────────
|
||
let _alertFilter = '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) {
|
||
// Stop any existing network auto-refresh when leaving
|
||
if (_netAutoRefresh) { clearInterval(_netAutoRefresh); _netAutoRefresh = null; }
|
||
({
|
||
dashboard: loadDashboard,
|
||
agents: loadAgents,
|
||
network: ()=>{ loadNetwork(); _netAutoRefresh = setInterval(loadNetwork, 30000); },
|
||
alerts: loadAlerts,
|
||
facts: ()=>{ loadFactCategories(); loadFacts(); },
|
||
intents: loadIntents,
|
||
ha: loadHA,
|
||
news: loadNews,
|
||
vms: loadVMs,
|
||
sites: loadSites,
|
||
users: loadUsers,
|
||
})[tab]?.();
|
||
}
|
||
|
||
function initApp() { loadDashboard(); setInterval(loadDashboard, 15000); }
|
||
|
||
// ── DASHBOARD ─────────────────────────────────────────────────────────────────
|
||
async function loadDashboard() {
|
||
const d = await api('dashboard');
|
||
const s = d.sys;
|
||
const mp = s.mem_pct, mc = mp>80?'danger':mp>60?'warn':'';
|
||
const lc = s.load_1m>2?'danger':s.load_1m>1?'warn':'';
|
||
const dc = (parseInt(s.disk_pct)>88)?'danger':(parseInt(s.disk_pct)>75)?'warn':'';
|
||
|
||
document.getElementById('dash-cards').innerHTML = `
|
||
<div class="stat-card">
|
||
<div class="lbl">MEMORY</div>
|
||
<div class="val ${mc}">${mp}%</div>
|
||
<div class="sub">${s.mem_used_mb} / ${s.mem_total_mb} MB</div>
|
||
<div class="meter"><div class="meter-bar ${mc}" style="width:${mp}%"></div></div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="lbl">LOAD AVG</div>
|
||
<div class="val ${lc}">${s.load_1m}</div>
|
||
<div class="sub">1-minute average</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="lbl">DISK USAGE</div>
|
||
<div class="val ${dc}">${s.disk_pct||'—'}</div>
|
||
<div class="sub">root filesystem</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="lbl">UPTIME</div>
|
||
<div class="val" style="font-size:1rem;margin-top:4px">${fmtUp(s.uptime_s)}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="lbl">AGENTS</div>
|
||
<div class="val ${d.agents?.online>0?'':'danger'}">${d.agents?.online||0}<span style="font-size:1rem;color:var(--dim)">/${d.agents?.total||0}</span></div>
|
||
<div class="sub">online</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="lbl">ACTIVE ALERTS</div>
|
||
<div class="val ${d.alerts?.active>0?'danger':''}">${d.alerts?.active||0}<span style="font-size:1rem;color:var(--dim)">/${d.alerts?.total||0}</span></div>
|
||
<div class="sub">unresolved</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="lbl">NAMED DEVICES</div>
|
||
<div class="val">${d.devices?.total||0}</div>
|
||
<div class="sub">${d.devices?.online||0} online</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="lbl">KB FACTS</div>
|
||
<div class="val">${d.facts?.total||0}</div>
|
||
<div class="sub">${d.intents?.active||0}/${d.intents?.total||0} intents active</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ── AGENTS ────────────────────────────────────────────────────────────────────
|
||
async function loadAgents() {
|
||
document.getElementById('agents-tbl').innerHTML = '<div class="loading">LOADING...</div>';
|
||
const agents = await api('agents_list');
|
||
if (!agents.length) { document.getElementById('agents-tbl').innerHTML='<div class="empty">NO AGENTS REGISTERED</div>'; return; }
|
||
let rows = agents.map(a => {
|
||
const m = a.metrics;
|
||
const meterCell = m
|
||
? `<span style="font-size:0.65rem">${m.cpu_pct??'—'}% CPU · ${m.mem_pct??'—'}% RAM</span>`
|
||
: `<span style="color:var(--dim);font-size:0.65rem">—</span>`;
|
||
return `<tr>
|
||
<td><span class="dot ${a.status==='online'?'dot-green':'dot-red'}"></span>${esc(a.hostname)}</td>
|
||
<td>${statusBadge(a.status)}</td>
|
||
<td><span class="badge badge-cyan">${esc(a.agent_type).toUpperCase()}</span></td>
|
||
<td>${esc(a.ip_address||'—')}</td>
|
||
<td>${meterCell}</td>
|
||
<td class="ts">${ago(a.last_seen)}</td>
|
||
<td class="ts">${ts(a.created_at)}</td>
|
||
<td><div class="actions-col">
|
||
<button class="btn btn-xs btn-red" onclick="delAgent('${esc(a.agent_id)}','${esc(a.hostname)}')">DELETE</button>
|
||
</div></td>
|
||
</tr>`;
|
||
}).join('');
|
||
document.getElementById('agents-tbl').innerHTML = `<table>
|
||
<thead><tr><th>HOSTNAME</th><th>STATUS</th><th>TYPE</th><th>IP</th><th>METRICS</th><th>LAST SEEN</th><th>REGISTERED</th><th>ACTIONS</th></tr></thead>
|
||
<tbody>${rows}</tbody></table>`;
|
||
}
|
||
|
||
function delAgent(id, name) {
|
||
if (!confirm(`Delete agent "${name}"? This cannot be undone.`)) return;
|
||
apiPost('agents_delete', {agent_id:id}, ()=>{ toast('Agent deleted','ok'); loadAgents(); });
|
||
}
|
||
|
||
// ── NETWORK ───────────────────────────────────────────────────────────────────
|
||
let _netFilter = 'all';
|
||
let _allDevices = [];
|
||
let _netAutoRefresh = null;
|
||
|
||
function setNetFilter(f, el) {
|
||
_netFilter = f;
|
||
document.querySelectorAll('#tab-network .filter-btn').forEach(b=>b.classList.remove('active'));
|
||
el.classList.add('active');
|
||
renderNetwork();
|
||
}
|
||
|
||
async function loadNetwork() {
|
||
document.getElementById('network-tbl').innerHTML = '<div class="loading">LOADING...</div>';
|
||
_allDevices = await api('network_list');
|
||
renderNetwork();
|
||
}
|
||
|
||
function renderNetwork() {
|
||
let devs = _allDevices;
|
||
if (_netFilter === 'online') devs = devs.filter(d => d.status === 'online');
|
||
if (_netFilter === 'offline') devs = devs.filter(d => d.status === 'offline');
|
||
if (_netFilter === 'named') devs = devs.filter(d => d.alias);
|
||
|
||
const onlineCount = _allDevices.filter(d=>d.status==='online').length;
|
||
document.getElementById('net-count').textContent = `${onlineCount}/${_allDevices.length} ONLINE`;
|
||
|
||
if (!devs.length) { document.getElementById('network-tbl').innerHTML='<div class="empty">NO DEVICES MATCH FILTER</div>'; return; }
|
||
let rows = devs.map(d => {
|
||
const name = d.alias || d.hostname || d.ip;
|
||
const isNamed = !!d.alias;
|
||
const vendor = d.device_type || '—';
|
||
return `<tr>
|
||
<td>
|
||
<span class="dot ${d.status==='online'?'dot-green':d.status==='offline'?'dot-red':'dot-dim'}"></span>
|
||
<strong>${esc(name)}</strong>${isNamed?'':' <span style="color:var(--dim);font-size:0.6rem">(discovered)</span>'}
|
||
</td>
|
||
<td style="color:var(--cyan)">${esc(d.ip)}</td>
|
||
<td style="font-size:0.65rem;color:var(--dim)">${esc(d.mac||'—')}</td>
|
||
<td class="trunc ts" style="max-width:140px" title="${esc(vendor)}">${esc(vendor)}</td>
|
||
<td>${statusBadge(d.status)}</td>
|
||
<td class="ts">${ago(d.last_seen)}</td>
|
||
<td><div class="actions-col">
|
||
<button class="btn btn-xs" onclick="pingDev('${esc(d.ip)}',this)">PING</button>
|
||
<button class="btn btn-xs btn-yellow" onclick="netModal(${d.id},'${esc(d.ip)}','${esc(d.alias||'')}','${esc(d.device_type||'')}')">NAME</button>
|
||
<button class="btn btn-xs btn-red" onclick="delNet(${d.id},'${esc(name)}')">DEL</button>
|
||
</div></td>
|
||
</tr>`;
|
||
}).join('');
|
||
document.getElementById('network-tbl').innerHTML = `<table>
|
||
<thead><tr><th>NAME</th><th>IP</th><th>MAC</th><th>VENDOR / TYPE</th><th>STATUS</th><th>LAST SEEN</th><th>ACTIONS</th></tr></thead>
|
||
<tbody>${rows}</tbody></table>`;
|
||
}
|
||
|
||
async function scanNow() {
|
||
const btn = document.getElementById('scanBtn');
|
||
btn.textContent = 'QUEUING...'; btn.disabled = true;
|
||
const fd = new FormData(); fd.append('action','network_scan');
|
||
try {
|
||
const r = await fetch(location.href,{method:'POST',body:fd});
|
||
const d = await r.json();
|
||
if (d.ok && d.queued) {
|
||
toast('Scan queued — refreshing in 45s...','ok');
|
||
setTimeout(()=>{ loadNetwork(); }, 45000);
|
||
} else {
|
||
toast(d.note || 'Scan scheduled via cron','ok');
|
||
}
|
||
} catch(e){ toast('Request failed','err'); }
|
||
setTimeout(()=>{ btn.textContent='SCAN NOW'; btn.disabled=false; }, 5000);
|
||
}
|
||
|
||
function netModal(id=0, ip='', alias='', type='') {
|
||
openModal(id?'NAME / EDIT DEVICE':'ADD DEVICE', `
|
||
<div class="form-row"><label>IP ADDRESS</label><input id="f-ip" value="${esc(ip)}" placeholder="10.48.200.x"></div>
|
||
<div class="form-row"><label>NAME / ALIAS</label><input id="f-alias" value="${esc(alias)}" placeholder="My Device"></div>
|
||
<div class="form-row"><label>TYPE</label>
|
||
<select id="f-type">
|
||
${['device','server','router','switch','phone','camera','nas','printer','vm','workstation'].map(t=>`<option value="${t}" ${t===type?'selected':''}>${t.toUpperCase()}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<input type="hidden" id="f-id" value="${id}">
|
||
`, () => {
|
||
const data = {id: document.getElementById('f-id').value, ip: document.getElementById('f-ip').value, alias: document.getElementById('f-alias').value, device_type: document.getElementById('f-type').value};
|
||
apiPost('network_save', data, ()=>{ toast('Saved','ok'); closeModal(); loadNetwork(); });
|
||
});
|
||
}
|
||
|
||
async function pingDev(ip, btn) {
|
||
btn.textContent='…'; btn.disabled=true;
|
||
const fd=new FormData(); fd.append('action','network_ping'); fd.append('ip',ip);
|
||
try {
|
||
const r = await fetch(location.href,{method:'POST',body:fd});
|
||
const d = await r.json();
|
||
toast(d.alive ? `${ip} ONLINE (${d.latency_ms??'?'}ms)` : `${ip} OFFLINE`, d.alive?'ok':'err');
|
||
} catch(e){ toast('Ping failed','err'); }
|
||
btn.textContent='PING'; btn.disabled=false;
|
||
}
|
||
|
||
function delNet(id, name) {
|
||
if (!confirm(`Remove "${name}" from network devices?`)) return;
|
||
apiPost('network_delete', {id}, ()=>{ toast('Removed','ok'); loadNetwork(); });
|
||
}
|
||
|
||
// ── ALERTS ────────────────────────────────────────────────────────────────────
|
||
function setAlertFilter(f, el) {
|
||
_alertFilter = f;
|
||
document.querySelectorAll('.filter-btn').forEach(b=>b.classList.remove('active'));
|
||
el.classList.add('active');
|
||
loadAlerts();
|
||
}
|
||
|
||
async function loadAlerts() {
|
||
document.getElementById('alerts-tbl').innerHTML='<div class="loading">LOADING...</div>';
|
||
const alerts = await api('alerts_list', {filter:_alertFilter});
|
||
if (!alerts.length) { document.getElementById('alerts-tbl').innerHTML='<div class="empty">NO ALERTS</div>'; return; }
|
||
let rows = alerts.map(a => `<tr>
|
||
<td>${sevBadge(a.severity)}</td>
|
||
<td>${esc(a.alert_type)}</td>
|
||
<td class="trunc">${esc(a.title)}</td>
|
||
<td class="trunc ts">${esc(a.message||'—')}</td>
|
||
<td>${a.resolved ? '<span class="badge badge-dim">RESOLVED</span>' : '<span class="badge badge-red">ACTIVE</span>'}</td>
|
||
<td class="ts">${ts(a.created_at)}</td>
|
||
<td><div class="actions-col">
|
||
${!a.resolved?`<button class="btn btn-xs btn-green" onclick="apiPost('alerts_resolve',{id:${a.id}},()=>{toast('Resolved','ok');loadAlerts()})">RESOLVE</button>`:''}
|
||
<button class="btn btn-xs btn-yellow" onclick="alertModal(${a.id},'${esc(a.alert_type)}','${esc(a.title)}','${esc(a.message||'')}','${esc(a.severity)}')">EDIT</button>
|
||
<button class="btn btn-xs btn-red" onclick="apiPost('alerts_delete',{id:${a.id}},()=>{toast('Deleted','ok');loadAlerts()})">DEL</button>
|
||
</div></td>
|
||
</tr>`).join('');
|
||
document.getElementById('alerts-tbl').innerHTML = `<table>
|
||
<thead><tr><th>SEV</th><th>TYPE</th><th>TITLE</th><th>MESSAGE</th><th>STATUS</th><th>CREATED</th><th>ACTIONS</th></tr></thead>
|
||
<tbody>${rows}</tbody></table>`;
|
||
}
|
||
|
||
function alertModal(id=0, type='manual', title='', message='', severity='info') {
|
||
openModal(id?'EDIT ALERT':'CREATE ALERT', `
|
||
<div class="form-row"><label>TYPE</label><input id="a-type" value="${esc(type)}" placeholder="manual"></div>
|
||
<div class="form-row"><label>TITLE</label><input id="a-title" value="${esc(title)}"></div>
|
||
<div class="form-row"><label>MESSAGE</label><textarea id="a-msg">${esc(message)}</textarea></div>
|
||
<div class="form-row"><label>SEVERITY</label>
|
||
<select id="a-sev">
|
||
${['info','warning','critical'].map(s=>`<option value="${s}" ${s===severity?'selected':''}>${s.toUpperCase()}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<input type="hidden" id="a-id" value="${id}">
|
||
`, () => {
|
||
const data = {id: document.getElementById('a-id').value, alert_type: document.getElementById('a-type').value, title: document.getElementById('a-title').value, message: document.getElementById('a-msg').value, severity: document.getElementById('a-sev').value};
|
||
apiPost('alerts_save', data, ()=>{ toast('Saved','ok'); closeModal(); loadAlerts(); });
|
||
});
|
||
}
|
||
|
||
// ── KB FACTS ─────────────────────────────────────────────────────────────────
|
||
async function loadFactCategories() {
|
||
const cats = await api('facts_categories');
|
||
const sel = document.getElementById('factCat');
|
||
sel.innerHTML = '<option value="__all__">ALL CATEGORIES</option>' +
|
||
cats.map(c=>`<option value="${esc(c.category)}">${esc(c.category)} (${c.cnt})</option>`).join('');
|
||
}
|
||
|
||
async function loadFacts() {
|
||
document.getElementById('facts-tbl').innerHTML='<div class="loading">LOADING...</div>';
|
||
const cat = document.getElementById('factCat')?.value || '__all__';
|
||
const facts = await api('facts_list', {category: cat});
|
||
if (!facts.length) { document.getElementById('facts-tbl').innerHTML='<div class="empty">NO FACTS</div>'; return; }
|
||
let rows = facts.map(f => `<tr>
|
||
<td><span class="badge badge-cyan">${esc(f.category)}</span></td>
|
||
<td>${esc(f.fact_key)}</td>
|
||
<td class="trunc" style="max-width:320px" title="${esc(f.fact_value)}">${esc(f.fact_value)}</td>
|
||
<td class="ts">${ago(f.updated_at)}</td>
|
||
<td><div class="actions-col">
|
||
<button class="btn btn-xs btn-yellow" onclick='factModal(${f.id},"${esc(f.category)}","${esc(f.fact_key)}",${JSON.stringify(f.fact_value)})'>EDIT</button>
|
||
<button class="btn btn-xs btn-red" onclick="apiPost('facts_delete',{id:${f.id}},()=>{toast('Deleted','ok');loadFacts()})">DEL</button>
|
||
</div></td>
|
||
</tr>`).join('');
|
||
document.getElementById('facts-tbl').innerHTML = `<table>
|
||
<thead><tr><th>CATEGORY</th><th>KEY</th><th>VALUE</th><th>UPDATED</th><th>ACTIONS</th></tr></thead>
|
||
<tbody>${rows}</tbody></table>`;
|
||
}
|
||
|
||
function factModal(id=0, category='', key='', value='') {
|
||
openModal(id?'EDIT FACT':'ADD FACT', `
|
||
<div class="form-row"><label>CATEGORY</label><input id="fc-cat" value="${esc(category)}" placeholder="system"></div>
|
||
<div class="form-row"><label>KEY</label><input id="fc-key" value="${esc(key)}" placeholder="fact_name"></div>
|
||
<div class="form-row"><label>VALUE</label><textarea id="fc-val">${esc(value)}</textarea></div>
|
||
<input type="hidden" id="fc-id" value="${id}">
|
||
`, () => {
|
||
const data = {id: document.getElementById('fc-id').value, category: document.getElementById('fc-cat').value, fact_key: document.getElementById('fc-key').value, fact_value: document.getElementById('fc-val').value};
|
||
apiPost('facts_save', data, ()=>{ toast('Saved','ok'); closeModal(); loadFacts(); });
|
||
});
|
||
}
|
||
|
||
// ── KB INTENTS ────────────────────────────────────────────────────────────────
|
||
async function loadIntents() {
|
||
document.getElementById('intents-tbl').innerHTML='<div class="loading">LOADING...</div>';
|
||
const intents = await api('intents_list');
|
||
if (!intents.length) { document.getElementById('intents-tbl').innerHTML='<div class="empty">NO INTENTS</div>'; return; }
|
||
let rows = intents.map(i => `<tr style="${i.active?'':'opacity:0.45'}">
|
||
<td>${esc(i.intent_name)}</td>
|
||
<td class="trunc" style="max-width:240px" title="${esc(i.pattern)}"><code style="font-size:0.65rem;color:var(--yellow)">${esc(i.pattern)}</code></td>
|
||
<td class="trunc" style="max-width:200px" title="${esc(i.response_template||'')}"><span style="font-size:0.7rem">${esc(i.response_template||'—')}</span></td>
|
||
<td><span class="badge badge-dim">${esc(i.action_type)}</span></td>
|
||
<td style="text-align:center">${i.priority}</td>
|
||
<td>${i.active?'<span class="badge badge-green">ON</span>':'<span class="badge badge-dim">OFF</span>'}</td>
|
||
<td><div class="actions-col">
|
||
<button class="btn btn-xs" onclick="apiPost('intents_toggle',{id:${i.id}},()=>{toast('Toggled','ok');loadIntents()})">${i.active?'DISABLE':'ENABLE'}</button>
|
||
<button class="btn btn-xs btn-yellow" onclick='intentModal(${i.id},"${esc(i.intent_name)}","${esc(i.pattern)}",${JSON.stringify(i.response_template||"")},"${esc(i.action_type)}",${i.priority},${i.active})'>EDIT</button>
|
||
<button class="btn btn-xs btn-red" onclick="apiPost('intents_delete',{id:${i.id}},()=>{toast('Deleted','ok');loadIntents()})">DEL</button>
|
||
</div></td>
|
||
</tr>`).join('');
|
||
document.getElementById('intents-tbl').innerHTML = `<table>
|
||
<thead><tr><th>NAME</th><th>PATTERN</th><th>RESPONSE</th><th>TYPE</th><th style="text-align:center">PRI</th><th>STATUS</th><th>ACTIONS</th></tr></thead>
|
||
<tbody>${rows}</tbody></table>`;
|
||
}
|
||
|
||
function intentModal(id=0, name='', pattern='', response='', type='response', priority=5, active=1) {
|
||
openModal(id?'EDIT INTENT':'ADD INTENT', `
|
||
<div class="form-row"><label>INTENT NAME</label><input id="i-name" value="${esc(name)}" placeholder="my_intent"></div>
|
||
<div class="form-row"><label>REGEX PATTERN</label><input id="i-pat" value="${esc(pattern)}" placeholder="(?i)(keyword).*(match)"></div>
|
||
<div class="form-row"><label>RESPONSE TEMPLATE</label><textarea id="i-resp">${esc(response)}</textarea></div>
|
||
<div class="form-row"><label>ACTION TYPE</label>
|
||
<select id="i-type">${['response','action','query'].map(t=>`<option value="${t}" ${t===type?'selected':''}>${t.toUpperCase()}</option>`).join('')}</select>
|
||
</div>
|
||
<div class="form-row"><label>PRIORITY (1-10)</label><input id="i-pri" type="number" min="1" max="10" value="${priority}"></div>
|
||
<div class="form-row"><label>ACTIVE</label>
|
||
<select id="i-act"><option value="1" ${active?'selected':''}>YES</option><option value="0" ${!active?'selected':''}>NO</option></select>
|
||
</div>
|
||
<input type="hidden" id="i-id" value="${id}">
|
||
`, () => {
|
||
const data = {id: document.getElementById('i-id').value, intent_name: document.getElementById('i-name').value, pattern: document.getElementById('i-pat').value, response_template: document.getElementById('i-resp').value, action_type: document.getElementById('i-type').value, priority: document.getElementById('i-pri').value, active: document.getElementById('i-act').value};
|
||
apiPost('intents_save', data, ()=>{ toast('Saved','ok'); closeModal(); loadIntents(); });
|
||
});
|
||
}
|
||
|
||
// ── SITES ─────────────────────────────────────────────────────────────────────
|
||
async function loadSites() {
|
||
document.getElementById('sites-content').innerHTML='<div class="loading">LOADING...</div>';
|
||
const sites = await api('sites_list');
|
||
if (!sites.length) { document.getElementById('sites-content').innerHTML='<div class="empty">NO SITE DATA</div>'; return; }
|
||
const labels = {
|
||
'jarvis':'jarvis.orbishosting.com','tomsjavajive':'tomsjavajive.com',
|
||
'epictravelexp':'epictravelexpeditions.com','parkersling':'parkerslingshot',
|
||
'orbishosting':'orbishosting.com','orbisportal':'orbis.orbishosting.com','tomtomgames':'tomtomgames.com'
|
||
};
|
||
let cards = sites.map(s => {
|
||
const up = s.fact_value==='up';
|
||
return `<div class="stat-card" style="border-left:3px solid ${up?'var(--green)':'var(--red)'}">
|
||
<div class="lbl">${esc(labels[s.fact_key]||s.fact_key)}</div>
|
||
<div class="val ${up?'ok':'danger'}" style="font-size:1.2rem">${up?'ONLINE':'OFFLINE'}</div>
|
||
<div class="sub">checked ${ago(s.updated_at)}</div>
|
||
</div>`;
|
||
}).join('');
|
||
document.getElementById('sites-content').innerHTML = `<div class="stat-grid">${cards}</div>`;
|
||
}
|
||
|
||
// ── USERS ─────────────────────────────────────────────────────────────────────
|
||
async function loadUsers() {
|
||
document.getElementById('users-tbl').innerHTML='<div class="loading">LOADING...</div>';
|
||
const users = await api('users_list');
|
||
let rows = users.map(u => `<tr>
|
||
<td>${esc(u.username)}</td>
|
||
<td>${esc(u.display_name||'—')}</td>
|
||
<td class="ts">${ago(u.last_seen)}</td>
|
||
<td class="ts">${ts(u.created_at)}</td>
|
||
<td><button class="btn btn-xs btn-yellow" onclick="userModal(${u.id},'${esc(u.display_name||'')}')">EDIT</button></td>
|
||
</tr>`).join('');
|
||
document.getElementById('users-tbl').innerHTML = `<table>
|
||
<thead><tr><th>USERNAME</th><th>DISPLAY NAME</th><th>LAST SEEN</th><th>CREATED</th><th>ACTIONS</th></tr></thead>
|
||
<tbody>${rows}</tbody></table>`;
|
||
}
|
||
|
||
function userModal(id, display) {
|
||
openModal('EDIT USER', `
|
||
<div class="form-row"><label>DISPLAY NAME</label><input id="u-dn" value="${esc(display)}"></div>
|
||
<div class="form-row"><label>NEW PASSWORD (leave blank to keep)</label><input id="u-pw" type="password" placeholder="••••••••"></div>
|
||
<input type="hidden" id="u-id" value="${id}">
|
||
`, () => {
|
||
const data = {id: document.getElementById('u-id').value, display_name: document.getElementById('u-dn').value, password: document.getElementById('u-pw').value};
|
||
apiPost('users_save', data, ()=>{ toast('Saved','ok'); closeModal(); loadUsers(); });
|
||
});
|
||
}
|
||
|
||
// ── MODAL ─────────────────────────────────────────────────────────────────────
|
||
function openModal(title, body, saveCb) {
|
||
document.getElementById('modalTitle').textContent = title;
|
||
document.getElementById('modalBody').innerHTML = body;
|
||
_modalCb = saveCb;
|
||
document.getElementById('modalBg').classList.add('open');
|
||
const first = document.querySelector('#modalBody input, #modalBody textarea, #modalBody select');
|
||
if (first) setTimeout(()=>first.focus(), 50);
|
||
}
|
||
|
||
function closeModal() { document.getElementById('modalBg').classList.remove('open'); _modalCb=null; }
|
||
function modalSave() { if (_modalCb) _modalCb(); }
|
||
|
||
document.getElementById('modalBg').addEventListener('click', e => { if (e.target===document.getElementById('modalBg')) closeModal(); });
|
||
document.addEventListener('keydown', e => { if (e.key==='Escape') closeModal(); });
|
||
document.addEventListener('keydown', e => { if (e.key==='Enter' && e.ctrlKey && document.getElementById('modalBg').classList.contains('open')) modalSave(); });
|
||
|
||
// ── HOME ASSISTANT ────────────────────────────────────────────────────────────
|
||
let _haEntities = [];
|
||
|
||
async function loadHA() {
|
||
document.getElementById('ha-tbl').innerHTML = '<div class="loading">LOADING...</div>';
|
||
const domain = document.getElementById('ha-domain')?.value || '';
|
||
const data = await api('ha_list', {domain});
|
||
_haEntities = data.entities || [];
|
||
// Populate domain filter
|
||
const sel = document.getElementById('ha-domain');
|
||
const cur = sel.value;
|
||
sel.innerHTML = '<option value="">ALL DOMAINS</option>' + (data.domains||[]).map(d=>`<option value="${esc(d)}" ${d===cur?'selected':''}>${esc(d).toUpperCase()} </option>`).join('');
|
||
if (cur) sel.value = cur;
|
||
const age = data.ts ? Math.floor((Date.now()/1000)-data.ts) : null;
|
||
document.getElementById('ha-count').textContent = `${_haEntities.length} ENTITIES${age!=null?' · CACHE '+age+'s AGO':''}`;
|
||
renderHATable(_haEntities);
|
||
}
|
||
|
||
function filterHATable() {
|
||
const q = document.getElementById('ha-search')?.value.toLowerCase() || '';
|
||
renderHATable(q ? _haEntities.filter(e => (e.name||'').toLowerCase().includes(q)||(e.entity_id||'').toLowerCase().includes(q)) : _haEntities);
|
||
}
|
||
|
||
function renderHATable(entities) {
|
||
if (!entities.length) { document.getElementById('ha-tbl').innerHTML='<div class="empty">NO ENTITIES</div>'; return; }
|
||
const domainColors = {light:'#ffcc00',switch:'#00d4ff',binary_sensor:'#39ff14',sensor:'#9b9bff',media_player:'#ff8800',alarm_control_panel:'#ff3333',camera:'#888'};
|
||
let rows = entities.map(e => {
|
||
const on = ['on','home','open','playing','mowing','active'].includes(e.state);
|
||
const dc = domainColors[e.domain] || 'var(--dim)';
|
||
const toggleable = ['light','switch','input_boolean','fan'].includes(e.domain);
|
||
return `<tr>
|
||
<td><span style="color:${dc};font-size:0.6rem">${esc(e.domain)}</span></td>
|
||
<td>${esc(e.name||e.entity_id)}</td>
|
||
<td style="font-size:0.65rem;color:var(--dim)">${esc(e.entity_id)}</td>
|
||
<td><span class="badge ${on?'badge-green':'badge-dim'}">${esc(e.state)}</span></td>
|
||
<td>${toggleable?`<button class="btn btn-xs" onclick="haToggle('${esc(e.entity_id)}','${esc(e.state)}',this)">${on?'TURN OFF':'TURN ON'}</button>`:''}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
document.getElementById('ha-tbl').innerHTML = `<table>
|
||
<thead><tr><th>DOMAIN</th><th>NAME</th><th>ENTITY ID</th><th>STATE</th><th>ACTION</th></tr></thead>
|
||
<tbody>${rows}</tbody></table>`;
|
||
}
|
||
|
||
async function haToggle(eid, state, btn) {
|
||
btn.disabled=true; btn.textContent='...';
|
||
const fd=new FormData(); fd.append('action','ha_toggle'); fd.append('entity_id',eid); fd.append('state',state);
|
||
try {
|
||
const r=await fetch(location.href,{method:'POST',body:fd});
|
||
const d=await r.json();
|
||
if(d.ok) { toast('Toggled '+eid,'ok'); setTimeout(loadHA,1500); }
|
||
else toast('Toggle failed','err');
|
||
} catch(e){ toast('Failed','err'); }
|
||
btn.disabled=false;
|
||
}
|
||
|
||
// ── NEWS ──────────────────────────────────────────────────────────────────────
|
||
async function loadNews() {
|
||
document.getElementById('news-custom').innerHTML='<div class="loading">LOADING...</div>';
|
||
document.getElementById('news-live').innerHTML='<div class="loading">LOADING...</div>';
|
||
const data = await api('news_list');
|
||
|
||
// Custom entries
|
||
const custom = data.custom||[];
|
||
if (!custom.length) {
|
||
document.getElementById('news-custom').innerHTML='<div class="empty">NO CUSTOM ENTRIES</div>';
|
||
} else {
|
||
document.getElementById('news-custom').innerHTML = custom.map(c=>`
|
||
<div style="background:var(--surface);border:1px solid var(--border);padding:10px 12px;margin-bottom:8px;display:flex;align-items:center;gap:8px">
|
||
<div style="flex:1">
|
||
<div style="font-size:0.75rem">${esc(c.title)}</div>
|
||
${c.url?`<div style="font-size:0.6rem;color:var(--dim)">${esc(c.url)}</div>`:''}
|
||
</div>
|
||
<button class="btn btn-xs btn-yellow" onclick='newsCustomModal(${c.id},"${esc(c.title)}","${esc(c.url||"")}")'>EDIT</button>
|
||
<button class="btn btn-xs btn-red" onclick="apiPost('news_custom_delete',{id:${c.id}},()=>{toast('Deleted','ok');loadNews()})">DEL</button>
|
||
</div>`).join('');
|
||
}
|
||
|
||
// Live feed
|
||
const cats = data.news?.categories || {};
|
||
const all = Object.values(cats).flat().slice(0,30);
|
||
if (!all.length) {
|
||
document.getElementById('news-live').innerHTML='<div class="empty">NO FEED DATA</div>';
|
||
} else {
|
||
document.getElementById('news-live').innerHTML = all.map(n=>`
|
||
<div style="border-bottom:1px solid var(--border);padding:8px 0;font-size:0.72rem">
|
||
<div>${esc(n.title||'')}</div>
|
||
<div style="color:var(--dim);font-size:0.6rem">${esc(n.source||'')}${n.published?' · '+n.published:''}</div>
|
||
</div>`).join('');
|
||
}
|
||
}
|
||
|
||
function newsCustomModal(id=0, title='', url='') {
|
||
openModal(id?'EDIT CUSTOM NEWS':'ADD CUSTOM NEWS', `
|
||
<div class="form-row"><label>HEADLINE / TITLE</label><input id="nc-t" value="${esc(title)}"></div>
|
||
<div class="form-row"><label>URL (optional)</label><input id="nc-u" value="${esc(url)}" placeholder="https://..."></div>
|
||
<input type="hidden" id="nc-id" value="${id}">
|
||
`, () => {
|
||
apiPost('news_custom_save',{id:document.getElementById('nc-id').value,title:document.getElementById('nc-t').value,url:document.getElementById('nc-u').value},
|
||
()=>{ toast('Saved','ok'); closeModal(); loadNews(); });
|
||
});
|
||
}
|
||
|
||
// ── PROXMOX VMs ───────────────────────────────────────────────────────────────
|
||
async function loadVMs() {
|
||
document.getElementById('vms-tbl').innerHTML='<div class="loading">LOADING...</div>';
|
||
const data = await api('vms_list');
|
||
const vms = data.vms||[];
|
||
if (!vms.length) { document.getElementById('vms-tbl').innerHTML='<div class="empty">NO VM DATA (Proxmox cache may be empty)</div>'; return; }
|
||
const ns = data.node_status||{};
|
||
let rows = vms.map(v => {
|
||
const run = v.status==='running';
|
||
const cpu = v.cpu_pct!=null?Math.round(v.cpu_pct)+'%':'—';
|
||
const mem = v.mem_pct!=null?Math.round(v.mem_pct)+'%':'—';
|
||
return `<tr>
|
||
<td>${v.vmid||'—'}</td>
|
||
<td><strong>${esc(v.name||'—')}</strong></td>
|
||
<td><span class="badge badge-dim">${esc(v.type||'vm').toUpperCase()}</span></td>
|
||
<td>${run?'<span class="badge badge-green">RUNNING</span>':'<span class="badge badge-red">'+esc(v.status).toUpperCase()+'</span>'}</td>
|
||
<td>${cpu}</td><td>${mem}</td>
|
||
<td class="ts">${v.uptime_human||'—'}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
document.getElementById('vms-tbl').innerHTML = `
|
||
${ns.cpu_pct!=null?`<div style="font-size:0.65rem;color:var(--dim);padding:8px 12px">PVE NODE — CPU: ${Math.round(ns.cpu_pct)}% · RAM: ${Math.round(ns.mem_pct||0)}% · UPTIME: ${ns.uptime||'—'}</div>`:''}
|
||
<table><thead><tr><th>VMID</th><th>NAME</th><th>TYPE</th><th>STATUS</th><th>CPU</th><th>MEM</th><th>UPTIME</th></tr></thead>
|
||
<tbody>${rows}</tbody></table>`;
|
||
}
|
||
|
||
// ── AUTO-LOGIN CHECK (PHP session) ───────────────────────────────────────────
|
||
<?php if (loggedIn()): ?>
|
||
document.getElementById('loginWrap').style.display='none';
|
||
document.getElementById('app').style.display='flex';
|
||
document.getElementById('adminUser').textContent = '<?= htmlspecialchars($_SESSION['admin_name'] ?? $_SESSION['admin_user']) ?>'.toUpperCase();
|
||
initApp();
|
||
<?php endif; ?>
|
||
</script>
|
||
</body>
|
||
</html>
|