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
299 lines
12 KiB
PHP
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";
|