$msg]); } function self_upsert_device(array $d): void { JarvisDB::execute( 'INSERT INTO network_devices (ip,mac,hostname,status,last_seen) VALUES (?,?,?,"online",NOW()) ON DUPLICATE KEY UPDATE mac=VALUES(mac), hostname=COALESCE(VALUES(hostname),hostname), status="online", last_seen=NOW()', [$d['ip'], $d['mac'], $d['hostname'] ?? $d['vendor']] ); if (!empty($d['vendor'])) { JarvisDB::execute('UPDATE network_devices SET device_type=? WHERE ip=? AND (device_type IS NULL OR device_type="")', [$d['vendor'], $d['ip']]); } } // ── BACKEND API ─────────────────────────────────────────────────────────────── $action = $_GET['action'] ?? $_POST['action'] ?? ''; if ($action) { // Login doesn't require session if ($action === 'login') { $u = trim($_POST['username'] ?? ''); $p = $_POST['password'] ?? ''; $row = JarvisDB::single('SELECT * FROM users WHERE username = ?', [$u]); if ($row && password_verify($p, $row['password_hash'])) { $_SESSION['admin_user'] = $row['username']; $_SESSION['admin_name'] = $row['display_name']; j(['ok' => true, 'name' => $row['display_name']]); } bad('Invalid credentials', 401); } if ($action === 'logout') { session_destroy(); j(['ok' => true]); } if (!loggedIn()) bad('Not authenticated', 401); switch ($action) { // ── DASHBOARD ───────────────────────────────────────────────────────── case 'dashboard': $mi = []; foreach (file('/proc/meminfo') as $l) { [$k,$v] = explode(':', $l, 2) + [null,null]; if ($k) $mi[trim($k)] = (int)trim($v); } $mt = $mi['MemTotal'] ?? 0; $mf = $mi['MemAvailable'] ?? 0; $up = (int)explode(' ', file_get_contents('/proc/uptime'))[0]; $la = explode(' ', file_get_contents('/proc/loadavg')); $disk = trim(shell_exec("df / | tail -1 | awk '{print $5}'") ?? ''); j([ 'sys' => [ 'mem_pct' => $mt > 0 ? round(($mt-$mf)/$mt*100,1) : 0, 'mem_used_mb' => round(($mt-$mf)/1024), 'mem_total_mb' => round($mt/1024), 'uptime_s' => $up, 'load_1m' => (float)$la[0], 'disk_pct' => $disk, ], 'agents' => JarvisDB::single('SELECT COUNT(*) total, SUM(status="online") online FROM registered_agents'), 'alerts' => JarvisDB::single('SELECT COUNT(*) total, SUM(resolved=0) active FROM alerts'), 'devices' => JarvisDB::single('SELECT COUNT(*) total, SUM(status="online") online FROM network_devices WHERE alias IS NOT NULL'), 'facts' => JarvisDB::single('SELECT COUNT(*) total FROM kb_facts'), 'intents' => JarvisDB::single('SELECT COUNT(*) total, SUM(active=1) active FROM kb_intents'), ]); // ── AGENTS ─────────────────────────────────────────────────────────── case 'agents_list': $agents = JarvisDB::query('SELECT agent_id, hostname, agent_type, ip_address, status, last_seen, created_at FROM registered_agents ORDER BY status="online" DESC, hostname'); $metrics = JarvisDB::query('SELECT agent_id, cpu_pct, mem_pct, disk_pct FROM agent_metrics WHERE recorded_at > DATE_SUB(NOW(), INTERVAL 5 MINUTE) GROUP BY agent_id'); $mm = array_column($metrics, null, 'agent_id'); foreach ($agents as &$a) $a['metrics'] = $mm[$a['agent_id']] ?? null; j($agents); case 'agents_delete': $id = $_POST['agent_id'] ?? ''; if (!$id) bad('Missing agent_id'); JarvisDB::execute('DELETE FROM registered_agents WHERE agent_id=?', [$id]); JarvisDB::execute('DELETE FROM agent_metrics WHERE agent_id=?', [$id]); JarvisDB::execute('DELETE FROM agent_commands WHERE agent_id=?', [$id]); j(['ok' => true]); // ── NETWORK ────────────────────────────────────────────────────────── case 'network_list': j(JarvisDB::query('SELECT id,ip,mac,hostname,alias,device_type,status,last_seen FROM network_devices ORDER BY status="online" DESC, COALESCE(alias,hostname,ip)')); case 'network_scan': // Queue shell command to PVE1 agent — it runs jarvis-netscan.sh and pushes results back $pve1 = JarvisDB::single('SELECT agent_id FROM registered_agents WHERE ip_address="10.48.200.90" AND status="online" LIMIT 1'); if (!$pve1) { $pve1 = JarvisDB::single('SELECT agent_id FROM registered_agents WHERE hostname LIKE "%pve%" AND status="online" LIMIT 1'); } if ($pve1) { JarvisDB::execute( 'INSERT INTO agent_commands (agent_id, command_type, command_data, status) VALUES (?,?,?,?)', [$pve1['agent_id'], 'shell', json_encode(['command'=>'/usr/local/bin/jarvis-netscan.sh','allowed'=>true]), 'pending'] ); j(['ok' => true, 'queued' => true, 'note' => 'Scan command sent to PVE1 agent — results in ~40 seconds']); } else { j(['ok' => false, 'note' => 'PVE1 agent offline — scan will run automatically via cron in < 3 minutes']); } case 'network_save': $id = (int)($_POST['id'] ?? 0); $ip = trim($_POST['ip'] ?? ''); $alias = trim($_POST['alias'] ?? ''); $type = trim($_POST['device_type'] ?? 'device'); if (!$ip || !$alias) bad('IP and alias required'); if ($id) { JarvisDB::execute('UPDATE network_devices SET ip=?,alias=?,device_type=? WHERE id=?', [$ip,$alias,$type,$id]); } else { JarvisDB::execute('INSERT INTO network_devices (ip,alias,device_type,status) VALUES (?,?,?,"unknown") ON DUPLICATE KEY UPDATE alias=?,device_type=?', [$ip,$alias,$type,$alias,$type]); } j(['ok' => true]); case 'network_delete': $id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id'); JarvisDB::execute('DELETE FROM network_devices WHERE id=?', [$id]); j(['ok' => true]); case 'network_ping': $ip = trim($_POST['ip'] ?? ''); if (!$ip) bad('Missing IP'); $out = shell_exec('ping -c 2 -W 2 '.escapeshellarg($ip).' 2>/dev/null'); $alive = $out && (strpos($out,'2 received')!==false || strpos($out,'1 received')!==false); $lat = null; if ($alive && preg_match('/time=([\d.]+)/', $out, $m)) $lat = (float)$m[1]; j(['alive'=>$alive,'latency_ms'=>$lat]); // ── ALERTS ─────────────────────────────────────────────────────────── case 'alerts_list': $f = $_GET['filter'] ?? 'all'; $w = $f === 'active' ? 'WHERE resolved=0' : ($f === 'resolved' ? 'WHERE resolved=1' : ''); j(JarvisDB::query("SELECT id,alert_type,title,message,severity,resolved,created_at,resolved_at,source_key FROM alerts $w ORDER BY created_at DESC LIMIT 300")); case 'alerts_resolve': $id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id'); JarvisDB::execute('UPDATE alerts SET resolved=1,resolved_at=NOW() WHERE id=?', [$id]); j(['ok' => true]); case 'alerts_resolve_all': JarvisDB::execute('UPDATE alerts SET resolved=1,resolved_at=NOW() WHERE resolved=0'); j(['ok' => true]); case 'alerts_delete': $id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id'); JarvisDB::execute('DELETE FROM alerts WHERE id=?', [$id]); j(['ok' => true]); case 'alerts_purge_resolved': JarvisDB::execute('DELETE FROM alerts WHERE resolved=1'); j(['ok' => true]); case 'alerts_save': $id = (int)($_POST['id'] ?? 0); $t = trim($_POST['title'] ?? ''); if (!$t) bad('Title required'); $typ = trim($_POST['alert_type'] ?? 'manual'); $msg = trim($_POST['message'] ?? ''); $sev = trim($_POST['severity'] ?? 'info'); if ($id) { JarvisDB::execute('UPDATE alerts SET alert_type=?,title=?,message=?,severity=? WHERE id=?', [$typ,$t,$msg,$sev,$id]); } else { JarvisDB::execute('INSERT INTO alerts (alert_type,title,message,severity,resolved) VALUES (?,?,?,?,0)', [$typ,$t,$msg,$sev]); } j(['ok' => true]); // ── KB FACTS ───────────────────────────────────────────────────────── case 'facts_categories': j(JarvisDB::query('SELECT category, COUNT(*) cnt FROM kb_facts GROUP BY category ORDER BY cnt DESC')); case 'facts_list': $cat = $_GET['category'] ?? ''; if ($cat === '__all__') { j(JarvisDB::query('SELECT id,category,fact_key,fact_value,host,updated_at FROM kb_facts ORDER BY category,fact_key LIMIT 1000')); } j(JarvisDB::query('SELECT id,category,fact_key,fact_value,host,updated_at FROM kb_facts WHERE category=? ORDER BY fact_key', [$cat])); case 'facts_save': $id = (int)($_POST['id'] ?? 0); $cat = trim($_POST['category'] ?? ''); $key = trim($_POST['fact_key'] ?? ''); $val = trim($_POST['fact_value'] ?? ''); if (!$cat||!$key) bad('Category and key required'); if ($id) { JarvisDB::execute('UPDATE kb_facts SET category=?,fact_key=?,fact_value=? WHERE id=?', [$cat,$key,$val,$id]); } else { JarvisDB::execute('INSERT INTO kb_facts (category,fact_key,fact_value) VALUES (?,?,?)', [$cat,$key,$val]); } j(['ok' => true]); case 'facts_delete': $id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id'); JarvisDB::execute('DELETE FROM kb_facts WHERE id=?', [$id]); j(['ok' => true]); // ── KB INTENTS ─────────────────────────────────────────────────────── case 'intents_list': j(JarvisDB::query('SELECT id,intent_name,pattern,response_template,action_type,priority,active FROM kb_intents ORDER BY priority DESC,intent_name')); case 'intents_save': $id = (int)($_POST['id'] ?? 0); $name = trim($_POST['intent_name'] ?? ''); $pat = trim($_POST['pattern'] ?? ''); $resp = trim($_POST['response_template'] ?? ''); $typ = trim($_POST['action_type'] ?? 'response'); $pri = (int)($_POST['priority'] ?? 5); $act = (int)($_POST['active'] ?? 1); if (!$name||!$pat) bad('Name and pattern required'); if ($id) { JarvisDB::execute('UPDATE kb_intents SET intent_name=?,pattern=?,response_template=?,action_type=?,priority=?,active=? WHERE id=?', [$name,$pat,$resp,$typ,$pri,$act,$id]); } else { JarvisDB::execute('INSERT INTO kb_intents (intent_name,pattern,response_template,action_type,priority,active) VALUES (?,?,?,?,?,?)', [$name,$pat,$resp,$typ,$pri,$act]); } j(['ok' => true]); case 'intents_delete': $id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id'); JarvisDB::execute('DELETE FROM kb_intents WHERE id=?', [$id]); j(['ok' => true]); case 'intents_toggle': $id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id'); JarvisDB::execute('UPDATE kb_intents SET active=NOT active WHERE id=?', [$id]); j(['ok' => true]); // ── SITES ──────────────────────────────────────────────────────────── case 'sites_list': j(JarvisDB::query("SELECT fact_key,fact_value,updated_at FROM kb_facts WHERE category='sites' ORDER BY fact_key")); // ── HOME ASSISTANT ENTITIES ─────────────────────────────────────────── case 'ha_list': $raw = JarvisDB::single("SELECT data, UNIX_TIMESTAMP(updated_at) ts FROM api_cache WHERE cache_key='ha_entities'"); if (!$raw) j(['entities'=>[],'domains'=>[],'ts'=>null]); $cache = json_decode($raw['data'], true) ?? []; $domain = $_GET['domain'] ?? ''; $search = strtolower(trim($_GET['search'] ?? '')); $all = []; foreach ($cache['entities'] ?? [] as $dom => $ents) { if ($domain && $dom !== $domain) continue; foreach ($ents as $e) { if ($search && strpos(strtolower($e['name']??''),$search)===false && strpos(strtolower($e['entity_id']??''),$search)===false) continue; $e['domain'] = $dom; $all[] = $e; } } usort($all, fn($a,$b) => strcmp($a['name']??'',$b['name']??'')); j(['entities'=>array_slice($all,0,500),'domains'=>array_keys($cache['entities']??[]),'total'=>count($all),'ts'=>$raw['ts']]); case 'ha_toggle': $eid = trim($_POST['entity_id'] ?? ''); if (!$eid) bad('Missing entity_id'); $state = trim($_POST['state'] ?? ''); if (!defined('HA_URL')||!defined('HA_TOKEN')) bad('HA not configured'); $domain = explode('.',$eid)[0]; $svc = match($domain) { 'light','switch','input_boolean','fan' => ($state==='on'?'turn_off':'turn_on'), default => ($state==='on'?'turn_off':'turn_on') }; $ch = curl_init(HA_URL.'/api/services/'.$domain.'/'.$svc); curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_POST=>true, CURLOPT_HTTPHEADER=>['Authorization: Bearer '.HA_TOKEN,'Content-Type: application/json'], CURLOPT_POSTFIELDS=>json_encode(['entity_id'=>$eid]),CURLOPT_TIMEOUT=>8]); $res = curl_exec($ch); $code = curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch); j(['ok'=>$code<300,'code'=>$code]); // ── NEWS ───────────────────────────────────────────────────────────── case 'news_list': $cached = JarvisDB::single("SELECT data,UNIX_TIMESTAMP(updated_at) ts FROM api_cache WHERE cache_key='news'"); $news = $cached ? (json_decode($cached['data'],true)??[]) : []; $custom = JarvisDB::query("SELECT id,fact_key title,fact_value url,updated_at FROM kb_facts WHERE category='custom_news' ORDER BY id DESC"); j(['news'=>$news,'custom'=>$custom,'cache_age'=>$cached?time()-(int)$cached['ts']:null]); case 'news_custom_save': $id = (int)($_POST['id']??0); $t = trim($_POST['title']??''); if(!$t) bad('Title required'); $url = trim($_POST['url']??''); if($id) { JarvisDB::execute('UPDATE kb_facts SET fact_key=?,fact_value=? WHERE id=? AND category="custom_news"',[$t,$url,$id]); } else { JarvisDB::execute('INSERT INTO kb_facts (category,fact_key,fact_value) VALUES ("custom_news",?,?)',[$t,$url]); } j(['ok'=>true]); case 'news_custom_delete': $id=(int)($_POST['id']??0); if(!$id) bad('Missing id'); JarvisDB::execute('DELETE FROM kb_facts WHERE id=? AND category="custom_news"',[$id]); j(['ok'=>true]); // ── PROXMOX VMs ─────────────────────────────────────────────────────── case 'vms_list': $raw = JarvisDB::single("SELECT data,UNIX_TIMESTAMP(updated_at) ts FROM api_cache WHERE cache_key='proxmox'"); if (!$raw) j(['vms'=>[],'containers'=>[],'node_info'=>[],'ts'=>null]); $pve = json_decode($raw['data'],true) ?? []; j(['vms'=>$pve['vms']??[],'containers'=>$pve['containers']??[],'node_info'=>$pve['node_info']??[],'node_status'=>$pve['node_status']??null,'ts'=>$raw['ts']]); // ── USERS ──────────────────────────────────────────────────────────── case 'users_list': j(JarvisDB::query('SELECT id,username,display_name,last_seen,created_at FROM users ORDER BY username')); case 'users_save': $id = (int)($_POST['id'] ?? 0); if (!$id) bad('Missing id'); $dn = trim($_POST['display_name'] ?? ''); $pw = trim($_POST['password'] ?? ''); if ($pw) { JarvisDB::execute('UPDATE users SET display_name=?,password_hash=? WHERE id=?', [$dn, password_hash($pw, PASSWORD_BCRYPT), $id]); } else { JarvisDB::execute('UPDATE users SET display_name=? WHERE id=?', [$dn, $id]); } j(['ok' => true]); default: bad('Unknown action'); } } ?>
ADMIN PORTAL