Files
jarvis/api/endpoints/stats_cache.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

299 lines
12 KiB
PHP

<?php
/**
* JARVIS Stats Cache Collector
* Runs every 5 min via cron. Fetches Proxmox + HA data and stores in api_cache.
* Keeps live API calls out of the request path.
*/
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../../api/lib/db.php';
function curlGet(string $url, array $headers, int $timeout = 10): ?string {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
]);
$out = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($err) { echo "[cache] curl error: $err\n"; return null; }
return ($code >= 200 && $code < 300) ? $out : null;
}
function cacheStore(string $key, $data): void {
$json = is_string($data) ? $data : json_encode($data);
JarvisDB::execute(
'INSERT INTO api_cache (cache_key, data, updated_at) VALUES (?,?,NOW())
ON DUPLICATE KEY UPDATE data=VALUES(data), updated_at=NOW()',
[$key, $json]
);
}
// ── Proxmox ──────────────────────────────────────────────────────────────
if (PROXMOX_HOST !== '10.48.200.X' && PROXMOX_TOKEN_VAL !== 'YOUR_TOKEN_VALUE_HERE') {
$pveBase = 'https://' . PROXMOX_HOST . ':' . PROXMOX_PORT . '/api2/json';
$pveAuth = ['Authorization: PVEAPIToken=' . PROXMOX_USER . '!' . PROXMOX_TOKEN_ID . '=' . PROXMOX_TOKEN_VAL];
$nodeStatusRaw = curlGet("$pveBase/nodes/" . PROXMOX_NODE . "/status", $pveAuth);
$vmsRaw = curlGet("$pveBase/nodes/" . PROXMOX_NODE . "/qemu", $pveAuth);
$lxcRaw = curlGet("$pveBase/nodes/" . PROXMOX_NODE . "/lxc", $pveAuth);
$nodeStatus = $nodeStatusRaw ? (json_decode($nodeStatusRaw, true)['data'] ?? null) : null;
$vms = $vmsRaw ? (json_decode($vmsRaw, true)['data'] ?? []) : [];
$lxcs = $lxcRaw ? (json_decode($lxcRaw, true)['data'] ?? []) : [];
$vmDetails = [];
foreach ($vms as $vm) {
$vmDetails[] = [
'vmid' => $vm['vmid'],
'name' => $vm['name'] ?? 'VM-' . $vm['vmid'],
'status' => $vm['status'] ?? 'unknown',
'cpu' => round(($vm['cpu'] ?? 0) * 100, 1),
'mem_mb' => round(($vm['mem'] ?? 0) / 1048576),
'maxmem_mb' => round(($vm['maxmem'] ?? 0) / 1048576),
'disk_gb' => round(($vm['disk'] ?? 0) / 1073741824, 1),
'uptime' => $vm['uptime'] ?? 0,
'netin' => $vm['netin'] ?? 0,
'netout' => $vm['netout'] ?? 0,
];
}
$lxcDetails = [];
foreach ($lxcs as $lxc) {
$lxcDetails[] = [
'vmid' => $lxc['vmid'],
'name' => $lxc['name'] ?? 'CT-' . $lxc['vmid'],
'status' => $lxc['status'] ?? 'unknown',
'cpu' => round(($lxc['cpu'] ?? 0) * 100, 1),
'mem_mb' => round(($lxc['mem'] ?? 0) / 1048576),
'maxmem_mb' => round(($lxc['maxmem'] ?? 0) / 1048576),
'type' => 'lxc',
];
}
cacheStore('proxmox', [
'configured' => true,
'node' => PROXMOX_NODE,
'node_status' => $nodeStatus,
'vms' => $vmDetails,
'containers' => $lxcDetails,
'vm_count' => count($vmDetails),
'ct_count' => count($lxcDetails),
'cached_at' => date('c'),
]);
echo '[cache] Proxmox: ' . count($vmDetails) . ' VMs, ' . count($lxcDetails) . " CTs cached\n";
}
// ── Home Assistant ────────────────────────────────────────────────────────
if (HA_TOKEN !== 'YOUR_HA_TOKEN_HERE' && strpos(HA_URL, '10.48.200.X') === false) {
$haHeaders = [
'Authorization: Bearer ' . HA_TOKEN,
'Content-Type: application/json',
];
$statesRaw = curlGet(HA_URL . '/api/states', $haHeaders, 15);
$configRaw = curlGet(HA_URL . '/api/config', $haHeaders, 8);
$states = $statesRaw ? json_decode($statesRaw, true) : [];
$config = $configRaw ? json_decode($configRaw, true) : [];
$interesting = ['light','switch','sensor','climate','binary_sensor','cover',
'media_player','camera','alarm_control_panel','lock','fan','input_boolean'];
$grouped = [];
foreach (($states ?? []) as $entity) {
$domain = explode('.', $entity['entity_id'])[0];
if (!in_array($domain, $interesting)) continue;
if (strpos($entity['entity_id'], 'adguard') !== false) continue;
if (!isset($grouped[$domain])) $grouped[$domain] = [];
$grouped[$domain][] = [
'entity_id' => $entity['entity_id'],
'name' => $entity['attributes']['friendly_name'] ?? $entity['entity_id'],
'state' => $entity['state'],
'last_changed' => $entity['last_changed'] ?? null,
];
}
cacheStore('ha_entities', [
'configured' => true,
'ha_version' => $config['version'] ?? 'unknown',
'location' => $config['location_name'] ?? 'Home',
'entity_count' => count($states ?? []),
'entities' => $grouped,
'cached_at' => date('c'),
]);
$total = array_sum(array_map('count', $grouped));
echo "[cache] HA: $total entities across " . count($grouped) . " domains cached\n";
}
// ── Weather (wttr.in — refresh every 30 min) ──────────────────────────────
$weatherRow = JarvisDB::query(
"SELECT UNIX_TIMESTAMP(updated_at) as ts FROM api_cache WHERE cache_key='weather' LIMIT 1"
);
$weatherAge = $weatherRow ? (time() - (int)$weatherRow[0]['ts']) : PHP_INT_MAX;
if ($weatherAge > 1800) {
// wttr.in code → icon mapping
$wttrIcon = function(int $code): string {
if ($code === 113) return 'SUNNY';
if ($code === 116) return 'PARTLY CLOUDY';
if (in_array($code, [119,122])) return 'CLOUDY';
if (in_array($code, [143,248,260])) return 'FOGGY';
if (in_array($code, [176,263,266,293,296])) return 'LIGHT RAIN';
if (in_array($code, [299,302,305,308,353,356,359])) return 'RAIN';
if (in_array($code, [317,320,362,365])) return 'SLEET';
if (in_array($code, [323,326,329,332,335,338,368,371])) return 'SNOW';
if (in_array($code, [386,389,392,395,200])) return 'STORMS';
if (in_array($code, [281,284,311,314])) return 'FREEZING RAIN';
return 'MIXED';
};
$wttrEmoji = function(int $code): string {
if ($code === 113) return 'Sunny';
if ($code === 116) return 'Partly Cloudy';
if (in_array($code, [119,122])) return 'Cloudy';
if (in_array($code, [143,248,260])) return 'Foggy';
if (in_array($code, [176,263,266,293,296])) return 'Light Rain';
if (in_array($code, [299,302,305,308,353,356,359])) return 'Rain';
if (in_array($code, [317,320,362,365])) return 'Sleet';
if (in_array($code, [323,326,329,332,335,338,368,371])) return 'Snow';
if (in_array($code, [386,389,392,395,200])) return 'Thunderstorm';
return 'Mixed';
};
$weatherRaw = curlGet(
'https://wttr.in/FortWorth,TX?format=j1',
['User-Agent: curl/7.88 Jarvis/1.0'],
15
);
if ($weatherRaw) {
$w = json_decode($weatherRaw, true);
$cu = $w['current_condition'][0] ?? [];
$days = $w['weather'] ?? [];
$curCode = (int)($cu['weatherCode'] ?? 113);
$forecast = [];
foreach (array_slice($days, 0, 4) as $day) {
// max rain chance across 8 hourly slots
$rainPct = 0;
foreach ($day['hourly'] ?? [] as $h) {
$rainPct = max($rainPct, (int)($h['chanceofrain'] ?? 0));
}
$dayCode = (int)($day['hourly'][4]['weatherCode'] ?? 113);
$forecast[] = [
'date' => $day['date'] ?? '',
'day' => date('D', strtotime($day['date'] ?? 'now')),
'high' => (int)($day['maxtempF'] ?? 0),
'low' => (int)($day['mintempF'] ?? 0),
'rain_pct' => $rainPct,
'desc' => $wttrEmoji($dayCode),
'icon' => $wttrIcon($dayCode),
];
}
cacheStore('weather', [
'source' => 'wttr.in',
'location' => 'Fort Worth, TX',
'current' => [
'temp' => (int)($cu['temp_F'] ?? 0),
'feels' => (int)($cu['FeelsLikeF'] ?? 0),
'humidity' => (int)($cu['humidity'] ?? 0),
'wind' => (int)($cu['windspeedMiles'] ?? 0),
'desc' => $wttrEmoji($curCode),
'icon' => $wttrIcon($curCode),
'cloud' => (int)($cu['cloudcover'] ?? 0),
'vis' => (int)($cu['visibility'] ?? 0),
],
'forecast' => $forecast,
'cached_at' => date('c'),
]);
echo '[cache] Weather: ' . ($cu['temp_F'] ?? '?') . "°F, " . $wttrEmoji($curCode) . " (wttr.in) cached\n";
} else {
echo "[cache] Weather: wttr.in fetch failed\n";
}
} else {
echo '[cache] Weather: fresh (' . round($weatherAge/60) . " min old)\n";
}
// ── News (RSS feeds — refresh every 30 min) ───────────────────────────────
$newsRow = JarvisDB::query(
"SELECT UNIX_TIMESTAMP(updated_at) as ts FROM api_cache WHERE cache_key='news' LIMIT 1"
);
$newsAge = $newsRow ? (time() - (int)$newsRow[0]['ts']) : PHP_INT_MAX;
if ($newsAge > 1800) {
$feeds = [
'headlines' => [
'https://feeds.bbci.co.uk/news/rss.xml',
'https://feeds.npr.org/1001/rss.xml',
'https://feeds.abcnews.com/abcnews/topstories',
],
'technology' => [
'http://feeds.arstechnica.com/arstechnica/index',
'https://www.theverge.com/rss/index.xml',
],
];
function parseRss(string $xml, int $max = 5): array {
$items = [];
if (!$xml) return $items;
libxml_use_internal_errors(true);
$dom = new DOMDocument();
$dom->loadXML($xml);
$nodes = $dom->getElementsByTagName('item');
$count = 0;
foreach ($nodes as $node) {
if ($count >= $max) break;
$title = $node->getElementsByTagName('title')->item(0)?->textContent ?? '';
$link = $node->getElementsByTagName('link')->item(0)?->textContent ?? '';
$pub = $node->getElementsByTagName('pubDate')->item(0)?->textContent ?? '';
$title = html_entity_decode(trim($title), ENT_QUOTES | ENT_HTML5, 'UTF-8');
if ($title && strlen($title) > 5) {
$items[] = [
'title' => $title,
'link' => trim($link),
'pub' => $pub ? date('M j g:ia', strtotime($pub)) : '',
'source' => '',
];
$count++;
}
}
return $items;
}
$allNews = [];
foreach ($feeds as $category => $urls) {
$allNews[$category] = [];
foreach ($urls as $url) {
$xml = curlGet($url, ['User-Agent: Mozilla/5.0 Jarvis/1.0'], 12);
$items = parseRss($xml, 4);
// Tag source from URL
$src = preg_match('/bbc/i', $url) ? 'BBC' :
(preg_match('/npr/i', $url) ? 'NPR' :
(preg_match('/abcnews/i', $url) ? 'ABC' :
(preg_match('/arstechnica/i', $url) ? 'Ars Technica' :
(preg_match('/theverge/i', $url) ? 'The Verge' : 'News'))));
foreach ($items as &$it) { $it['source'] = $src; }
$allNews[$category] = array_merge($allNews[$category], $items);
if (count($allNews[$category]) >= 8) break;
}
// Trim to 8 per category
$allNews[$category] = array_slice($allNews[$category], 0, 8);
}
$totalItems = array_sum(array_map('count', $allNews));
cacheStore('news', [
'categories' => $allNews,
'cached_at' => date('c'),
'total' => $totalItems,
]);
echo "[cache] News: $totalItems articles cached\n";
} else {
echo '[cache] News: fresh (' . round($newsAge/60) . " min old)\n";
}
echo '[cache] Done at ' . date('Y-m-d H:i:s') . "\n";