0 ? round(($dTotal - $dIdle) / $dTotal * 100, 1) : 0; KBEngine::storeFact('system', 'cpu_usage', $cpuPct, 'local', $ttl); $memLines = file('/proc/meminfo'); $mem = []; foreach ($memLines as $l) { if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mem[$m[1]] = (int)$m[2]; } $total = round($mem['MemTotal'] / 1048576, 1); $avail = round($mem['MemAvailable'] / 1048576, 1); $used = round($total - $avail, 1); $free = round($mem['MemFree'] / 1048576, 1); $memPct = $total > 0 ? round($used / $total * 100) : 0; KBEngine::storeFact('system', 'mem_total_gb', $total, 'local', $ttl); KBEngine::storeFact('system', 'mem_used_gb', $used, 'local', $ttl); KBEngine::storeFact('system', 'mem_free_gb', $free, 'local', $ttl); KBEngine::storeFact('system', 'mem_percent', $memPct, 'local', $ttl); $la = explode(' ', file_get_contents('/proc/loadavg')); KBEngine::storeFact('system', 'load_1m', $la[0], 'local', $ttl); KBEngine::storeFact('system', 'load_5m', $la[1], 'local', $ttl); KBEngine::storeFact('system', 'load_15m', $la[2], 'local', $ttl); $sec = (int) file_get_contents('/proc/uptime'); KBEngine::storeFact('system', 'uptime', intdiv($sec, 86400) . ' days, ' . intdiv($sec % 86400, 3600) . ' hours', 'local', $ttl); $df = disk_free_space('/'); $dt = disk_total_space('/'); KBEngine::storeFact('system', 'disk_total', round($dt / 1073741824, 1) . 'GB', 'local', $ttl); KBEngine::storeFact('system', 'disk_used', round(($dt - $df) / 1073741824, 1) . 'GB', 'local', $ttl); KBEngine::storeFact('system', 'disk_free', round($df / 1073741824, 1) . 'GB', 'local', $ttl); $results['system'] = "ok (CPU {$cpuPct}%, MEM {$memPct}%)"; } catch (Exception $e) { $results['system'] = 'error: ' . $e->getMessage(); } // ── Network ─────────────────────────────────────────────────────────── try { $watchlist = [ 'gateway' => '10.48.200.1', 'proxmox' => '10.48.200.90', 'ollama' => '10.48.200.95', 'fusionpbx' => '10.48.200.96', 'ha' => '10.48.200.97', 'do_server' => '165.22.1.228', ]; $online = 0; $total = count($watchlist); foreach ($watchlist as $name => $ip) { exec('ping -c1 -W1 ' . escapeshellarg($ip) . ' > /dev/null 2>&1', $o, $code); $up = ($code === 0); if ($up) $online++; KBEngine::storeFact('network', "host_{$name}", $up ? 'online' : 'offline', $ip, $ttl); } KBEngine::storeFact('network', 'online_count', $online, 'local', $ttl); KBEngine::storeFact('network', 'total_count', $total, 'local', $ttl); KBEngine::storeFact('network', 'gateway_status', $online > 0 ? 'online' : 'offline', 'local', $ttl); $results['network'] = "ok ({$online}/{$total} online)"; } catch (Exception $e) { $results['network'] = 'error: ' . $e->getMessage(); } // ── Proxmox ─────────────────────────────────────────────────────────── try { if (defined('PROXMOX_TOKEN_ID') && PROXMOX_TOKEN_ID) { $base = 'https://' . PROXMOX_HOST . ':' . PROXMOX_PORT . '/api2/json'; $auth = 'Authorization: PVEAPIToken=' . PROXMOX_USER . '!' . PROXMOX_TOKEN_ID . '=' . PROXMOX_TOKEN_VAL; $nd = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/status", $auth); $vms = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/qemu", $auth); $cts = pve_api_get("{$base}/nodes/" . PROXMOX_NODE . "/lxc", $auth); if (isset($nd['data'])) { $cpuPct = round(($nd['data']['cpu'] ?? 0) * 100, 1); $memU = round(($nd['data']['memory']['used'] ?? 0) / 1073741824, 1); $memT = round(($nd['data']['memory']['total'] ?? 0) / 1073741824, 1); $memPct = $memT > 0 ? round($memU / $memT * 100) : 0; KBEngine::storeFact('proxmox', 'pve_cpu_percent', $cpuPct, PROXMOX_HOST, $ttl); KBEngine::storeFact('proxmox', 'pve_mem_used_gb', $memU, PROXMOX_HOST, $ttl); KBEngine::storeFact('proxmox', 'pve_mem_total_gb', $memT, PROXMOX_HOST, $ttl); KBEngine::storeFact('proxmox', 'pve_mem_percent', $memPct, PROXMOX_HOST, $ttl); } $all = array_merge($vms['data'] ?? [], $cts['data'] ?? []); $running = count(array_filter($all, fn($v) => ($v['status'] ?? '') === 'running')); KBEngine::storeFact('proxmox', 'vm_total', count($all), PROXMOX_HOST, $ttl); KBEngine::storeFact('proxmox', 'vm_running', $running, PROXMOX_HOST, $ttl); $results['proxmox'] = "ok ({$running}/" . count($all) . " running)"; } else { $results['proxmox'] = 'skipped (no token)'; } } catch (Exception $e) { $results['proxmox'] = 'error: ' . $e->getMessage(); } // ── Home Assistant ──────────────────────────────────────────────────── try { if (defined('HA_URL') && defined('HA_TOKEN') && HA_TOKEN !== 'YOUR_HA_TOKEN_HERE') { $haUrl = HA_URL; $haToken = HA_TOKEN; $haHdr = ['Authorization: Bearer ' . $haToken, 'Content-Type: application/json']; // Fetch all entity states $ch = curl_init($haUrl . '/api/states'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $haHdr, CURLOPT_TIMEOUT => 12, CURLOPT_CONNECTTIMEOUT => 5, ]); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($code === 200) { $allStates = json_decode($resp, true) ?? []; // Domains to index for control $controlDomains = ['light','switch','input_boolean','climate','cover','fan', 'scene','script','lawn_mower','vacuum','media_player']; // Switch keywords to skip (camera/HACS settings, not real devices) $skipKeywords = ['pre_release','_record','_ftp_','_push_','_hub_ringtone', '_siren_on','_email_on','_manual_record','_infrared_', 'do_not_disturb','matter_server','zerotier','mariadb', 'spotify','file_editor','ssh_web','uptime_kuma', 'adguard_home_','adguard_protection','adguard_parental', 'adguard_safe','adguard_filter','adguard_query', 'assist_microphone','folding_home','music_assistant', 'get_hacs','mealie','mosquitto','social_to', 'motion_detection','front_yard_record','down_hill_record', 'camera1_record','back_yard_record','nvr_']; $entityMap = []; $statusCount = ['online' => 0, 'offline' => 0, 'unavailable' => 0]; foreach ($allStates as $s) { $eid = $s['entity_id']; $domain = explode('.', $eid)[0]; $name = $s['attributes']['friendly_name'] ?? $eid; $state = $s['state']; if (!in_array($domain, $controlDomains)) continue; // Skip camera/HACS internals for switches if ($domain === 'switch') { $skip = false; foreach ($skipKeywords as $kw) { if (strpos($eid, $kw) !== false) { $skip = true; break; } } if ($skip) continue; } $entityMap[$eid] = ['name' => $name, 'state' => $state, 'domain' => $domain]; if ($state === 'unavailable' || $state === 'unknown') { $statusCount['unavailable']++; } elseif (in_array($state, ['on','open','playing','mowing','home','active','idle'])) { $statusCount['online']++; } elseif (in_array($state, ['off','closed','paused','docked','away'])) { $statusCount['offline']++; } } // Store entity map as JSON for chat.php to use KBEngine::storeFact('ha', 'entity_map', json_encode($entityMap), 'ha', 270); KBEngine::storeFact('ha', 'entity_count', count($entityMap), 'ha', $ttl); KBEngine::storeFact('ha', 'online_count', $statusCount['online'], 'ha', $ttl); KBEngine::storeFact('ha', 'offline_count', $statusCount['offline'], 'ha', $ttl); KBEngine::storeFact('ha', 'unavail_count', $statusCount['unavailable'], 'ha', $ttl); KBEngine::storeFact('ha', 'ha_status', 'online', 'ha', $ttl); // Store individual sensor facts $sensorDomains = ['sensor','binary_sensor','weather']; $interestingPatterns = ['temperature','humidity','battery','power','energy', 'voltage','current','illuminance','co2','pm25']; foreach ($allStates as $s) { $domain = explode('.', $s['entity_id'])[0]; if (!in_array($domain, $sensorDomains)) continue; $eid = $s['entity_id']; foreach ($interestingPatterns as $pat) { if (strpos($eid, $pat) !== false) { $name = $s['attributes']['friendly_name'] ?? $eid; $unit = $s['attributes']['unit_of_measurement'] ?? ''; KBEngine::storeFact('ha_sensors', $eid, $s['state'] . ($unit ? " {$unit}" : ''), 'ha', $ttl); break; } } } $results['ha'] = sprintf('ok (%d entities, %d on, %d off, %d unavail)', count($entityMap), $statusCount['online'], $statusCount['offline'], $statusCount['unavailable']); } else { KBEngine::storeFact('ha', 'ha_status', 'unreachable', 'ha', $ttl); $results['ha'] = "unreachable (HTTP {$code})"; } } else { KBEngine::storeFact('ha', 'ha_status', 'token not configured', 'ha', $ttl); $results['ha'] = 'skipped (no token)'; } } catch (Exception $e) { KBEngine::storeFact('ha', 'ha_status', 'error', 'ha', $ttl); $results['ha'] = 'error: ' . $e->getMessage(); } // ── Digital Ocean ───────────────────────────────────────────────────── try { exec('ping -c1 -W2 165.22.1.228 > /dev/null 2>&1', $o2, $doCode); $doStatus = ($doCode === 0) ? 'online' : 'unreachable'; KBEngine::storeFact('do_server', 'do_status', $doStatus, '165.22.1.228', $ttl); $results['do_server'] = "ok ({$doStatus})"; } catch (Exception $e) { $results['do_server'] = 'error: ' . $e->getMessage(); } // ── Ollama ──────────────────────────────────────────────────────────── try { $ollamaHost = defined('OLLAMA_HOST') ? OLLAMA_HOST : 'http://10.48.200.95:11434'; $ch = curl_init($ollamaHost . '/api/tags'); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5]); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($code === 200) { $models = json_decode($resp, true)['models'] ?? []; $names = array_column($models, 'name'); KBEngine::storeFact('ollama', 'available_models', implode(', ', $names) ?: 'none', 'proxmox', null); KBEngine::storeFact('ollama', 'model_count', count($names), 'proxmox', $ttl); KBEngine::storeFact('ollama', 'status', 'online', 'proxmox', $ttl); foreach ($models as $m) { JarvisDB::execute( 'INSERT INTO kb_ollama_models (model_name, size_gb) VALUES (?,?) ON DUPLICATE KEY UPDATE size_gb=VALUES(size_gb), pulled_at=NOW()', [$m['name'], round(($m['size'] ?? 0) / 1073741824, 1)] ); } $results['ollama'] = 'ok (' . (implode(', ', $names) ?: 'no models yet') . ')'; } else { KBEngine::storeFact('ollama', 'status', 'offline', 'proxmox', $ttl); $results['ollama'] = 'unreachable (VM may be booting)'; } } catch (Exception $e) { $results['ollama'] = 'error: ' . $e->getMessage(); } // ── Site Health ─────────────────────────────────────────────────────── try { $sites = [ 'jarvis' => 'https://jarvis.orbishosting.com', 'tomsjavajive' => 'https://tomsjavajive.com', 'epictravelexp'=> 'https://epictravelexpeditions.com', 'parkersling' => 'https://parkerslingshot.epictravelexpeditions.com', 'orbishosting' => 'https://orbishosting.com', 'orbisportal' => 'https://orbis.orbishosting.com', 'tomtomgames' => 'https://tomtomgames.com', ]; $down = []; foreach ($sites as $key => $url) { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_TIMEOUT => 10, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_NOBODY => true, ]); curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $status = ($code >= 200 && $code < 400) ? 'up' : "down-$code"; KBEngine::storeFact('sites', $key, $status, $url, 180); if ($status !== 'up') $down[] = "$key($code)"; } $results['sites'] = empty($down) ? 'all up' : 'DOWN: ' . implode(', ', $down); } catch (Exception $e) { $results['sites'] = 'error: ' . $e->getMessage(); } // ── Network Device Scan (nmap via PVE1) ─────────────────────────────── try { $nmapRaw = shell_exec( "sshpass -p 'Joker1974!!!' ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 " . "root@10.48.200.90 'nmap -sn --send-ip 10.48.200.0/24 2>/dev/null' 2>/dev/null" ); if ($nmapRaw) { $discovered = []; $cur = null; foreach (explode("\n", $nmapRaw) as $line) { $line = trim($line); if (preg_match('/Nmap scan report for (?:(\S+) \()?(\d+\.\d+\.\d+\.\d+)\)?/', $line, $m)) { if ($cur) $discovered[] = $cur; $cur = ['hostname' => ($m[1] && $m[1] !== $m[2]) ? $m[1] : null, 'ip' => $m[2], 'mac' => null, 'vendor' => null]; } elseif ($cur && preg_match('/MAC Address: ([0-9A-Fa-f:]{17}) \(([^)]+)\)/i', $line, $m)) { $cur['mac'] = strtolower($m[1]); $cur['vendor'] = $m[2] !== 'Unknown' ? $m[2] : null; } } if ($cur) $discovered[] = $cur; $discoveredIPs = []; foreach ($discovered as $d) { $discoveredIPs[] = $d['ip']; 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 ($d['vendor']) { JarvisDB::execute( 'UPDATE network_devices SET device_type=? WHERE ip=? AND (device_type IS NULL OR device_type="")', [$d['vendor'], $d['ip']] ); } } if (!empty($discoveredIPs)) { $ph = implode(',', array_fill(0, count($discoveredIPs), '?')); JarvisDB::execute( "UPDATE network_devices SET status='offline' WHERE ip NOT IN ($ph) AND last_seen < DATE_SUB(NOW(), INTERVAL 10 MINUTE)", $discoveredIPs ); } $results['nmap_scan'] = 'ok (' . count($discovered) . ' devices found)'; } else { $results['nmap_scan'] = 'skipped (PVE1 unreachable)'; } } catch (Exception $e) { $results['nmap_scan'] = 'error: ' . $e->getMessage(); } return $results; } function pve_api_get(string $url, string $authHeader): array { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [$authHeader], CURLOPT_SSL_VERIFYPEER => false, CURLOPT_TIMEOUT => 8, ]); $resp = curl_exec($ch); curl_close($ch); return $resp ? (json_decode($resp, true) ?? []) : []; } // ── Entry point ─────────────────────────────────────────────────────────── $results = collect_all(); if ($isCLI) { echo date('Y-m-d H:i:s') . " JARVIS facts collected:\n"; foreach ($results as $k => $v) { echo " {$k}: {$v}\n"; } } else { echo json_encode(['status' => 'ok', 'results' => $results, 'timestamp' => date('c')]); }