mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
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:
@@ -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);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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'),
|
||||
]);
|
||||
@@ -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'),
|
||||
]);
|
||||
@@ -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')]);
|
||||
}
|
||||
@@ -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.',
|
||||
]);
|
||||
}
|
||||
@@ -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')]);
|
||||
}
|
||||
@@ -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.',
|
||||
]);
|
||||
}
|
||||
@@ -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.',
|
||||
]);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
@@ -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.',
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user