// Historical incident view — heatmap (day-of-week × hour) + worst-offenders // + recent incidents timeline. Data comes from SQLite on the backend via // /api/history/{heatmap,node-stats,incidents}. // // Rules-of-Hooks: every useState/useEffect/useMemo is declared BEFORE any // early return. See components/admin.jsx:180-210 for the pattern that // has bitten us before. const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; const WINDOWS = [ { k: 7, l: '7d' }, { k: 30, l: '30d' }, { k: 90, l: '90d' }, ]; function _fmtDur(ms) { const s = Math.max(0, Math.floor((ms || 0) / 1000)); if (s < 60) return `${s}s`; if (s < 3600) return `${Math.floor(s/60)}m`; if (s < 86400) return `${Math.floor(s/3600)}h ${Math.floor((s%3600)/60)}m`; return `${Math.floor(s/86400)}d ${Math.floor((s%86400)/3600)}h`; } function _fmtTsLocal(ts) { if (!ts) return '—'; const d = new Date(ts); const dd = d.toLocaleDateString(undefined, { month: 'short', day: '2-digit' }); const tt = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); return `${dd} ${tt}`; } function HeatmapGrid({ grid, nodeLabel }) { // `grid` is 7×24. Color intensity scales with per-cell `ms` relative to the max. const peak = React.useMemo(() => { let m = 0; for (const row of grid || []) for (const c of row) if (c.ms > m) m = c.ms; return m; }, [grid]); if (!grid || !grid.length) return null; return (

Incidents · day × hour

{nodeLabel} darker red = more downtime · hover for detail
{/* header row — hours */}
{Array.from({length: 24}).map((_, h) => (
{String(h).padStart(2,'0')}
))} {/* rows */} {grid.map((row, di) => (
{DAYS[di]}
{row.map((cell, h) => { const frac = peak > 0 ? cell.ms / peak : 0; const bg = cell.count === 0 ? 'var(--surface-3)' // Blend accent (cool) → crit (hot) as frac grows. : (frac < 0.25 ? `oklch(0.72 0.14 195 / ${0.25 + frac})` : frac < 0.75 ? `oklch(0.72 0.14 85 / ${0.55 + frac * 0.25})` : `oklch(0.66 0.22 27 / ${0.65 + frac * 0.3})`); const title = cell.count ? `${DAYS[di]} ${String(h).padStart(2,'0')}:00 · ${cell.count} incident${cell.count===1?'':'s'} · ${_fmtDur(cell.ms)}` : `${DAYS[di]} ${String(h).padStart(2,'0')}:00 · no incidents`; return (
); })} ))}
); } function WorstOffenders({ stats, onSelectNode, selectedNodeId }) { if (!stats) return null; if (stats.length === 0) { return (

Worst offenders

no incidents in window
); } const maxMs = Math.max(...stats.map(s => s.ms), 1); return (

Worst offenders

{stats.length} node{stats.length === 1 ? '' : 's'} click a row to filter heatmap
{stats.map(s => { const pct = Math.round((s.ms / maxMs) * 100); const sel = selectedNodeId === s.nodeId; return ( ); })}
); } function IncidentTimeline({ incidents }) { if (!incidents) return null; if (incidents.length === 0) { return (

Timeline

no incidents yet
History fills up as incidents happen. Trigger one with Tweaks → simulate incident to see it here.
); } return (

Timeline

{incidents.length} recent · newest first
{incidents.map(i => ( ))}
nodegroupwhenduration statusnotified
{i.displayName || i.hostname || i.node_id} {i.group || '—'} {_fmtTsLocal(i.started_at)} {i.ended_at ? _fmtDur(i.durationMs) : still down · {_fmtDur(i.durationMs)}} {i.max_status} {i.notifications_sent > 0 ? {i.notifications_sent}× : }
); } function History({ runtimeNodes }) { // ALL hooks above early returns. const [days, setDays] = React.useState(30); const [nodeId, setNodeId] = React.useState(null); const [data, setData] = React.useState(null); // { grid, stats, incidents } const [loading, setLoading] = React.useState(false); const [err, setErr] = React.useState(null); const tzOffsetMinutes = -new Date().getTimezoneOffset(); const selectedNode = React.useMemo(() => { if (!nodeId || !runtimeNodes) return null; return runtimeNodes.find(n => n.id === nodeId) || null; }, [nodeId, runtimeNodes]); async function load() { setLoading(true); setErr(null); try { const q = (p) => { const sp = new URLSearchParams({ days: String(days), tzOffsetMinutes: String(tzOffsetMinutes) }); if (nodeId) sp.set('nodeId', nodeId); return `${p}?${sp.toString()}`; }; const [heatmap, stats, incidents] = await Promise.all([ window.apiFetch(q('/api/history/heatmap')), window.apiFetch(`/api/history/node-stats?days=${days}`), window.apiFetch(q('/api/history/incidents')), ]); setData({ grid: heatmap.grid, stats: stats.stats || [], incidents: incidents.incidents || [], total: incidents.total, }); } catch (e) { if (e.message !== 'unauthorized') setErr(e.message); } finally { setLoading(false); } } React.useEffect(() => { load(); }, [days, nodeId]); // Also auto-refresh every 60s so a brand-new incident shows up without a manual reload. React.useEffect(() => { const t = setInterval(load, 60_000); return () => clearInterval(t); }, [days, nodeId]); const nodeLabel = selectedNode ? `${selectedNode.displayName || selectedNode.hostname} · ${selectedNode.group}` : 'all nodes'; return (

History

sqlite · bucketed in local timezone Window
{WINDOWS.map(w => ( ))}
Node
{err &&
Failed to load: {err}
}
); } Object.assign(window, { History });