mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
348 lines
15 KiB
PHP
348 lines
15 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://10.48.200.90:' . PROXMOX_PORT . '/api2/json';
|
|
$pveAuth = ['Authorization: PVEAPIToken=' . PROXMOX_USER . '!' . PROXMOX_TOKEN_ID . '=' . PROXMOX_TOKEN_VAL];
|
|
|
|
// Cluster resources API — returns all VMs/CTs from ALL nodes (pve + pve2)
|
|
$clusterRaw = curlGet("$pveBase/cluster/resources?type=vm", $pveAuth);
|
|
$nodeStatusRaw = curlGet("$pveBase/nodes/" . PROXMOX_NODE . "/status", $pveAuth);
|
|
$nodeStatus = $nodeStatusRaw ? (json_decode($nodeStatusRaw, true)['data'] ?? null) : null;
|
|
|
|
$allResources = $clusterRaw ? (json_decode($clusterRaw, true)['data'] ?? []) : [];
|
|
|
|
function fmtUptime(int $sec): string {
|
|
$d = intdiv($sec, 86400); $h = intdiv($sec % 86400, 3600); $m = intdiv($sec % 3600, 60);
|
|
return ($d > 0 ? "{$d}d " : '') . "{$h}h {$m}m";
|
|
}
|
|
|
|
function fmtBytes(int $b): string {
|
|
if ($b >= 1073741824) return round($b/1073741824, 1) . ' GB';
|
|
if ($b >= 1048576) return round($b/1048576, 1) . ' MB';
|
|
return $b . ' B';
|
|
}
|
|
|
|
$vmDetails = []; $lxcDetails = [];
|
|
foreach ($allResources as $r) {
|
|
$memPct = ($r['maxmem'] ?? 0) > 0 ? round($r['mem'] / $r['maxmem'] * 100, 1) : 0;
|
|
$diskGb = round(($r['maxdisk'] ?? 0) / 1073741824, 1);
|
|
$cpuPct = round(($r['cpu'] ?? 0) * 100, 1);
|
|
$upSec = (int)($r['uptime'] ?? 0);
|
|
$entry = [
|
|
'vmid' => $r['vmid'],
|
|
'name' => $r['name'] ?? ($r['type'] === 'lxc' ? 'CT-' : 'VM-') . $r['vmid'],
|
|
'node' => $r['node'] ?? 'pve',
|
|
'type' => $r['type'] ?? 'qemu',
|
|
'status' => $r['status'] ?? 'unknown',
|
|
'cpu_pct' => $cpuPct,
|
|
'cpus' => $r['maxcpu'] ?? 1,
|
|
'mem_pct' => $memPct,
|
|
'mem_used_mb' => round(($r['mem'] ?? 0) / 1048576),
|
|
'mem_total_mb' => round(($r['maxmem'] ?? 0) / 1048576),
|
|
'disk_gb' => $diskGb,
|
|
'uptime_s' => $upSec,
|
|
'uptime_human' => $upSec > 0 ? fmtUptime($upSec) : '—',
|
|
'netin' => $r['netin'] ?? 0,
|
|
'netout' => $r['netout'] ?? 0,
|
|
'netin_fmt' => fmtBytes((int)($r['netin'] ?? 0)),
|
|
'netout_fmt' => fmtBytes((int)($r['netout'] ?? 0)),
|
|
];
|
|
if ($r['type'] === 'lxc') $lxcDetails[] = $entry;
|
|
else $vmDetails[] = $entry;
|
|
}
|
|
|
|
// Sort by node then vmid
|
|
usort($vmDetails, fn($a,$b) => $a['node'] <=> $b['node'] ?: $a['vmid'] <=> $b['vmid']);
|
|
usort($lxcDetails, fn($a,$b) => $a['node'] <=> $b['node'] ?: $a['vmid'] <=> $b['vmid']);
|
|
|
|
// Node summary for both nodes
|
|
$nodeInfo = [];
|
|
if ($nodeStatus) {
|
|
$ns = $nodeStatus;
|
|
$nodeInfo['pve'] = [
|
|
'cpu_pct' => round(($ns['cpu'] ?? 0) * 100, 1),
|
|
'mem_pct' => ($ns['memory']['total'] ?? 0) > 0
|
|
? round($ns['memory']['used'] / $ns['memory']['total'] * 100, 1) : 0,
|
|
'mem_used_gb' => round(($ns['memory']['used'] ?? 0) / 1073741824, 1),
|
|
'mem_total_gb' => round(($ns['memory']['total'] ?? 0) / 1073741824, 1),
|
|
'uptime' => fmtUptime((int)($ns['uptime'] ?? 0)),
|
|
'disk_used_gb' => round(($ns['rootfs']['used'] ?? 0) / 1073741824, 1),
|
|
'disk_total_gb'=> round(($ns['rootfs']['total'] ?? 0) / 1073741824, 1),
|
|
];
|
|
}
|
|
|
|
cacheStore('proxmox', [
|
|
'configured' => true,
|
|
'node' => PROXMOX_NODE,
|
|
'node_status' => $nodeStatus,
|
|
'node_info' => $nodeInfo,
|
|
'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 (both nodes)\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) : [];
|
|
|
|
// Controllable domains only — skip read-only sensors to keep list manageable
|
|
$interesting = ['light','switch','alarm_control_panel',
|
|
'lawn_mower','fan','lock','cover','climate','input_boolean'];
|
|
// Switches that are HA internals / camera settings, not physical devices
|
|
$skipKeywords = ['pre_release','_record','_ftp_','_push_','_hub_ringtone',
|
|
'_siren_on','_email_on','_manual_record','_infrared_',
|
|
'do_not_disturb','matter_server','zerotier','mariadb',
|
|
'spotify_connect','file_editor','ssh_web','uptime_kuma',
|
|
'folding_home','music_assistant','get_hacs','mealie',
|
|
'mosquitto','social_to','esphome_device','motion_detection',
|
|
'front_yard_record','down_hill_record','camera1_record',
|
|
'back_yard_record','nvr_','assist_microphone','cec_scanner',
|
|
'kiosker','hacs_pre','adguard'];
|
|
$grouped = [];
|
|
foreach (($states ?? []) as $entity) {
|
|
$domain = explode('.', $entity['entity_id'])[0];
|
|
if (!in_array($domain, $interesting)) continue;
|
|
if ($domain === 'switch') {
|
|
$skip = false;
|
|
foreach ($skipKeywords as $kw) {
|
|
if (strpos($entity['entity_id'], $kw) !== false) { $skip = true; break; }
|
|
}
|
|
if ($skip) 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";
|