From c29d1bf4c78e034452fa34bab337685aa596d7c4 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Wed, 17 Jun 2026 02:30:13 +0000 Subject: [PATCH] feat: proactive alerts, Jellyfin control, agent sparklines, CCR roster fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Proactive alerts (#1): polls every 60s; baselines on load so old alerts are silent; speaks new critical/warning alerts aloud if voice active; adds chat bubble for all new alerts - Jellyfin control (#2): pause/stop/next/previous via voice — auto-detects active session; 12 KB intents; jellyfin.php control action uses Jellyfin General Commands API - Agent sparklines (#6): metrics.php returns 2h CPU+MEM history per agent; SVG polyline sparklines rendered in each agent card (cyan=CPU, green=MEM); non-blocking fetch so existing view shows instantly - Agent health CCR (#7): updated hourly cloud routine to current 13-agent roster, removed ollama-ai and alien-pc, added all active agents with correct IPs/IDs Co-Authored-By: Claude Sonnet 4.6 --- api/endpoints/chat.php | 40 +++++++++++++++++++++++ api/endpoints/jellyfin.php | 28 ++++++++++++++++ api/endpoints/metrics.php | 51 +++++++++++++++++++++++++++++ public_html/api.php | 1 + public_html/index.html | 67 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 187 insertions(+) create mode 100644 api/endpoints/metrics.php diff --git a/api/endpoints/chat.php b/api/endpoints/chat.php index 99bb674..d2fa470 100644 --- a/api/endpoints/chat.php +++ b/api/endpoints/chat.php @@ -1722,6 +1722,46 @@ if (!$reply) { $source = 'intent:network_scan'; break; + case 'jellyfin_pause': + case 'jellyfin_stop': + case 'jellyfin_next': + case 'jellyfin_previous': { + $intentCmd = [ + 'jellyfin_pause' => 'TogglePause', + 'jellyfin_stop' => 'Stop', + 'jellyfin_next' => 'NextTrack', + 'jellyfin_previous' => 'PreviousTrack', + ][$matched['intent']]; + // Get first active session + $jfSessions = @json_decode(@file_get_contents( + JELLYFIN_URL . '/Sessions?api_key=' . JELLYFIN_API_KEY, false, + stream_context_create(['http' => ['timeout' => 4]]) + ), true) ?? []; + $activeSession = null; + foreach ($jfSessions as $s) { + if (isset($s['NowPlayingItem'])) { $activeSession = $s; break; } + } + if (!$activeSession) { + $reply = "Nothing is currently playing on Jellyfin, {$userAddr}."; + } else { + $sid = $activeSession['Id']; + $url = JELLYFIN_URL . '/Sessions/' . rawurlencode($sid) . '/Command/' . $intentCmd + . '?api_key=' . JELLYFIN_API_KEY; + $ctx = stream_context_create(['http' => ['method' => 'POST', 'timeout' => 5, + 'header' => 'Content-Type: application/json', 'content' => '{}', 'ignore_errors' => true]]); + @file_get_contents($url, false, $ctx); + $titles = [ + 'TogglePause' => 'Playback toggled, {$userAddr}.', + 'Stop' => 'Playback stopped, {$userAddr}.', + 'NextTrack' => 'Skipping to next track, {$userAddr}.', + 'PreviousTrack' => 'Going back to previous, {$userAddr}.', + ]; + $reply = strtr($titles[$intentCmd], ['{$userAddr}' => $userAddr]); + } + $source = 'intent:jellyfin_control'; + break; + } + case 'jellyfin_now_playing': $jSessions = @json_decode(@file_get_contents( JELLYFIN_URL . '/Sessions?api_key=' . JELLYFIN_API_KEY, false, diff --git a/api/endpoints/jellyfin.php b/api/endpoints/jellyfin.php index dfd8a4a..b3002d7 100644 --- a/api/endpoints/jellyfin.php +++ b/api/endpoints/jellyfin.php @@ -76,6 +76,34 @@ switch ($action) { echo json_encode(['recent' => $out]); break; + case 'control': + $jfSessionId = $_GET['session_id'] ?? ''; + $jfCommand = $_GET['command'] ?? ''; + // General commands supported by Jellyfin session control + $allowed = ['TogglePause', 'Stop', 'NextTrack', 'PreviousTrack', 'VolumeUp', 'VolumeDown']; + if (!$jfSessionId || !in_array($jfCommand, $allowed, true)) { + // No session_id: get the first active session automatically + if (!$jfSessionId && in_array($jfCommand, $allowed, true)) { + $sessions = jf_get('/Sessions'); + foreach ($sessions as $s) { + if (isset($s['NowPlayingItem'])) { $jfSessionId = $s['Id']; break; } + } + } + if (!$jfSessionId) { echo json_encode(['error' => 'No active session or invalid command']); break; } + } + $url = JELLYFIN_URL . '/Sessions/' . rawurlencode($jfSessionId) . '/Command/' . $jfCommand + . '?api_key=' . JELLYFIN_API_KEY; + $ctx = stream_context_create(['http' => [ + 'method' => 'POST', + 'timeout' => 5, + 'header' => 'Content-Type: application/json', + 'content' => '{}', + 'ignore_errors' => true, + ]]); + @file_get_contents($url, false, $ctx); + echo json_encode(['success' => true, 'command' => $jfCommand, 'session_id' => $jfSessionId]); + break; + default: echo json_encode(['error' => 'Unknown action']); } diff --git a/api/endpoints/metrics.php b/api/endpoints/metrics.php new file mode 100644 index 0000000..8763f11 --- /dev/null +++ b/api/endpoints/metrics.php @@ -0,0 +1,51 @@ + DATE_SUB(NOW(), INTERVAL ? HOUR)", + [$hours] + ) ?? []; + + $out = []; + foreach ($agents as $a) { + $rows = JarvisDB::query( + "SELECT CAST(JSON_EXTRACT(metric_data, '$.cpu_percent') AS DECIMAL(5,1)) as cpu, + CAST(JSON_EXTRACT(metric_data, '$.memory.percent') AS DECIMAL(5,1)) as mem, + recorded_at + FROM agent_metrics + WHERE agent_id = ? AND recorded_at > DATE_SUB(NOW(), INTERVAL ? HOUR) + ORDER BY recorded_at ASC", + [$a['agent_id'], $hours] + ) ?? []; + if ($rows) { + $out[$a['agent_id']] = array_map(fn($r) => [ + 'cpu' => (float)($r['cpu'] ?? 0), + 'mem' => (float)($r['mem'] ?? 0), + ], $rows); + } + } + echo json_encode($out); +} else { + $rows = JarvisDB::query( + "SELECT CAST(JSON_EXTRACT(metric_data, '$.cpu_percent') AS DECIMAL(5,1)) as cpu, + CAST(JSON_EXTRACT(metric_data, '$.memory.percent') AS DECIMAL(5,1)) as mem, + recorded_at + FROM agent_metrics + WHERE agent_id = ? AND recorded_at > DATE_SUB(NOW(), INTERVAL ? HOUR) + ORDER BY recorded_at ASC", + [$agentId, $hours] + ) ?? []; + echo json_encode(array_map(fn($r) => [ + 'cpu' => (float)($r['cpu'] ?? 0), + 'mem' => (float)($r['mem'] ?? 0), + ], $rows)); +} diff --git a/public_html/api.php b/public_html/api.php index 49afe8b..ca4f1e1 100644 --- a/public_html/api.php +++ b/public_html/api.php @@ -73,6 +73,7 @@ $endpoints = [ 'planner' => 'planner.php', 'jellyfin' => 'jellyfin.php', 'history' => 'history.php', + 'metrics' => 'metrics.php', 'arc' => 'arc.php', 'directives' => 'directives.php', 'memory' => 'memory.php', diff --git a/public_html/index.html b/public_html/index.html index e2c94f3..78f5b2a 100644 --- a/public_html/index.html +++ b/public_html/index.html @@ -2586,6 +2586,8 @@ function showApp(name, greeting, silent = false) { initMobile(); setTimeout(checkPlannerReminder, 3000); setInterval(checkUpcomingAppts, 300000); + setTimeout(pollAlertsProactive, 8000); // baseline on load + setInterval(pollAlertsProactive, 60000); // poll every 60s // Guardian Mode — badge refresh + proactive chat setTimeout(() => { _refreshGuardianBadge(); @@ -3293,6 +3295,41 @@ async function resolveAlert(id) { loadAlerts(); } +// ── PROACTIVE ALERT POLLING ─────────────────────────────────────────────────── +let _knownAlertIds = null; +let _spokenAlertIds = new Set(); + +async function pollAlertsProactive() { + const data = await api('alerts').catch(() => null); + if (!data) return; + const alerts = (data.alerts || []); + + if (_knownAlertIds === null) { + // First run: baseline existing alerts — do not speak them + _knownAlertIds = new Set(alerts.map(a => a.id)); + return; + } + + for (const a of alerts) { + if (_knownAlertIds.has(a.id) || _spokenAlertIds.has(a.id)) continue; + _knownAlertIds.add(a.id); + _spokenAlertIds.add(a.id); + + if (a.severity === 'critical' || a.severity === 'warning') { + const prefix = a.severity === 'critical' ? '🚨' : '⚠'; + addMessage('jarvis', `${prefix} ${a.title}: ${a.message}`); + const tts = (a.severity === 'critical' ? 'Critical alert. ' : 'Warning. ') + a.title + '. ' + a.message; + if (typeof speak === 'function' && isVoiceActive) speak(tts); + } + } + + // Remove resolved alerts from known set so they can re-trigger if they come back + const liveIds = new Set(alerts.map(a => a.id)); + for (const id of _knownAlertIds) { + if (!liveIds.has(id)) _knownAlertIds.delete(id); + } +} + // ── WEATHER ─────────────────────────────────────────────────────────── async function loadWeather() { const d = await api('weather'); @@ -4751,6 +4788,8 @@ async function loadAgents() { ]); const agents = listData.agents || []; const metrics = metricsData.metrics || {}; + // Fetch sparkline data (non-blocking) + api('metrics').then(d => { _sparkData = d || {}; renderAgentsTab(agents, metrics); }).catch(() => {}); renderAgentsTab(agents, metrics); } @@ -4773,6 +4812,24 @@ async function deleteNetworkDevice(ip, evt) { loadNetwork(); } +let _agentSparkData = {}; +function sparkline(points, width=80, height=20, color='var(--cyan)') { + if (!points || points.length < 2) return ''; + const max = Math.max(...points, 1); + const min = Math.min(...points); + const range = max - min || 1; + const step = width / (points.length - 1); + const pts = points.map((v, i) => { + const x = i * step; + const y = height - ((v - min) / range) * (height - 2) - 1; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); + return ` + + + `; +} + function renderAgentsTab(agents, metrics) { const el = document.getElementById('agents-list'); if (!el) return; @@ -4825,6 +4882,16 @@ function renderAgentsTab(agents, metrics) {
CPU
${gauge(cpu)}
MEM ${memUsed}/${memTot}
${gauge(mem)}
DISK
${maxDisk != null ? gauge(maxDisk) : '--'}
+ +
+
+
CPU 2H
+ ${sparkline((_agentSparkData[ag.agent_id]||[]).map(p=>p.cpu), 100, 18, 'rgba(0,212,255,0.7)')} +
+
+
MEM 2H
+ ${sparkline((_agentSparkData[ag.agent_id]||[]).map(p=>p.mem), 100, 18, 'rgba(0,255,136,0.7)')} +
` : ''}
UP: ${uptime} · SEEN: ${since}