mirror of
https://github.com/myronblair/jarvis
synced 2026-06-30 17:50:23 -05:00
feat: proactive alerts, Jellyfin control, agent sparklines, CCR roster fix
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -1722,6 +1722,46 @@ if (!$reply) {
|
|||||||
$source = 'intent:network_scan';
|
$source = 'intent:network_scan';
|
||||||
break;
|
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':
|
case 'jellyfin_now_playing':
|
||||||
$jSessions = @json_decode(@file_get_contents(
|
$jSessions = @json_decode(@file_get_contents(
|
||||||
JELLYFIN_URL . '/Sessions?api_key=' . JELLYFIN_API_KEY, false,
|
JELLYFIN_URL . '/Sessions?api_key=' . JELLYFIN_API_KEY, false,
|
||||||
|
|||||||
@@ -76,6 +76,34 @@ switch ($action) {
|
|||||||
echo json_encode(['recent' => $out]);
|
echo json_encode(['recent' => $out]);
|
||||||
break;
|
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:
|
default:
|
||||||
echo json_encode(['error' => 'Unknown action']);
|
echo json_encode(['error' => 'Unknown action']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
// Agent metrics history — returns CPU/RAM samples for sparklines
|
||||||
|
require_once __DIR__ . '/../config.php';
|
||||||
|
require_once __DIR__ . '/../../includes/auth.php';
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
AuthMiddleware::requireAuth();
|
||||||
|
|
||||||
|
$agentId = $_GET['agent_id'] ?? '';
|
||||||
|
$hours = min((int)($_GET['hours'] ?? 2), 24);
|
||||||
|
|
||||||
|
if (!$agentId) {
|
||||||
|
// Return all online agents' last 2h in one shot
|
||||||
|
$agents = JarvisDB::query(
|
||||||
|
"SELECT DISTINCT agent_id FROM agent_metrics WHERE recorded_at > 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));
|
||||||
|
}
|
||||||
@@ -73,6 +73,7 @@ $endpoints = [
|
|||||||
'planner' => 'planner.php',
|
'planner' => 'planner.php',
|
||||||
'jellyfin' => 'jellyfin.php',
|
'jellyfin' => 'jellyfin.php',
|
||||||
'history' => 'history.php',
|
'history' => 'history.php',
|
||||||
|
'metrics' => 'metrics.php',
|
||||||
'arc' => 'arc.php',
|
'arc' => 'arc.php',
|
||||||
'directives' => 'directives.php',
|
'directives' => 'directives.php',
|
||||||
'memory' => 'memory.php',
|
'memory' => 'memory.php',
|
||||||
|
|||||||
@@ -2586,6 +2586,8 @@ function showApp(name, greeting, silent = false) {
|
|||||||
initMobile();
|
initMobile();
|
||||||
setTimeout(checkPlannerReminder, 3000);
|
setTimeout(checkPlannerReminder, 3000);
|
||||||
setInterval(checkUpcomingAppts, 300000);
|
setInterval(checkUpcomingAppts, 300000);
|
||||||
|
setTimeout(pollAlertsProactive, 8000); // baseline on load
|
||||||
|
setInterval(pollAlertsProactive, 60000); // poll every 60s
|
||||||
// Guardian Mode — badge refresh + proactive chat
|
// Guardian Mode — badge refresh + proactive chat
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
_refreshGuardianBadge();
|
_refreshGuardianBadge();
|
||||||
@@ -3293,6 +3295,41 @@ async function resolveAlert(id) {
|
|||||||
loadAlerts();
|
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 ───────────────────────────────────────────────────────────
|
// ── WEATHER ───────────────────────────────────────────────────────────
|
||||||
async function loadWeather() {
|
async function loadWeather() {
|
||||||
const d = await api('weather');
|
const d = await api('weather');
|
||||||
@@ -4751,6 +4788,8 @@ async function loadAgents() {
|
|||||||
]);
|
]);
|
||||||
const agents = listData.agents || [];
|
const agents = listData.agents || [];
|
||||||
const metrics = metricsData.metrics || {};
|
const metrics = metricsData.metrics || {};
|
||||||
|
// Fetch sparkline data (non-blocking)
|
||||||
|
api('metrics').then(d => { _sparkData = d || {}; renderAgentsTab(agents, metrics); }).catch(() => {});
|
||||||
renderAgentsTab(agents, metrics);
|
renderAgentsTab(agents, metrics);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4773,6 +4812,24 @@ async function deleteNetworkDevice(ip, evt) {
|
|||||||
loadNetwork();
|
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 `<svg width="${width}" height="${height}" style="overflow:visible;display:block">
|
||||||
|
<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" opacity="0.8"/>
|
||||||
|
<circle cx="${((points.length-1)*step).toFixed(1)}" cy="${(height - ((points[points.length-1]-min)/range)*(height-2)-1).toFixed(1)}" r="2" fill="${color}"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderAgentsTab(agents, metrics) {
|
function renderAgentsTab(agents, metrics) {
|
||||||
const el = document.getElementById('agents-list');
|
const el = document.getElementById('agents-list');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -4825,6 +4882,16 @@ function renderAgentsTab(agents, metrics) {
|
|||||||
<div><div style="font-size:0.58rem;color:var(--text-dim);margin-bottom:2px">CPU</div>${gauge(cpu)}</div>
|
<div><div style="font-size:0.58rem;color:var(--text-dim);margin-bottom:2px">CPU</div>${gauge(cpu)}</div>
|
||||||
<div><div style="font-size:0.58rem;color:var(--text-dim);margin-bottom:2px">MEM ${memUsed}/${memTot}</div>${gauge(mem)}</div>
|
<div><div style="font-size:0.58rem;color:var(--text-dim);margin-bottom:2px">MEM ${memUsed}/${memTot}</div>${gauge(mem)}</div>
|
||||||
<div><div style="font-size:0.58rem;color:var(--text-dim);margin-bottom:2px">DISK</div>${maxDisk != null ? gauge(maxDisk) : '<span style="color:var(--text-dim)">--</span>'}</div>
|
<div><div style="font-size:0.58rem;color:var(--text-dim);margin-bottom:2px">DISK</div>${maxDisk != null ? gauge(maxDisk) : '<span style="color:var(--text-dim)">--</span>'}</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:4px">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:0.52rem;color:var(--text-dim);margin-bottom:2px">CPU 2H</div>
|
||||||
|
${sparkline((_agentSparkData[ag.agent_id]||[]).map(p=>p.cpu), 100, 18, 'rgba(0,212,255,0.7)')}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:0.52rem;color:var(--text-dim);margin-bottom:2px">MEM 2H</div>
|
||||||
|
${sparkline((_agentSparkData[ag.agent_id]||[]).map(p=>p.mem), 100, 18, 'rgba(0,255,136,0.7)')}
|
||||||
|
</div>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between">
|
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||||
<div style="font-size:0.58rem;color:var(--text-dim)">UP: ${uptime} · SEEN: ${since}</div>
|
<div style="font-size:0.58rem;color:var(--text-dim)">UP: ${uptime} · SEEN: ${since}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user