// 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 (
onSelectNode(sel ? null : s.nodeId)}
style={{
width: '100%', textAlign: 'left',
padding: '7px 12px',
borderBottom: '1px solid var(--border)',
display: 'grid', gridTemplateColumns: '1.2fr 60px 1fr 80px', gap: 10, alignItems: 'center',
background: sel ? 'var(--accent-bg)' : 'transparent',
color: sel ? 'var(--accent)' : 'var(--text)',
fontSize: 12, fontFamily: 'var(--mono)',
}}>
{s.displayName || s.hostname || s.nodeId}
{s.group || '—'}
{s.count}× · {_fmtDur(s.ms)}
);
})}
);
}
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
node group when duration
status notified
{incidents.map(i => (
{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 => (
setDays(w.k)}>{w.l}
))}
Node
setNodeId(e.target.value || null)}
style={{background: 'var(--surface)', border: '1px solid var(--border-2)',
borderRadius: 2, color: 'var(--text)', padding: '4px 8px',
fontFamily: 'var(--mono)', fontSize: 11}}>
all
{(runtimeNodes || []).slice().sort((a,b) => (a.displayName||a.hostname||'').localeCompare(b.displayName||b.hostname||'')).map(n => (
{n.displayName || n.hostname} · {n.group}
))}
{loading ? '…' : 'reload'}
{err &&
Failed to load: {err}
}
);
}
Object.assign(window, { History });