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
This commit is contained in:
2026-05-25 13:22:57 +00:00
commit dc55e6c45b
27 changed files with 5835 additions and 0 deletions
+244
View File
@@ -0,0 +1,244 @@
<?php
/**
* JARVIS Agent API
* Handles registration, heartbeats, metrics, HA state pushes, and command polling
* from remote jarvis-agent clients on external networks.
*
* Auth: POST /api/agent/register → uses AGENT_REGISTRATION_KEY
* All other actions → uses X-Agent-Key header (per-agent key)
*/
header('Content-Type: application/json');
$agentAction = $action ?? ($parts[1] ?? '');
// ── Helpers ───────────────────────────────────────────────────────────────────
function agent_error(int $code, string $msg): void {
http_response_code($code);
echo json_encode(['error' => $msg]);
exit;
}
function agent_ok(array $payload = []): void {
echo json_encode(array_merge(['ok' => true], $payload));
exit;
}
function generate_api_key(): string {
return bin2hex(random_bytes(24));
}
function get_agent_by_key(string $key): ?array {
$rows = JarvisDB::query(
'SELECT * FROM registered_agents WHERE api_key = ? LIMIT 1',
[$key]
);
return $rows[0] ?? null;
}
function update_agent_seen(string $agentId, string $status = 'online'): void {
JarvisDB::query(
'UPDATE registered_agents SET last_seen = NOW(), status = ? WHERE agent_id = ?',
[$status, $agentId]
);
}
// ── Auth (all actions except register) ───────────────────────────────────────
$agentKey = $_SERVER['HTTP_X_AGENT_KEY'] ?? '';
$browserActions = ['list', 'status', 'myip'];
if ($agentAction !== 'register') {
if (in_array($agentAction, $browserActions)) {
$agent = null; // browser-accessible via session auth already validated by api.php
} else {
if (empty($agentKey)) agent_error(401, 'X-Agent-Key header required');
$agent = get_agent_by_key($agentKey);
if (!$agent) agent_error(401, 'Invalid agent key');
}
}
// ── Route ─────────────────────────────────────────────────────────────────────
switch ($agentAction) {
// ── REGISTER ─────────────────────────────────────────────────────────────
case 'register':
if ($method !== 'POST') agent_error(405, 'POST only');
$regKey = $_SERVER['HTTP_X_REGISTRATION_KEY'] ?? ($data['registration_key'] ?? '');
if ($regKey !== AGENT_REGISTRATION_KEY) agent_error(403, 'Invalid registration key');
$hostname = trim($data['hostname'] ?? '');
$agentType = $data['agent_type'] ?? 'linux';
$ipAddress = $data['ip_address'] ?? ($_SERVER['REMOTE_ADDR'] ?? '');
$capabilities = $data['capabilities'] ?? [];
$agentId = $data['agent_id'] ?? ($hostname . '_' . substr(md5($hostname . $ipAddress), 0, 8));
if (!$hostname) agent_error(400, 'hostname required');
if (!in_array($agentType, ['linux', 'homeassistant', 'proxmox', 'windows'])) agent_error(400, 'Invalid agent_type');
// Upsert agent
$existing = JarvisDB::query('SELECT api_key FROM registered_agents WHERE agent_id = ?', [$agentId]);
if ($existing) {
$apiKey = $existing[0]['api_key'];
JarvisDB::query(
'UPDATE registered_agents SET hostname=?, agent_type=?, ip_address=?, capabilities=?, last_seen=NOW(), status="online" WHERE agent_id=?',
[$hostname, $agentType, $ipAddress, json_encode($capabilities), $agentId]
);
} else {
$apiKey = generate_api_key();
JarvisDB::query(
'INSERT INTO registered_agents (agent_id, hostname, agent_type, ip_address, api_key, capabilities, last_seen, status) VALUES (?,?,?,?,?,?,NOW(),"online")',
[$agentId, $hostname, $agentType, $ipAddress, $apiKey, json_encode($capabilities)]
);
}
agent_ok(['agent_id' => $agentId, 'api_key' => $apiKey]);
// ── HEARTBEAT ────────────────────────────────────────────────────────────
case 'heartbeat':
update_agent_seen($agent['agent_id']);
// Return any pending commands for this agent
$commands = JarvisDB::query(
'SELECT id, command_type, command_data FROM agent_commands WHERE agent_id = ? AND status = "pending" ORDER BY created_at ASC LIMIT 10',
[$agent['agent_id']]
);
// Mark as delivered
if ($commands) {
$ids = implode(',', array_column($commands, 'id'));
JarvisDB::query("UPDATE agent_commands SET status='delivered', delivered_at=NOW() WHERE id IN ($ids)");
foreach ($commands as &$cmd) {
$cmd['command_data'] = json_decode($cmd['command_data'] ?? '{}', true);
}
}
agent_ok(['commands' => $commands ?: []]);
// ── METRICS ──────────────────────────────────────────────────────────────
case 'metrics':
if ($method !== 'POST') agent_error(405, 'POST only');
update_agent_seen($agent['agent_id']);
$metricType = $data['type'] ?? 'system';
$metricData = $data['data'] ?? [];
JarvisDB::query(
'INSERT INTO agent_metrics (agent_id, metric_type, metric_data) VALUES (?,?,?)',
[$agent['agent_id'], $metricType, json_encode($metricData)]
);
// Prune old metrics (keep 24h)
JarvisDB::query(
'DELETE FROM agent_metrics WHERE agent_id = ? AND recorded_at < DATE_SUB(NOW(), INTERVAL 24 HOUR)',
[$agent['agent_id']]
);
agent_ok();
// ── HA STATE PUSH ────────────────────────────────────────────────────────
case 'ha_state':
if ($method !== 'POST') agent_error(405, 'POST only');
update_agent_seen($agent['agent_id']);
$entities = $data['entities'] ?? [];
if (empty($entities)) agent_error(400, 'entities array required');
foreach ($entities as $e) {
$entityId = $e['entity_id'] ?? '';
$entityName = $e['name'] ?? $e['friendly_name'] ?? $entityId;
$domain = explode('.', $entityId)[0] ?? 'unknown';
$state = $e['state'] ?? '';
$attrs = $e['attributes'] ?? [];
$lastChanged = $e['last_changed'] ?? date('Y-m-d H:i:s');
if (!$entityId) continue;
JarvisDB::query(
'INSERT INTO ha_entities (agent_id, entity_id, entity_name, domain, state, attributes, last_changed, updated_at)
VALUES (?,?,?,?,?,?,?,NOW())
ON DUPLICATE KEY UPDATE entity_name=VALUES(entity_name), state=VALUES(state), attributes=VALUES(attributes), last_changed=VALUES(last_changed), updated_at=NOW()',
[$agent['agent_id'], $entityId, $entityName, $domain, $state, json_encode($attrs), $lastChanged]
);
}
// Also update kb_facts for compatibility with existing KB engine
$entityMap = [];
$all = JarvisDB::query('SELECT entity_id, entity_name, domain, state, attributes FROM ha_entities WHERE agent_id = ?', [$agent['agent_id']]);
foreach ($all as $row) {
$entityMap[$row['entity_id']] = [
'entity_id' => $row['entity_id'],
'name' => $row['entity_name'],
'domain' => $row['domain'],
'state' => $row['state'],
'attributes'=> json_decode($row['attributes'] ?? '{}', true),
];
}
JarvisDB::query(
'INSERT INTO kb_facts (fact_key, fact_value, fact_type) VALUES ("ha/entity_map", ?, "json")
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()',
[json_encode($entityMap)]
);
agent_ok(['accepted' => count($entities)]);
// ── COMMAND RESULT ───────────────────────────────────────────────────────
case 'command_result':
if ($method !== 'POST') agent_error(405, 'POST only');
$cmdId = (int)($data['command_id'] ?? 0);
$result = $data['result'] ?? [];
$status = ($data['success'] ?? true) ? 'executed' : 'failed';
JarvisDB::query(
'UPDATE agent_commands SET status=?, executed_at=NOW(), result=? WHERE id=? AND agent_id=?',
[$status, json_encode($result), $cmdId, $agent['agent_id']]
);
agent_ok();
// ── LIST (admin: get all agents status) ──────────────────────────────────
case 'list':
// Mark agents offline if last_seen > 2 minutes ago
JarvisDB::query(
'UPDATE registered_agents SET status="offline" WHERE last_seen < DATE_SUB(NOW(), INTERVAL 2 MINUTE) AND status = "online"'
);
$agents = JarvisDB::query('SELECT agent_id, hostname, agent_type, ip_address, status, last_seen, capabilities FROM registered_agents ORDER BY agent_type, hostname');
foreach ($agents as &$a) {
$a['capabilities'] = json_decode($a['capabilities'] ?? '[]', true);
}
agent_ok(['agents' => $agents, 'my_ip' => $_SERVER['REMOTE_ADDR'] ?? '']);
// ── LATEST METRICS (for dashboard display) ───────────────────────────────
case 'status':
$agentIdReq = $data['agent_id'] ?? ($parts[2] ?? '');
$whereAgent = $agentIdReq ? 'AND agent_id = ?' : '';
$params = $agentIdReq ? [$agentIdReq] : [];
$latest = JarvisDB::query(
"SELECT agent_id, metric_type, metric_data, recorded_at
FROM agent_metrics
WHERE (agent_id, metric_type, recorded_at) IN (
SELECT agent_id, metric_type, MAX(recorded_at)
FROM agent_metrics $whereAgent
GROUP BY agent_id, metric_type
)
ORDER BY agent_id, metric_type",
$params
);
$grouped = [];
foreach ($latest as $row) {
$grouped[$row['agent_id']][$row['metric_type']] = json_decode($row['metric_data'], true);
$grouped[$row['agent_id']]['recorded_at'] = $row['recorded_at'];
}
agent_ok(['metrics' => $grouped]);
// ── MY IP (browser client IP detection) ──────────────────────────────────
case 'myip':
agent_ok(['ip' => $_SERVER['REMOTE_ADDR'] ?? '']);
default:
agent_error(404, 'Unknown agent action: ' . $agentAction);
}
+177
View File
@@ -0,0 +1,177 @@
<?php
/**
* JARVIS Alerts API
* GET /api/alerts — return active alerts (auto-generates agent alerts first)
* POST /api/alerts/resolve — resolve an alert by id
* POST /api/alerts — manually create an alert
*/
// ── Auto-generate alerts from agent data ─────────────────────────────────────
function refresh_agent_alerts(): void {
// Thresholds
$CPU_WARN = 85;
$MEM_WARN = 85;
$DISK_WARN = 88;
$DISK_CRIT = 95;
// ── Mark auto-resolve alerts whose condition cleared ──────────────────────
// We'll re-evaluate below and upsert; first collect keys that are still active
$still_active = [];
// ── Offline agents ────────────────────────────────────────────────────────
$offline = JarvisDB::query(
"SELECT agent_id, hostname FROM registered_agents
WHERE status='offline' OR last_seen < DATE_SUB(NOW(), INTERVAL 3 MINUTE)"
);
foreach ($offline as $ag) {
$key = 'agent:' . $ag['agent_id'] . ':offline';
upsert_alert($key, 'critical', 'Agent Offline: ' . $ag['hostname'],
'JARVIS Agent on ' . $ag['hostname'] . ' is not responding. Last contact was more than 3 minutes ago.');
$still_active[$key] = true;
}
// ── Metric-based alerts ───────────────────────────────────────────────────
// Get latest system metrics for each agent
$latest = JarvisDB::query(
"SELECT m.agent_id, m.metric_data, a.hostname
FROM agent_metrics m
JOIN registered_agents a ON a.agent_id = m.agent_id
WHERE m.metric_type = 'system'
AND (m.agent_id, m.recorded_at) IN (
SELECT agent_id, MAX(recorded_at) FROM agent_metrics
WHERE metric_type = 'system'
GROUP BY agent_id
)
AND m.recorded_at > DATE_SUB(NOW(), INTERVAL 5 MINUTE)"
);
foreach ($latest as $row) {
$d = json_decode($row['metric_data'] ?? '{}', true);
$hn = $row['hostname'];
$id = $row['agent_id'];
// CPU
$cpu = (float)($d['cpu_percent'] ?? 0);
if ($cpu >= $CPU_WARN) {
$key = 'agent:' . $id . ':cpu_high';
$sev = $cpu >= 95 ? 'critical' : 'warning';
upsert_alert($key, $sev, 'High CPU: ' . $hn,
round($cpu, 1) . '% CPU utilization on ' . $hn . '. Sustained high load detected.');
$still_active[$key] = true;
}
// Memory
$mem_pct = (float)($d['memory']['percent'] ?? 0);
if ($mem_pct >= $MEM_WARN) {
$key = 'agent:' . $id . ':mem_high';
$sev = $mem_pct >= 95 ? 'critical' : 'warning';
upsert_alert($key, $sev, 'High Memory: ' . $hn,
round($mem_pct, 1) . '% memory used on ' . $hn .
' (' . round($d['memory']['used_mb'] ?? 0) . '/' .
round($d['memory']['total_mb'] ?? 0) . ' MB).');
$still_active[$key] = true;
}
// Disk
foreach (($d['disk'] ?? []) as $disk) {
$pct = (int)($disk['percent'] ?? 0);
if ($pct >= $DISK_WARN) {
$mount = $disk['mount'] ?? '/';
$key = 'agent:' . $id . ':disk:' . str_replace('/', '_', $mount);
$sev = $pct >= $DISK_CRIT ? 'critical' : 'warning';
upsert_alert($key, $sev, 'Disk Full: ' . $hn . ' ' . $mount,
$mount . ' is ' . $pct . '% full on ' . $hn .
' (' . ($disk['used'] ?? '?') . ' of ' . ($disk['size'] ?? '?') . ' used).');
$still_active[$key] = true;
}
}
// Services down
foreach (($d['services'] ?? []) as $svc) {
if (($svc['status'] ?? '') === 'active') continue;
if (($svc['status'] ?? '') === 'unknown') continue; // not watched/installed
$svcName = $svc['service'] ?? '';
$key = 'agent:' . $id . ':svc:' . $svcName;
upsert_alert($key, 'warning', 'Service Down: ' . $svcName . ' on ' . $hn,
$svcName . ' is ' . ($svc['status'] ?? 'inactive') . ' on ' . $hn . '.');
$still_active[$key] = true;
}
}
// ── Auto-resolve alerts whose condition has cleared ────────────────────────
if (!empty($still_active)) {
$active_keys = array_keys($still_active);
// Get all auto-resolvable alerts that are unresolved
$open_auto = JarvisDB::query(
"SELECT id, source_key FROM alerts WHERE resolved=0 AND auto_resolve=1 AND source_key IS NOT NULL"
);
foreach ($open_auto as $row) {
if (!isset($still_active[$row['source_key']])) {
JarvisDB::query(
'UPDATE alerts SET resolved=1, resolved_at=NOW() WHERE id=?',
[$row['id']]
);
}
}
} else {
// Nothing active — resolve all auto alerts
JarvisDB::query(
"UPDATE alerts SET resolved=1, resolved_at=NOW()
WHERE resolved=0 AND auto_resolve=1"
);
}
}
function upsert_alert(string $key, string $sev, string $title, string $msg): void {
$existing = JarvisDB::query(
'SELECT id, severity FROM alerts WHERE source_key=? AND resolved=0 LIMIT 1',
[$key]
);
if ($existing) {
// Update severity/message if changed (e.g., warning → critical)
if ($existing[0]['severity'] !== $sev) {
JarvisDB::query(
'UPDATE alerts SET severity=?, title=?, message=?, created_at=NOW() WHERE id=?',
[$sev, $title, $msg, $existing[0]['id']]
);
}
} else {
JarvisDB::query(
'INSERT INTO alerts (alert_type, title, message, severity, source_key, auto_resolve) VALUES (?,?,?,?,?,1)',
['agent', $title, $msg, $sev, $key]
);
}
}
// ── Route ─────────────────────────────────────────────────────────────────────
if ($method === 'GET') {
// Rate-limit agent alert refresh to once per 60 seconds via kb_facts lock
$last_refresh = JarvisDB::query("SELECT fact_value FROM kb_facts WHERE category='agent' AND fact_key='alert_refresh' LIMIT 1");
$last_ts = !empty($last_refresh) ? (int)$last_refresh[0]['fact_value'] : 0;
if (time() - $last_ts >= 60) {
JarvisDB::query(
"INSERT INTO kb_facts (category, fact_key, fact_value, host) VALUES ('agent', 'alert_refresh', ?, 'local')
ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value), updated_at=NOW()",
[time()]
);
refresh_agent_alerts();
}
$alerts = JarvisDB::query(
'SELECT * FROM alerts WHERE resolved=0 ORDER BY severity DESC, created_at DESC LIMIT 30'
);
echo json_encode(['alerts' => $alerts ?: [], 'count' => count($alerts ?: [])]);
} elseif ($method === 'POST' && ($action === 'resolve' || ($data['action'] ?? '') === 'resolve')) {
$id = (int)($data['id'] ?? 0);
JarvisDB::query('UPDATE alerts SET resolved=1, resolved_at=NOW() WHERE id=?', [$id]);
echo json_encode(['success' => true]);
} elseif ($method === 'POST') {
JarvisDB::query(
'INSERT INTO alerts (alert_type, title, message, severity) VALUES (?,?,?,?)',
[$data['type'] ?? 'system', $data['title'] ?? 'Alert', $data['message'] ?? '', $data['severity'] ?? 'info']
);
echo json_encode(['success' => true]);
}
+53
View File
@@ -0,0 +1,53 @@
<?php
// Auth endpoint
if ($method === 'POST') {
$username = trim($data['username'] ?? '');
$password = $data['password'] ?? '';
if (!$username || !$password) {
http_response_code(400);
echo json_encode(['error' => 'Credentials required']);
exit;
}
$user = JarvisDB::single(
'SELECT * FROM users WHERE username = ?', [$username]
);
if ($user && password_verify($password, $user['password_hash'])) {
$token = bin2hex(random_bytes(32));
$_SESSION['jarvis_token'] = $token;
$_SESSION['jarvis_user_id'] = $user['id'];
$_SESSION['jarvis_name'] = $user['display_name'];
JarvisDB::execute(
'UPDATE users SET last_seen = NOW() WHERE id = ?', [$user['id']]
);
// Use stored address preference for greeting
$addrRow = JarvisDB::query(
"SELECT pref_value FROM kb_preferences WHERE pref_key='user_title' LIMIT 1"
);
$userAddr = $addrRow[0]['pref_value'] ?? $user['display_name'];
echo json_encode([
'success' => true,
'token' => $token,
'display_name' => $userAddr,
'greeting' => "Welcome back, {$userAddr}. All systems are online and awaiting your command.",
]);
} else {
http_response_code(401);
echo json_encode(['error' => 'Invalid credentials']);
}
} elseif ($method === 'DELETE') {
session_destroy();
echo json_encode(['success' => true, 'message' => 'Session terminated.']);
} else {
// Check session status
$loggedIn = !empty($_SESSION['jarvis_token']);
echo json_encode([
'authenticated' => $loggedIn,
'name' => $_SESSION['jarvis_name'] ?? null,
]);
}
+717
View File
@@ -0,0 +1,717 @@
<?php
// JARVIS Chat — Intent Engine → Ollama → Claude fallback chain
if ($method !== 'POST') {
echo json_encode(['error' => 'POST only']); exit;
}
$message = trim($data['message'] ?? '');
$sessionId = $data['session_id'] ?? session_id();
$panelCtx = $data['context'] ?? null; // Panel item selected by user (VM, device, alert, etc.)
if (!$message) {
echo json_encode(['error' => 'Message required']); exit;
}
// Build context string from selected panel item
$ctxSnippet = '';
if ($panelCtx && is_array($panelCtx)) {
$type = $panelCtx['type'] ?? '';
switch ($type) {
case 'vm':
$ctxSnippet = sprintf(
'[Selected VM: %s (VMID %s) — Status: %s, CPU: %s%%, RAM: %s/%sMB, Type: %s]',
$panelCtx['name'] ?? '?',
$panelCtx['vmid'] ?? '?',
$panelCtx['status'] ?? '?',
$panelCtx['cpu'] ?? '?',
$panelCtx['mem_mb'] ?? '?',
$panelCtx['maxmem_mb'] ?? '?',
$panelCtx['type_label'] ?? 'qemu'
);
break;
case 'network':
$ctxSnippet = sprintf(
'[Selected Device: %s — IP: %s, Status: %s%s]',
$panelCtx['name'] ?? '?',
$panelCtx['ip'] ?? '?',
$panelCtx['status'] ?? '?',
$panelCtx['latency'] ? ', Latency: ' . $panelCtx['latency'] . 'ms' : ''
);
break;
case 'alert':
$ctxSnippet = sprintf(
'[Selected Alert: %s — Severity: %s, Message: %s]',
$panelCtx['title'] ?? '?',
$panelCtx['severity'] ?? '?',
$panelCtx['message'] ?? '?'
);
break;
case 'news':
$ctxSnippet = sprintf(
'[Selected News Story: "%s" — Source: %s, Published: %s]',
$panelCtx['title'] ?? '?',
$panelCtx['source'] ?? '?',
$panelCtx['pub'] ?? 'unknown'
);
break;
case 'ha':
$ctxSnippet = sprintf(
'[Selected Home Device: %s (%s) — Current State: %s]',
$panelCtx['name'] ?? '?',
$panelCtx['entity_id'] ?? '?',
$panelCtx['state'] ?? '?'
);
break;
}
}
// Save user message
JarvisDB::insert(
'INSERT INTO conversations (session_id, role, content) VALUES (?,?,?)',
[$sessionId, 'user', $message]
);
// Conversation history
$history = JarvisDB::query(
'SELECT role, content FROM conversations WHERE session_id=? ORDER BY created_at DESC LIMIT 10',
[$sessionId]
);
$history = array_reverse($history);
$reply = null;
$source = 'unknown';
// ── Load user address preference ──────────────────────────────────────────
$prefRows = JarvisDB::query(
"SELECT pref_key, pref_value FROM kb_preferences WHERE pref_key IN ('user_name','user_title')"
);
$prefs = [];
foreach ($prefRows ?? [] as $p) { $prefs[$p['pref_key']] = $p['pref_value']; }
$userName = $prefs['user_name'] ?? 'Myron';
$userTitle = $prefs['user_title'] ?? 'Mr. Blair';
// Address to use in responses
$userAddr = $userTitle;
// ── Tier 0.1: Name preference change ─────────────────────────────────────
if (!$reply) {
$lc = strtolower($message);
// Patterns: "call me X", "refer to me as X", "address me as X", "my name is X",
// "don't call me X", "stop calling me X", "just call me X"
if (preg_match(
'/(?:(?:please\s+)?(?:just\s+)?(?:call|refer\s+to|address)\s+me\s+(?:as\s+)?|my\s+name\s+is\s+|i(?:\s+prefer|\s+go\s+by|\s+want\s+to\s+be\s+called)\s+)([A-Za-z][\w\s\-\'\.]{0,29})/i',
$message, $m
)) {
$newName = trim(preg_replace('/\s+/', ' ', $m[1]));
// Strip trailing punctuation
$newName = rtrim($newName, '.!?,;');
if (strlen($newName) >= 2 && strlen($newName) <= 30) {
JarvisDB::execute(
"INSERT INTO kb_preferences (pref_key, pref_value) VALUES ('user_title', ?)
ON DUPLICATE KEY UPDATE pref_value=VALUES(pref_value)",
[$newName]
);
$userTitle = $newName;
$userAddr = $newName;
$reply = "Understood. I'll address you as {$newName} from now on.";
$source = 'intent:name_pref';
}
} elseif (preg_match(
'/(?:don\'?t|stop|no\s+(?:more|longer))\s+call(?:ing)?\s+me\s+([A-Za-z][\w\s\-\'\.]{0,29})/i',
$message, $m
)) {
// "don't call me {$userAddr}" — switch to first name
JarvisDB::execute(
"INSERT INTO kb_preferences (pref_key, pref_value) VALUES ('user_title', ?)
ON DUPLICATE KEY UPDATE pref_value=VALUES(pref_value)",
[$userName]
);
$userTitle = $userName;
$userAddr = $userName;
$reply = "Of course. I'll call you {$userName} going forward.";
$source = 'intent:name_pref';
}
}
// ── Tier 0: Home Assistant Control ───────────────────────────────────────
// Uses entity_map stored by facts_collector to resolve natural language → entity
$haEntityMapRow = JarvisDB::query(
'SELECT fact_value FROM kb_facts WHERE category=? AND fact_key=? LIMIT 1',
['ha', 'entity_map']
);
$haEntityMap = ($haEntityMapRow && !empty($haEntityMapRow[0]['fact_value']))
? (json_decode($haEntityMapRow[0]['fact_value'], true) ?? [])
: [];
// Scene keywords for one-shot activations (no on/off needed)
$sceneKeywords = [
'good night' => 'scene.good_night',
'goodnight' => 'scene.good_night',
'good morning' => 'scene.good_morning',
'goodmorning' => 'scene.good_morning',
'goodbye' => 'scene.goodbye',
'bye' => 'scene.goodbye',
'kitchen lights on' => 'scene.kitchen_lights_on',
'kitchen on' => 'scene.kitchen_lights_on',
'kitchen lights off' => 'scene.kitchen_lights_off',
'kitchen off' => 'scene.kitchen_lights_off',
'front porch lights' => 'scene.outdoors_front_porch_lights',
'porch scene' => 'scene.outdoors_front_porch_lights',
'office dawn' => 'scene.office_ocean_dawn',
];
$msgLower = strtolower(trim($message));
// Check for scene activation first
$sceneId = null;
foreach ($sceneKeywords as $kw => $sid) {
if (strpos($msgLower, $kw) !== false) {
$sceneId = $sid;
break;
}
}
if ($sceneId) {
$haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123';
$haToken = defined('HA_TOKEN') ? HA_TOKEN : '';
$ch = curl_init($haUrl . '/api/services/scene/turn_on');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['entity_id' => $sceneId]),
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $haToken, 'Content-Type: application/json'],
CURLOPT_TIMEOUT => 8, CURLOPT_CONNECTTIMEOUT => 3,
]);
$haResp = curl_exec($ch);
$haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($haCode === 200) {
$sceneName = ucwords(str_replace(['scene.', '_'], ['', ' '], $sceneId));
$reply = "Activating {$sceneName}, {$userAddr}.";
$source = 'ha:scene';
}
}
// Check for device on/off control
if (!$reply && preg_match('/(turn|switch|put|set)\s+(on|off)/i', $message, $actionMatch)
|| (!$reply && preg_match('/(lights?|lamps?|plugs?|strips?)\s+(on|off)/i', $message, $actionMatch))) {
$turnOn = (bool) preg_match('/\bon\b/i', $message);
$turnOff = (bool) preg_match('/\boff\b/i', $message);
$haService = ($turnOn && !$turnOff) ? 'turn_on' : ($turnOff ? 'turn_off' : null);
if ($haService && !empty($haEntityMap)) {
// Find best matching entity
$bestEid = null;
$bestScore = 0;
$bestName = '';
// Special: "all lights" / "everything"
if (preg_match('/(all|everything|every)/i', $message)) {
$bestEid = '__all_lights__';
$bestName = 'All lights';
$bestScore = 1;
}
if (!$bestEid) {
// Build search terms from message (remove control words with word boundaries)
$searchMsg = preg_replace('/\b(turn|switch|put|set|the|my|all|please|jarvis|on|off|lights?|lamps?)\b/i', ' ', $msgLower);
$searchMsg = trim(preg_replace('/\s+/', ' ', $searchMsg));
foreach ($haEntityMap as $eid => $info) {
$nameLower = strtolower($info['name']);
// Score: exact substring match = 10, word overlap = words matched
if ($searchMsg && strpos($nameLower, $searchMsg) !== false) {
$score = 10;
} else {
$words = array_filter(explode(' ', $searchMsg));
$score = 0;
foreach ($words as $w) {
if (strlen($w) > 2 && strpos($nameLower, $w) !== false) $score++;
}
}
if ($score > $bestScore) {
$bestScore = $score;
$bestEid = $eid;
$bestName = $info['name'];
}
}
}
if ($bestEid && $bestScore > 0) {
$haUrl = defined('HA_URL') ? HA_URL : 'http://10.48.200.97:8123';
$haToken = defined('HA_TOKEN') ? HA_TOKEN : '';
if ($bestEid === '__all_lights__') {
// Turn all lights on/off via domain targeting
$lightIds = array_keys(array_filter($haEntityMap, fn($e) => $e['domain'] === 'light'));
$payload = ['entity_id' => $lightIds];
$svcDomain = 'light';
} else {
$domain = $haEntityMap[$bestEid]['domain'] ?? 'switch';
$payload = ['entity_id' => $bestEid];
$svcDomain = $domain;
}
$ch = curl_init($haUrl . '/api/services/' . $svcDomain . '/' . $haService);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $haToken, 'Content-Type: application/json'],
CURLOPT_TIMEOUT => 8, CURLOPT_CONNECTTIMEOUT => 3,
]);
$haResp = curl_exec($ch);
$haCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($haCode === 200) {
$action = ($haService === 'turn_on') ? 'activated' : 'deactivated';
$label = ($bestEid === '__all_lights__') ? 'All lights' : $bestName;
$reply = "{$label} {$action}, {$userAddr}.";
$source = 'ha:' . $haService;
}
}
}
}
// Status query for HA entities
if (!$reply && preg_match('/(is|are|what.s|status|state).*(on|off|light|switch|plug|strip|mower|garage|living|kitchen|office|bedroom|porch|carport|driveway)/i', $message)
&& !preg_match('/(turn|switch|put)/i', $message)) {
// Status query - find the entity and report its state
$searchMsg = preg_replace('/\b(is|are|the|my|status|what|state|of|jarvis)\b/i', ' ', $msgLower);
$searchMsg = trim(preg_replace('/\s+/', ' ', $searchMsg));
$bestEid = null; $bestScore = 0; $bestName = ''; $bestState = '';
foreach ($haEntityMap as $eid => $info) {
$nameLower = strtolower($info['name']);
$words = array_filter(explode(' ', $searchMsg));
$score = 0;
foreach ($words as $w) {
if (strlen($w) > 2 && strpos($nameLower, $w) !== false) $score++;
}
if ($score > $bestScore) {
$bestScore = $score; $bestEid = $eid;
$bestName = $info['name']; $bestState = $info['state'];
}
}
if ($bestEid && $bestScore > 0) {
$reply = "The {$bestName} is currently {$bestState}, {$userAddr}.";
$source = 'ha:status';
}
}
// ── Tier 0.5: Network Device Management ──────────────────────────────────
if (!$reply) {
// Flow state stored in kb_facts (session_write_close() is called before this runs)
$flowKey = substr(session_id() ?: 'anon', 0, 32);
// Expire stale chat flows older than 10 minutes
JarvisDB::execute("DELETE FROM kb_facts WHERE category='chat_flow' AND updated_at < DATE_SUB(NOW(), INTERVAL 10 MINUTE)");
$flowRows = JarvisDB::query("SELECT fact_value FROM kb_facts WHERE category='chat_flow' AND fact_key=? LIMIT 1", [$flowKey]);
$devState = !empty($flowRows) ? json_decode($flowRows[0]['fact_value'], true) : null;
// Continue an active multi-step add-device flow
if ($devState && isset($devState['step'])) {
switch ($devState['step']) {
case 'waiting_ip':
if (preg_match('/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/', $message, $m)) {
$newState = array_merge($devState ?? [], ['ip' => $m[1], 'step' => 'waiting_name']);
JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode($newState)]);
$reply = "Got it — {$m[1]}. What should I call this device?";
} else {
$reply = "Please give me a valid IP address such as 192.168.1.100.";
}
$source = 'device:flow';
break;
case 'waiting_name':
$cleanName = preg_replace('/[^a-zA-Z0-9 \-_()\.]/u', '', trim($message));
$newState = array_merge($devState ?? [], ['name' => $cleanName, 'step' => 'waiting_type']);
JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode($newState)]);
$reply = "Understood — '{$cleanName}'. What type of device is it? Options: server, camera, printer, router, phone, IoT, or say skip.";
$source = 'device:flow';
break;
case 'waiting_type':
$rawType = strtolower(trim($message));
$allowedTypes = ['server','camera','printer','router','phone','iot','voip','switch','access point','unknown'];
$devType = in_array($rawType, $allowedTypes) ? $rawType : ($rawType === 'skip' ? 'unknown' : 'unknown');
$devIp = $devState['ip'] ?? '';
$devName = $devState['name'] ?? '';
JarvisDB::execute("DELETE FROM kb_facts WHERE category='chat_flow' AND fact_key=?", [$flowKey]);
if ($devIp && $devName) {
JarvisDB::execute(
"INSERT INTO network_devices (ip, alias, device_type, status, last_seen)
VALUES (?,?,?,'unknown',NOW())
ON DUPLICATE KEY UPDATE alias=VALUES(alias), device_type=VALUES(device_type)",
[$devIp, $devName, $devType]
);
$reply = "Device '{$devName}' at {$devIp} has been added to network monitoring as a {$devType}. It will appear in the Network Status panel and I'll begin tracking its status.";
} else {
$reply = "Something went wrong with the device registration. Please try again.";
}
$source = 'device:added';
break;
default:
JarvisDB::execute("DELETE FROM kb_facts WHERE category='chat_flow' AND fact_key=?", [$flowKey]);
}
}
// Start a new add-device flow or handle inline add
if (!$reply && preg_match('/\b(add|create|register|monitor)\s+(a\s+)?(new\s+)?(device|network device|server|node|host)\b/i', $message)) {
$hasIp = preg_match('/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/', $message, $ipM);
$hasName = preg_match('/\b(?:called|named|as)\s+["\']?([^"\']+?)["\']?(?:\s+type\b|\s*$)/i', $message, $nameM);
$hasType = preg_match('/\btype\s+(\w[\w ]*)/i', $message, $typeM);
if ($hasIp && $hasName) {
$devIp = $ipM[1];
$devName = trim($nameM[1]);
$devType = $hasType ? trim($typeM[1]) : 'unknown';
JarvisDB::execute(
"INSERT INTO network_devices (ip, alias, device_type, status, last_seen)
VALUES (?,?,?,'unknown',NOW())
ON DUPLICATE KEY UPDATE alias=VALUES(alias), device_type=VALUES(device_type)",
[$devIp, $devName, $devType]
);
$reply = "Device '{$devName}' at {$devIp} has been added to network monitoring.";
$source = 'device:added';
} elseif ($hasIp) {
JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode(['step'=>'waiting_name','ip'=>$ipM[1]])]);
$reply = "I found the address {$ipM[1]}. What should I call this device?";
$source = 'device:flow';
} else {
JarvisDB::execute("INSERT INTO kb_facts (category,fact_key,fact_value,host) VALUES('chat_flow',?,?,'local') ON DUPLICATE KEY UPDATE fact_value=VALUES(fact_value),updated_at=NOW()", [$flowKey, json_encode(['step'=>'waiting_ip'])]);
$reply = "I'll add a new device to network monitoring. What's its IP address?";
$source = 'device:flow';
}
}
// Remove / delete device
if (!$reply && preg_match('/\b(remove|delete|stop monitoring|unmonitor)\s+(?:device\s+)?(.+)/i', $message, $dm)) {
$target = trim($dm[2]);
$isIp = preg_match('/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/', $target);
$rows = $isIp
? JarvisDB::query('SELECT ip, alias FROM network_devices WHERE ip=?', [$target])
: JarvisDB::query('SELECT ip, alias FROM network_devices WHERE alias LIKE ?', ['%' . $target . '%']);
if (count($rows)) {
$dev = $rows[0];
JarvisDB::execute('DELETE FROM network_devices WHERE ip=?', [$dev['ip']]);
$label = $dev['alias'] ?? $dev['ip'];
$reply = "Device '{$label}' at {$dev['ip']} has been removed from network monitoring.";
$source = 'device:removed';
}
}
// Update device name or type
if (!$reply && preg_match('/\b(rename|update|change)\s+(?:device\s+)?(.+?)\s+to\s+(.+)/i', $message, $um)) {
$target = trim($um[2]);
$newVal = trim($um[3]);
$rows = JarvisDB::query('SELECT ip, alias FROM network_devices WHERE alias LIKE ?', ['%' . $target . '%']);
if (count($rows)) {
$dev = $rows[0];
JarvisDB::execute('UPDATE network_devices SET alias=? WHERE ip=?', [$newVal, $dev['ip']]);
$reply = "Device at {$dev['ip']} has been renamed to '{$newVal}'.";
$source = 'device:updated';
}
}
// List devices
if (!$reply && preg_match('/\b(list|show|what are|tell me|display)\s+(?:the\s+|my\s+|all\s+)?(?:network\s+)?(device|devices|server|servers|node|nodes|host|hosts|monitored)\b/i', $message)) {
$rows = JarvisDB::query(
"SELECT alias, ip, device_type, status FROM network_devices WHERE alias IS NOT NULL ORDER BY alias"
);
if (count($rows)) {
$items = array_map(fn($r) =>
($r['alias'] ?? $r['ip']) . ' at ' . $r['ip'] . ' — ' . ($r['status'] ?? 'unknown'),
$rows
);
$reply = "Monitored devices: " . implode('; ', $items) . '.';
} else {
$reply = "No named devices in network monitoring yet. Say 'add a device' to get started.";
}
$source = 'device:list';
}
}
// ── Tier 0.8: Weather & News intents ─────────────────────────────────────
if (!$reply && preg_match('/\b(weather|forecast|temperature|temp|rain|snow|storm|outside|how.?s it (out|look)|what.?s it like outside)\b/i', $message)) {
$wRow = JarvisDB::query("SELECT data FROM api_cache WHERE cache_key='weather' LIMIT 1");
if ($wRow && !empty($wRow[0]['data'])) {
$wd = json_decode($wRow[0]['data'], true);
$c = $wd['current'] ?? [];
$fc = $wd['forecast'] ?? [];
$today = $fc[0] ?? [];
$tomorrow = $fc[1] ?? [];
$reply = sprintf(
'Current conditions: %s %s, %d°F (feels like %d°F), humidity %d%%, wind %d mph. ' .
'Today\'s range: %d%d°F. ' .
'Tomorrow: %s, %d%d°F, %d%% chance of rain.',
$c['icon'] ?? '',
$c['desc'] ?? '',
$c['temp'] ?? 0,
$c['feels'] ?? 0,
$c['humidity'] ?? 0,
$c['wind'] ?? 0,
$today['low'] ?? 0,
$today['high'] ?? 0,
$tomorrow['desc'] ?? '',
$tomorrow['low'] ?? 0,
$tomorrow['high'] ?? 0,
$tomorrow['rain_pct'] ?? 0
);
$source = 'intent:weather';
}
}
if (!$reply && preg_match('/\b(news|headlines|latest|what.?s happening|current events|whats new)\b/i', $message)) {
$nRow = JarvisDB::query("SELECT data FROM api_cache WHERE cache_key='news' LIMIT 1");
if ($nRow && !empty($nRow[0]['data'])) {
$nd = json_decode($nRow[0]['data'], true);
$cats = $nd['categories'] ?? [];
$lines = [];
foreach ($cats as $cat => $articles) {
if (!empty($articles)) {
$top3 = array_slice($articles, 0, 3);
foreach ($top3 as $a) {
$lines[] = '[' . $a['source'] . '] ' . $a['title'];
}
}
}
if ($lines) {
$reply = "Here are the latest headlines, {$userAddr}: " . implode(' — ', array_slice($lines, 0, 5)) . '.';
} else {
$reply = 'News feed is still loading, Sir. Please try again in a moment.';
}
$source = 'intent:news';
}
}
// ── Tier 1: Intent Engine (instant, no LLM) ───────────────────────────────
if (!$reply) {
$matched = KBEngine::match($message);
if ($matched && $matched['action'] === 'response') {
$reply = $matched['reply'];
$source = 'intent:' . $matched['intent'];
}
}
// ── Tier 2: Ollama local LLM (fast local fallback) ───────────────────────
if (!$reply && defined('OLLAMA_HOST') && OLLAMA_HOST) {
$ollamaHost = OLLAMA_HOST;
$ollamaModel = defined('OLLAMA_MODEL_PRIMARY') ? OLLAMA_MODEL_PRIMARY : 'llama3.2:1b';
$timeout = defined('OLLAMA_TIMEOUT') ? OLLAMA_TIMEOUT : 45;
$ollamaMessages = [];
$ollamaMessages[] = ['role' => 'system', 'content' =>
"You are JARVIS, AI assistant for {$userName}. Address him as \"{$userAddr}\". " .
'British butler tone. Be concise — 1-3 sentences max. Today: ' . date('D M j Y g:i A') . '.'];
$ollamaMessages[] = ['role' => 'user', 'content' => $ctxSnippet ? $ctxSnippet . "\n" . $message : $message];
$promptParts = [];
foreach ($ollamaMessages as $msg) {
$role = ucfirst($msg['role']);
$promptParts[] = "{$role}: {$msg['content']}";
}
$promptParts[] = 'Assistant:';
$promptStr = implode("\n\n", $promptParts);
$payload = [
'model' => $ollamaModel,
'prompt' => $promptStr,
'stream' => false,
'options' => ['temperature' => 0.7, 'num_predict' => 150],
];
$ch = curl_init($ollamaHost . '/api/generate');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code === 200 && $resp) {
$decoded = json_decode($resp, true);
$text = $decoded['response'] ?? null;
if ($text) {
$reply = trim($text);
$source = 'ollama:' . $ollamaModel;
}
}
// Silently fall through to Groq if Ollama fails or times out
}
// ── Tier 3: Groq AI (cloud — fast 70B + built-in web search) ─────────────
if (!$reply && defined('GROQ_API_KEY') && GROQ_API_KEY) {
$needsSearch = (bool) preg_match(
'/\b(latest|current|today|right now|live|breaking|score|who won|what happened|price|stock|market|exchange rate|news about|weather in|forecast for|recently|just now|this (week|month|year))\b/i',
$message
);
$groqModel = $needsSearch ? GROQ_MODEL_SEARCH : GROQ_MODEL_GENERAL;
$groqMessages = [['role' => 'system', 'content' =>
"You are JARVIS — Just A Rather Very Intelligent System — the AI of {$userName} " .
"(address him as \"{$userAddr}\"). Formal, efficient, British butler tone. " .
'Be concise — 2-4 sentences unless detail is explicitly requested. Today: ' . date('D M j Y g:i A T') . '.'],
];
foreach (array_slice($history, -6) as $h) {
$groqMessages[] = ['role' => $h['role'], 'content' => $h['content']];
}
$userMsg = $ctxSnippet ? $ctxSnippet . "\n" . $message : $message;
$groqMessages[] = ['role' => 'user', 'content' => $userMsg];
$ch = curl_init('https://api.groq.com/openai/v1/chat/completions');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode([
'model' => $groqModel,
'messages' => $groqMessages,
'max_tokens' => 400,
'temperature' => 0.7,
]),
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . GROQ_API_KEY,
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => GROQ_TIMEOUT,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => false,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code === 200 && $resp) {
$decoded = json_decode($resp, true);
$text = $decoded['choices'][0]['message']['content'] ?? null;
if ($text) {
$reply = trim($text);
$source = 'groq:' . $groqModel;
}
}
// Silently fall through to Claude if Groq fails
}
// ── Tier 4: Claude API (final fallback) ──────────────────────────────────
if (!$reply) {
// Live context for Claude
$systemContext = '';
try {
$memLines = file('/proc/meminfo');
$mem = [];
foreach ($memLines as $l) {
if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mem[$m[1]] = (int)$m[2];
}
$memPct = $mem['MemTotal'] > 0
? round((($mem['MemTotal'] - $mem['MemAvailable']) / $mem['MemTotal']) * 100)
: '?';
$sec = (int) file_get_contents('/proc/uptime');
$uptime = intdiv($sec, 86400) . 'd ' . intdiv($sec % 86400, 3600) . 'h';
$load = explode(' ', file_get_contents('/proc/loadavg'));
$systemContext .= "Jarvis server (165.22.1.228 DO): Memory {$memPct}%, Uptime {$uptime}, Load {$load[0]}.\n";
} catch (Exception $e) {}
$alerts = JarvisDB::query(
'SELECT title, severity FROM alerts WHERE resolved=0 ORDER BY created_at DESC LIMIT 3'
);
if ($alerts) {
$systemContext .= 'Active alerts: ' . implode('; ', array_map(fn($a) => "[{$a['severity']}] {$a['title']}", $alerts)) . ".\n";
}
$kbContext = KBEngine::getContextSummary();
$systemPrompt = "You are JARVIS — Just A Rather Very Intelligent System — the AI of {$userName} (address him as \"{$userAddr}\"). You manage his home network, servers, Proxmox VMs, websites, and Home Assistant smart home. Your personality: formal, efficient, British butler — like the AI in Iron Man. Be concise. Use technical precision.
Infrastructure:
- Jarvis Server: 165.22.1.228 (DigitalOcean, CyberPanel/OLS, Ubuntu 24.04)
- Ollama AI VM: 10.48.200.95 (local LLM server, llama3.1:8b + 70b)
- Proxmox Host: 10.48.200.90 (manages all VMs)
- Home Assistant: 10.48.200.97:8123
- FusionPBX: 134.209.72.226 / fusion.orbishosting.com (production DO server), Yealink T48S: 10.48.200.43
- Digital Ocean: 165.22.1.228 (tomsjavajive.com, epictravelexpeditions.com, tomtomgames.com, parkerslingshotrentals.com, orbishosting.com)
- Network: 10.48.200.0/24, FortiGate firewall
Live data:
{$systemContext}" . ($kbContext ? "\nKnowledge base:\n{$kbContext}" : '') . "
Today: " . date('l, F j Y, g:i A T') . "
Respond as JARVIS. Voice readout: under 3 sentences unless detail is requested. For system status, interpret the data and give an assessment — don't just recite numbers.";
$messages = [];
foreach ($history as $h) {
$messages[] = ['role' => $h['role'], 'content' => $h['content']];
}
$messages[] = ['role' => 'user', 'content' => $ctxSnippet ? $ctxSnippet . "\n" . $message : $message];
if (!defined('CLAUDE_API_KEY') || CLAUDE_API_KEY === 'sk-ant-YOUR_KEY_HERE') {
$reply = "My AI core requires a valid API key, {$userAddr}. I can still display all system dashboards and respond to local commands.";
$source = 'fallback:no-key';
} else {
$payload = [
'model' => CLAUDE_MODEL,
'max_tokens' => CLAUDE_MAX_TOKENS,
'system' => $systemPrompt,
'messages' => $messages,
];
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'x-api-key: ' . CLAUDE_API_KEY,
'anthropic-version: 2023-06-01',
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => false,
]);
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code === 200) {
$decoded = json_decode($resp, true);
$reply = $decoded['content'][0]['text'] ?? null;
$source = 'claude';
} else {
$err = json_decode($resp, true);
$errMsg = $err['error']['message'] ?? '';
if (stripos($errMsg, 'credit') !== false || stripos($errMsg, 'balance') !== false) {
$reply = "Claude is not currently connected, {$userAddr}. Local AI and intent systems remain operational.";
} else {
$reply = 'My AI core returned an error, Sir. Code: ' . $code . '. ' . $errMsg;
}
$source = 'claude:error';
}
}
}
// ── Final fallback ─────────────────────────────────────────────────────────
if (!$reply) {
$reply = "My systems are processing your request, {$userAddr}. Please try again momentarily.";
$source = 'fallback';
}
// Save reply and learn
JarvisDB::insert(
'INSERT INTO conversations (session_id, role, content) VALUES (?,?,?)',
[$sessionId, 'assistant', $reply]
);
KBEngine::learnFromConversation($message, $reply);
echo json_encode([
'reply' => $reply,
'source' => $source,
'session_id' => $sessionId,
'timestamp' => date('c'),
]);
+77
View File
@@ -0,0 +1,77 @@
<?php
// Digital Ocean server monitoring via SSH
function sshCommand(string $cmd): string {
$sshCmd = sprintf(
'sshpass -p %s ssh -o StrictHostKeyChecking=no -o ConnectTimeout=8 %s@%s %s 2>/dev/null',
escapeshellarg(DO_SSH_PASS),
escapeshellarg(DO_SSH_USER),
escapeshellarg(DO_SERVER_IP),
escapeshellarg($cmd)
);
return shell_exec($sshCmd) ?? '';
}
// Check if sshpass is available
$hasSshpass = trim(shell_exec('which sshpass 2>/dev/null'));
if (!$hasSshpass) {
echo json_encode([
'error' => 'sshpass not installed on Jarvis server. Run: sudo apt-get install sshpass',
'ip' => DO_SERVER_IP,
]);
exit;
}
// Gather DO server stats in one SSH session
$statsRaw = sshCommand("echo CPU:$(grep 'cpu ' /proc/stat | awk '{u=\$2+\$4; t=\$2+\$3+\$4+\$5; print u/t*100}');echo MEM_TOTAL:\$(grep MemTotal /proc/meminfo | awk '{print \$2}');echo MEM_FREE:\$(grep MemAvailable /proc/meminfo | awk '{print \$2}');echo UPTIME:\$(cat /proc/uptime | awk '{print int(\$1)}');echo DISK_USED:\$(df / | tail -1 | awk '{print \$5}');echo LOAD:\$(cat /proc/loadavg | awk '{print \$1}')");
$stats = [];
foreach (explode("\n", trim($statsRaw)) as $line) {
[$key, $val] = explode(':', $line, 2) + [null, null];
if ($key) $stats[$key] = trim($val ?? '');
}
// Get running services on DO
$services = sshCommand("systemctl is-active lsphp85 lshttpd nginx apache2 mysql mariadb php8.1-fpm 2>/dev/null | paste <(echo -e 'lsphp85\nlshttpd\nnginx\napache2\nmysql\nmariadb\nphp8.1-fpm') - | awk '{print \$1\":\"\$2}'");
$svcMap = [];
foreach (explode("\n", trim($services)) as $line) {
if (!$line) continue;
[$name, $status] = explode(':', $line, 2) + [null, null];
if ($name && $status && trim($status) === 'active') {
$svcMap[trim($name)] = true;
}
}
// Get disk usage per site
$siteDisk = sshCommand("du -sh /home/*/public_html 2>/dev/null | sort -h");
$sites = [];
foreach (explode("\n", trim($siteDisk)) as $line) {
if (preg_match('/^([\d.]+\w)\s+\/home\/([^\/]+)/', $line, $m)) {
$sites[$m[2]] = $m[1];
}
}
$cpuPct = isset($stats['CPU']) ? round((float)$stats['CPU'], 1) : null;
$memTotal = (int)($stats['MEM_TOTAL'] ?? 0);
$memFree = (int)($stats['MEM_FREE'] ?? 0);
$memUsed = $memTotal - $memFree;
$uptimeSec = (int)($stats['UPTIME'] ?? 0);
$uptimeDays = intdiv($uptimeSec, 86400);
$uptimeHrs = intdiv($uptimeSec % 86400, 3600);
echo json_encode([
'ip' => DO_SERVER_IP,
'reachable' => !empty($statsRaw),
'cpu_pct' => $cpuPct,
'memory' => [
'total_mb' => round($memTotal / 1024),
'used_mb' => round($memUsed / 1024),
'percent' => $memTotal > 0 ? round(($memUsed/$memTotal)*100,1) : 0,
],
'disk_used_pct' => $stats['DISK_USED'] ?? null,
'load_1m' => (float)($stats['LOAD'] ?? 0),
'uptime' => "{$uptimeDays}d {$uptimeHrs}h",
'services' => $svcMap,
'sites' => $sites,
'timestamp' => date('c'),
]);
+306
View File
@@ -0,0 +1,306 @@
<?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')]);
}
+77
View File
@@ -0,0 +1,77 @@
<?php
// Home Assistant endpoint — entities served from api_cache (refreshed every 5 min by cron)
// Live service calls (turn on/off) still go direct to HA.
function haRequest(string $path, string $method = 'GET', array $payload = []): ?array {
if (HA_TOKEN === 'YOUR_HA_TOKEN_HERE' || strpos(HA_URL, '10.48.200.X') !== false) {
return null;
}
$ch = curl_init(HA_URL . '/api' . $path);
$headers = [
'Authorization: Bearer ' . HA_TOKEN,
'Content-Type: application/json',
];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 8,
CURLOPT_CONNECTTIMEOUT => 4,
CURLOPT_SSL_VERIFYPEER => false,
]);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
}
$resp = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code >= 200 && $code < 300 && $resp) {
return json_decode($resp, true);
}
return null;
}
$configured = !(HA_TOKEN === 'YOUR_HA_TOKEN_HERE' || strpos(HA_URL, '10.48.200.X') !== false);
if (!$configured) {
echo json_encode(['configured' => false, 'message' => 'HA token not configured.', 'entities' => []]);
exit;
}
// Live service call (toggle device) — always direct to HA, never cached
if ($method === 'POST' && $action === 'service') {
$domain = $data['domain'] ?? '';
$service = $data['service'] ?? '';
$entity_id = $data['entity_id'] ?? '';
if ($domain && $service && $entity_id) {
$result = haRequest("/services/{$domain}/{$service}", 'POST', ['entity_id' => $entity_id]);
echo json_encode(['success' => true, 'result' => $result]);
} else {
echo json_encode(['error' => 'Missing domain/service/entity_id']);
}
exit;
}
// Serve entities from cache (populated by stats_cache.php cron, every 5 min)
$cached = JarvisDB::query(
'SELECT data, UNIX_TIMESTAMP(updated_at) as updated_ts FROM api_cache WHERE cache_key=? LIMIT 1',
['ha_entities']
);
if ($cached && !empty($cached[0]['data'])) {
$row = $cached[0];
$data_out = json_decode($row['data'], true);
$data_out['cache_age_s'] = (int)(time() - (int)$row['updated_ts']);
echo json_encode($data_out);
} else {
echo json_encode([
'configured' => true,
'ha_version' => 'unknown',
'location' => 'Home',
'entity_count' => 0,
'entities' => [],
'cached_at' => null,
'cache_age_s' => -1,
'message' => 'Cache warming up — first update in under 5 minutes.',
]);
}
+169
View File
@@ -0,0 +1,169 @@
<?php
// Network monitoring endpoint
function pingHost(string $ip): array {
$cmd = 'ping -c 1 -W 1 ' . escapeshellarg($ip) . ' 2>/dev/null';
$out = shell_exec($cmd);
$alive = $out && strpos($out, '1 received') !== false;
$latency = null;
if ($alive && preg_match('/time=([\d.]+)/', $out, $m)) {
$latency = (float)$m[1];
}
return ['alive' => $alive, 'latency_ms' => $latency];
}
function scanSubnet(string $prefix, int $timeout = 10): array {
$cmd = 'nmap -sn --host-timeout 1s ' . escapeshellarg($prefix . '.0/24') .
' -oG - 2>/dev/null | grep "Up$" | awk \'{print $2}\'';
$out = shell_exec($cmd) ?? '';
$hosts = array_filter(explode("\n", trim($out)));
return array_values($hosts);
}
function getArpTable(): array {
$out = shell_exec('arp -n 2>/dev/null') ?? '';
$devices = [];
foreach (explode("\n", trim($out)) as $line) {
if (preg_match('/^([\d.]+)\s+\w+\s+([\w:]+)/', $line, $m)) {
$devices[$m[1]] = strtolower($m[2]);
}
}
return $devices;
}
$action = $action ?? 'status';
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($action === 'scan') {
$liveHosts = scanSubnet(LOCAL_SUBNET, 8);
$arp = getArpTable();
$known = JarvisDB::query('SELECT * FROM network_devices');
$knownMap = [];
foreach ($known as $d) $knownMap[$d['ip']] = $d;
$devices = [];
foreach ($liveHosts as $ip) {
$mac = $arp[$ip] ?? null;
$known_dev = $knownMap[$ip] ?? null;
$nsOut = shell_exec('timeout 1 nslookup ' . escapeshellarg($ip) . ' 2>/dev/null | grep "name ="');
$hostname = null;
if ($nsOut && preg_match('/name = (.+)\./', $nsOut, $nm)) {
$hostname = rtrim($nm[1], '.');
}
$devices[] = [
'ip' => $ip,
'mac' => $mac,
'hostname' => $hostname,
'alias' => $known_dev['alias'] ?? null,
'type' => $known_dev['device_type'] ?? 'unknown',
'status' => 'online',
];
JarvisDB::execute(
'INSERT INTO network_devices (ip, mac, hostname, status, last_seen) VALUES (?,?,?,\'online\',NOW())
ON DUPLICATE KEY UPDATE mac=VALUES(mac), hostname=VALUES(hostname), status=\'online\', last_seen=NOW()',
[$ip, $mac, $hostname]
);
}
foreach ($knownMap as $ip => $dev) {
if (!in_array($ip, $liveHosts)) {
JarvisDB::execute('UPDATE network_devices SET status=\'offline\' WHERE ip=?', [$ip]);
}
}
echo json_encode(['devices' => $devices, 'count' => count($devices), 'scanned_at' => date('c')]);
} elseif ($action === 'add' && $method === 'POST') {
$ip = filter_var($data['ip'] ?? '', FILTER_VALIDATE_IP);
$alias = substr(trim($data['alias'] ?? ''), 0, 100);
$type = preg_replace('/[^a-z0-9_\-]/', '', strtolower($data['type'] ?? 'device'));
if (!$ip) { echo json_encode(['error' => 'Invalid IP address']); exit; }
if (!$alias) { echo json_encode(['error' => 'Name is required']); exit; }
JarvisDB::execute(
'INSERT INTO network_devices (ip, alias, device_type, status) VALUES (?,?,?,\'unknown\')
ON DUPLICATE KEY UPDATE alias=VALUES(alias), device_type=VALUES(device_type)',
[$ip, $alias, $type]
);
echo json_encode(['success' => true]);
} elseif ($action === 'delete' && $method === 'POST') {
$ip = filter_var($data['ip'] ?? '', FILTER_VALIDATE_IP);
if (!$ip) { echo json_encode(['error' => 'Invalid IP']); exit; }
// Don't allow deleting agent-managed entries
$isAgent = JarvisDB::query('SELECT id FROM registered_agents WHERE ip_address=? LIMIT 1', [$ip]);
if (!empty($isAgent)) { echo json_encode(['error' => 'Cannot delete agent-managed device']); exit; }
JarvisDB::execute('DELETE FROM network_devices WHERE ip=?', [$ip]);
echo json_encode(['success' => true]);
} else {
// Status: unified device list from agents + user-managed DB entries + external services
$devices = [];
// Mark agents offline if not heard from in 2 minutes
JarvisDB::execute(
'UPDATE registered_agents SET status="offline" WHERE last_seen < DATE_SUB(NOW(), INTERVAL 2 MINUTE) AND status = "online"'
);
// 1. Agent-based devices — status from heartbeat, no ping from DO needed
$agents = JarvisDB::query(
'SELECT agent_id, hostname, ip_address, status, last_seen, agent_type FROM registered_agents ORDER BY hostname'
);
$agentIPs = [];
foreach ($agents as $ag) {
$agentIPs[] = $ag['ip_address'];
$devices[] = [
'ip' => $ag['ip_address'],
'name' => $ag['hostname'],
'type' => 'agent',
'agent_id' => $ag['agent_id'],
'agent_type' => $ag['agent_type'],
'alive' => $ag['status'] === 'online',
'status' => $ag['status'],
'last_seen' => $ag['last_seen'],
'source' => 'agent',
'deletable' => false,
];
}
// 2. User-managed devices from DB (named/aliased entries not covered by agents)
$pinned = JarvisDB::query(
'SELECT ip, alias, device_type, status, last_seen FROM network_devices
WHERE alias IS NOT NULL AND alias != "" ORDER BY alias'
);
foreach ($pinned as $dev) {
if (in_array($dev['ip'], $agentIPs)) continue; // agent already covers this IP
$ping = pingHost($dev['ip']);
$newStatus = $ping['alive'] ? 'online' : 'offline';
JarvisDB::execute(
'UPDATE network_devices SET status=?, last_seen=NOW() WHERE ip=?',
[$newStatus, $dev['ip']]
);
$devices[] = [
'ip' => $dev['ip'],
'name' => $dev['alias'],
'type' => $dev['device_type'] ?: 'device',
'alive' => $ping['alive'],
'latency_ms' => $ping['latency_ms'],
'status' => $newStatus,
'last_seen' => $dev['last_seen'],
'source' => 'db',
'deletable' => true,
];
}
// 3. External services we can actually ping from DO
$external = [
['ip' => '134.209.72.226', 'name' => 'FusionPBX DO', 'type' => 'server'],
];
foreach ($external as $host) {
if (in_array($host['ip'], $agentIPs)) continue;
$ping = pingHost($host['ip']);
$devices[] = array_merge($host, [
'alive' => $ping['alive'],
'latency_ms' => $ping['latency_ms'],
'status' => $ping['alive'] ? 'online' : 'offline',
'source' => 'static',
'deletable' => false,
]);
}
echo json_encode(['devices' => $devices, 'timestamp' => date('c')]);
}
+20
View File
@@ -0,0 +1,20 @@
<?php
// News endpoint — serves from api_cache (refreshed every 30 min by cron)
$cached = JarvisDB::query(
'SELECT data, UNIX_TIMESTAMP(updated_at) as ts FROM api_cache WHERE cache_key=? LIMIT 1',
['news']
);
if ($cached && !empty($cached[0]['data'])) {
$out = json_decode($cached[0]['data'], true);
$out['cache_age_s'] = (int)(time() - (int)$cached[0]['ts']);
echo json_encode($out);
} else {
echo json_encode([
'categories' => [],
'total' => 0,
'cache_age_s' => -1,
'message' => 'News feed warming up — available within 5 minutes.',
]);
}
+41
View File
@@ -0,0 +1,41 @@
<?php
// Proxmox API endpoint — serves from api_cache, refreshed every 5 min by cron
$isConfigured = !(PROXMOX_HOST === '10.48.200.X' || PROXMOX_TOKEN_VAL === 'YOUR_TOKEN_VALUE_HERE');
if (!$isConfigured) {
echo json_encode([
'configured' => false,
'message' => 'Proxmox API token not yet configured.',
'vms' => [], 'nodes' => [],
]);
exit;
}
// Serve from cache (refreshed by stats_cache.php cron every 5 min)
$cached = JarvisDB::query(
'SELECT data, UNIX_TIMESTAMP(updated_at) as updated_ts FROM api_cache WHERE cache_key=? LIMIT 1',
['proxmox']
);
if ($cached && !empty($cached[0]['data'])) {
$row = $cached[0];
$data = json_decode($row['data'], true);
// Add cache age to response
$data['cache_age_s'] = (int)(time() - (int)$row['updated_ts']);
echo json_encode($data);
} else {
// Cache empty — return placeholder so UI shows something useful
echo json_encode([
'configured' => true,
'node' => PROXMOX_NODE,
'node_status' => null,
'vms' => [],
'containers' => [],
'vm_count' => 0,
'ct_count' => 0,
'cached_at' => null,
'cache_age_s' => -1,
'message' => 'Cache warming up — first update in under 5 minutes.',
]);
}
+298
View File
@@ -0,0 +1,298 @@
<?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";
+136
View File
@@ -0,0 +1,136 @@
<?php
// System stats endpoint — reads /proc directly, no shell injection risk
function getCpuUsage(): float {
$s1 = file('/proc/stat')[0];
preg_match('/cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $s1, $m1);
usleep(200000);
$s2 = file('/proc/stat')[0];
preg_match('/cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/', $s2, $m2);
$idle1 = $m1[4] + $m1[5];
$total1 = array_sum(array_slice($m1, 1));
$idle2 = $m2[4] + $m2[5];
$total2 = array_sum(array_slice($m2, 1));
$dTotal = $total2 - $total1;
$dIdle = $idle2 - $idle1;
return $dTotal > 0 ? round((($dTotal - $dIdle) / $dTotal) * 100, 1) : 0.0;
}
function getMemory(): array {
$lines = file('/proc/meminfo');
$mem = [];
foreach ($lines as $l) {
if (preg_match('/^(\w+):\s+(\d+)/', $l, $m)) $mem[$m[1]] = (int)$m[2];
}
$total = $mem['MemTotal'] ?? 0;
$available = $mem['MemAvailable'] ?? 0;
$used = $total - $available;
return [
'total_mb' => round($total / 1024),
'used_mb' => round($used / 1024),
'free_mb' => round($available / 1024),
'percent' => $total > 0 ? round(($used / $total) * 100, 1) : 0,
];
}
function getDisk(): array {
$disks = [];
foreach (disk_total_space('/') as $dummy) break; // warm up
$total = disk_total_space('/');
$free = disk_free_space('/');
$used = $total - $free;
return [
'total_gb' => round($total / 1073741824, 1),
'used_gb' => round($used / 1073741824, 1),
'free_gb' => round($free / 1073741824, 1),
'percent' => $total > 0 ? round(($used / $total) * 100, 1) : 0,
];
}
function getDisk2(): array {
$total = disk_total_space('/');
$free = disk_free_space('/');
$used = $total - $free;
return [
'total_gb' => round($total / 1073741824, 1),
'used_gb' => round($used / 1073741824, 1),
'free_gb' => round($free / 1073741824, 1),
'percent' => $total > 0 ? round(($used / $total) * 100, 1) : 0,
];
}
function getUptime(): string {
$secs = (int)file_get_contents('/proc/uptime');
$d = intdiv($secs, 86400); $h = intdiv($secs % 86400, 3600);
$m = intdiv($secs % 3600, 60);
return "{$d}d {$h}h {$m}m";
}
function getLoadAvg(): array {
$l = explode(' ', file_get_contents('/proc/loadavg'));
return ['1m' => (float)$l[0], '5m' => (float)$l[1], '15m' => (float)$l[2]];
}
function getNetworkIO(): array {
$lines = file('/proc/net/dev');
$ifaces = [];
foreach ($lines as $line) {
if (strpos($line, ':') === false) continue;
[$name, $stats] = explode(':', $line, 2);
$name = trim($name);
if ($name === 'lo') continue;
$vals = preg_split('/\s+/', trim($stats));
$ifaces[$name] = [
'rx_mb' => round($vals[0] / 1048576, 2),
'tx_mb' => round($vals[8] / 1048576, 2),
];
}
return $ifaces;
}
function getServices(): array {
$services = ['apache2', 'mysql'];
$result = [];
foreach ($services as $svc) {
$out = shell_exec('systemctl is-active ' . escapeshellarg($svc) . ' 2>/dev/null');
$result[$svc] = trim($out ?? '') === 'active';
}
return $result;
}
function getTopProcesses(): array {
$out = shell_exec("ps aux --sort=-%cpu | awk 'NR>1 && NR<=6 {print $11\",\"$3\",\"$4}' 2>/dev/null");
$procs = [];
foreach (explode("\n", trim($out ?? '')) as $line) {
if (!$line) continue;
[$cmd, $cpu, $mem] = explode(',', $line, 3);
$procs[] = ['cmd' => basename($cmd), 'cpu' => (float)$cpu, 'mem' => (float)$mem];
}
return $procs;
}
$cpu = getCpuUsage();
$memory = getMemory();
$disk = getDisk2();
$stats = [
'cpu' => $cpu,
'memory' => $memory,
'disk' => $disk,
'uptime' => getUptime(),
'load' => getLoadAvg(),
'network_io' => getNetworkIO(),
'services' => getServices(),
'processes' => getTopProcesses(),
'hostname' => gethostname(),
'ip' => JARVIS_IP,
'timestamp' => date('c'),
];
// Log to history
JarvisDB::execute(
'INSERT INTO metrics_history (metric_name, metric_value, host) VALUES (?,?,?),(?,?,?),(?,?,?)',
['cpu', $cpu, 'jarvis', 'memory', $memory['percent'], 'jarvis', 'disk', $disk['percent'], 'jarvis']
);
echo json_encode($stats);
+20
View File
@@ -0,0 +1,20 @@
<?php
// Weather endpoint — serves from api_cache (refreshed every 30 min by cron)
$cached = JarvisDB::query(
'SELECT data, UNIX_TIMESTAMP(updated_at) as ts FROM api_cache WHERE cache_key=? LIMIT 1',
['weather']
);
if ($cached && !empty($cached[0]['data'])) {
$out = json_decode($cached[0]['data'], true);
$out['cache_age_s'] = (int)(time() - (int)$cached[0]['ts']);
echo json_encode($out);
} else {
echo json_encode([
'current' => null,
'forecast' => [],
'cache_age_s' => -1,
'message' => 'Weather data warming up — available within 5 minutes.',
]);
}