Files
jarvis/api/endpoints/facts_collector.php
T
myron dc55e6c45b Initial commit: JARVIS AI dashboard v2.3
- 4-tier chat: HA control → Ollama → Groq → Claude
- Push-based agent system with heartbeat/metrics
- Network monitoring, alerts, Proxmox, Home Assistant
- Windows + Linux agent installers
- Stats cache cron, facts collector, KB engine
2026-05-25 13:22:57 +00:00

307 lines
16 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
// ── 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 ───────────────────────────────────────────────────────────
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();
}
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')]);
}