mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
dc55e6c45b
- 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
307 lines
16 KiB
PHP
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')]);
|
|
}
|