mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
90e4ded7c9
- ha-poller: replace recursive main() retry with while loop (stack overflow fix) - ha-poller: advance last_push on empty HA response (log spam fix) - ha-poller: use datetime.now(timezone.utc) instead of deprecated utcnow() - ping-probe: always call update_status() unconditionally so offline devices register as offline - agent.php: heartbeat reads status from payload instead of hardcoding 'online' - phone-probe: delegate JSON building to python3 (bash concatenation injection fix) - netscan + phone-probe: read registration key from /etc/jarvis-agent/reg-key - admin/index.php: sync ha_list skipDomains with ha.php (14 missing domains added) - facts_collector: self-check JARVIS via 127.0.0.1 instead of Cloudflare hairpin
252 lines
12 KiB
PHP
252 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* JARVIS Facts Collector
|
|
* HTTP endpoint: /api/facts/collect (POST or GET)
|
|
* CLI/cron: php facts_collector.php
|
|
* Gathers live system, network, Proxmox, HA, and Ollama facts → kb_facts table.
|
|
*/
|
|
|
|
$isCLI = (php_sapi_name() === 'cli' || php_sapi_name() === 'litespeed');
|
|
|
|
// Bootstrap: load if not already available (HTTP via api.php loads these; CLI/lsphp/cron must load manually)
|
|
if (!class_exists('KBEngine')) {
|
|
require_once __DIR__ . '/../config.php';
|
|
require_once __DIR__ . '/../lib/db.php';
|
|
require_once __DIR__ . '/../lib/kb_engine.php';
|
|
}
|
|
|
|
function collect_all(): array {
|
|
$results = [];
|
|
$ttl = 300; // 5-minute TTL on live facts
|
|
|
|
// Returns true if a fact category has been updated within $secs seconds.
|
|
// Prevents expensive external calls when data is still fresh.
|
|
$fresh = function(string $cat, int $secs): bool {
|
|
$row = JarvisDB::query(
|
|
'SELECT updated_at FROM kb_facts WHERE category=? ORDER BY updated_at DESC LIMIT 1',
|
|
[$cat]
|
|
);
|
|
if (empty($row[0]['updated_at'])) return false;
|
|
return (time() - strtotime($row[0]['updated_at'])) < $secs;
|
|
};
|
|
|
|
|
|
// ── System ────────────────────────────────────────────────────────────
|
|
try {
|
|
$stat1 = file_get_contents('/proc/stat');
|
|
usleep(200000);
|
|
$stat2 = file_get_contents('/proc/stat');
|
|
|
|
$cpu1 = sscanf(explode("\n", $stat1)[0], "cpu %d %d %d %d %d %d %d");
|
|
$cpu2 = sscanf(explode("\n", $stat2)[0], "cpu %d %d %d %d %d %d %d");
|
|
$dIdle = $cpu2[3] - $cpu1[3];
|
|
$dTotal = array_sum($cpu2) - array_sum($cpu1);
|
|
$cpuPct = $dTotal > 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 — read from agent DB (agents push status, DO can't ping LAN IPs) ──
|
|
try {
|
|
$rows = JarvisDB::query(
|
|
"SELECT status FROM registered_agents WHERE last_seen > DATE_SUB(NOW(), INTERVAL 5 MINUTE)"
|
|
);
|
|
$online = count(array_filter($rows, fn($r) => $r['status'] === 'online'));
|
|
$total = count($rows);
|
|
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 (TTL 10 min) ─────────────────────────────────────────────
|
|
if ($fresh('proxmox', 600)) {
|
|
$results['proxmox'] = 'skipped (fresh)';
|
|
} else try {
|
|
if (defined('PROXMOX_TOKEN_ID') && PROXMOX_TOKEN_ID) {
|
|
$base = 'https://10.48.200.90:' . 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 — skipped (HA agent pushes entities every 30s) ────
|
|
$results['ha'] = 'skipped (agent push active)';
|
|
|
|
|
|
// ── Digital Ocean ─────────────────────────────────────────────────────
|
|
try {
|
|
exec("ping -c1 -W1 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 (TTL 15 min) ───────────────────────────────────────────────
|
|
if ($fresh('ollama', 900)) {
|
|
$results['ollama'] = 'skipped (fresh)';
|
|
} else 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_CONNECTTIMEOUT => 2, CURLOPT_TIMEOUT => 3]);
|
|
$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 (TTL 5 min) ───────────────────────────────────────────
|
|
if ($fresh('sites', 300)) {
|
|
$results['sites'] = 'skipped (fresh)';
|
|
} else try {
|
|
$sites = [
|
|
"jarvis" => "http://127.0.0.1",
|
|
'tomsjavajive' => 'https://tomsjavajive.com',
|
|
'epictravelexp'=> 'https://epictravelexpeditions.com',
|
|
'parkersling' => 'https://parkerslingshotrentals.com',
|
|
'orbishosting' => 'https://orbishosting.com',
|
|
'orbisportal' => 'https://orbis.orbishosting.com',
|
|
'tomtomgames' => 'https://tomtomgames.com',
|
|
];
|
|
$down = [];
|
|
foreach ($sites as $key => $url) {
|
|
$parsed = parse_url($url);
|
|
$host = $parsed['host'] ?? $url;
|
|
// Check sites on the local server directly to avoid Cloudflare CDN timeouts.
|
|
// All JARVIS-hosted sites are served from this same OLS instance.
|
|
$localUrl = $url; // external check
|
|
$ch = curl_init($localUrl);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_CONNECTTIMEOUT => 3,
|
|
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 is handled by PVE1 cron (/usr/local/bin/jarvis-netscan.sh)
|
|
// which POSTs nmap results to /api/netscan every 3 minutes.
|
|
$results['nmap_scan'] = 'handled by PVE1 push (jarvis-netscan.sh)';
|
|
|
|
return $results;
|
|
}
|
|
|
|
function pve_api_get(string $url, string $authHeader): array {
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_CONNECTTIMEOUT => 3,
|
|
CURLOPT_HTTPHEADER => [$authHeader],
|
|
CURLOPT_SSL_VERIFYPEER => false,
|
|
CURLOPT_TIMEOUT => 5,
|
|
]);
|
|
$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')]);
|
|
}
|