Drill into a single alert from the stats page

The stats page could only say how many alerts fired. It now answers when
one alert fires, how often, and how fast it is picked up and fixed: each
row of the top-alerts list opens /stats/alert/{name}, with a timeline,
by-hour and by-weekday charts, time-to-resolve per occurrence and a table
of every occurrence linking to its incident. The overview gains a
weekday-by-hour heatmap and a few derived observations.

An occurrence is an incident, not an alert row. Alert rows are reused per
fingerprint and mutated in place, so they carry no history; incidents are
the only record of each time something fired and was answered. That means
/api/stats/alerts/top now counts incidents opened (filtered on
triggered_at) so the list and the drilldown agree. The response keeps name
and count and only adds fields, so existing clients keep working, but the
counts can differ from before.

No migration: the lookups use incidents_signature_idx. Label breakdown,
team and severity filters and period-over-period deltas from the design
draft are left for later.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
Niklas Ye
2026-10-18 21:55:16 +02:00
parent a314083fa9
commit 6235a2f737
11 changed files with 763 additions and 117 deletions
+98 -102
View File
@@ -1,99 +1,87 @@
// Statistics: how many incidents, how fast they are answered, and when and
// what the alerts are. The same figures the TUI's Stats tab shows, over a
// range picked with the chips. The server scopes them to the caller's teams.
// /stats/alert/{name} is the drilldown behind a row of the top-alerts list.
import * as api from './api.js';
import { h, clear, emptyState, spinner } from './ui.js';
import { h, clear, emptyState, spinner, icon } from './ui.js';
import { duration } from './format.js';
const DAY_MS = 24 * 60 * 60 * 1000;
// `days` counts back from today, inclusive; the server reads from/to as UTC
// dates, so these are too.
const RANGES = [
{ id: 'today', label: 'Today', days: 1 },
{ id: '7d', label: '7d', days: 7 },
{ id: '30d', label: '30d', days: 30 },
{ id: '90d', label: '90d', days: 90 },
{ id: 'all', label: 'All', days: null },
];
import { chips, card, heatmap, rangeQuery, getRange, setRangeId, pad2 } from './charts.js';
import * as drill from './statsalert.js';
const view = () => document.getElementById('view-stats');
let range = '30d';
// What the page shows: the overview (null) or one alert's drilldown.
let alertName = null;
let data = null;
let error = null;
const utcDate = (ms) => new Date(ms).toISOString().slice(0, 10);
function query(r) {
if (!r.days) return {};
const now = Date.now();
return { from: utcDate(now - (r.days - 1) * DAY_MS), to: utcDate(now) };
}
export function show() {
export function show(route) {
const next = route?.alert ?? null;
if (next !== alertName) data = null;
alertName = next;
error = null;
render();
refresh();
}
export async function refresh() {
const requested = range;
const q = query(RANGES.find((x) => x.id === range));
const requested = getRange();
const name = alertName;
const q = rangeQuery();
try {
const [incidents, top, byHour, byDay] = await Promise.all([
api.statsIncidents(q),
api.statsTop({ ...q, limit: 10 }),
api.statsByHour(q),
api.statsByDay(q),
]);
if (requested !== range) return;
data = { incidents, top, byHour, byDay };
if (name != null) {
try {
data = await drill.load(name);
} catch (err) {
if (err.status !== 404) throw err;
data = 'none';
}
} else {
const [incidents, top, heat] = await Promise.all([
api.statsIncidents(q),
api.statsTop({ ...q, limit: 10 }),
api.statsHeatmap(q),
]);
data = { incidents, top, heat };
}
if (requested !== getRange() || name !== alertName) return;
error = null;
} catch (err) {
if (requested !== range) return;
if (requested !== getRange() || name !== alertName) return;
error = err.message;
}
render();
}
function setRange(id) {
if (id === range) return;
range = id;
if (id === getRange()) return;
setRangeId(id);
data = null;
render();
refresh();
}
function render() {
const chips = h('div', { class: 'chips', role: 'tablist', 'aria-label': 'Time range' },
RANGES.map((r) => h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(r.id === range),
onclick: () => setRange(r.id),
text: r.label,
})));
if (alertName != null) {
clear(view(), drill.body(alertName, data, error, setRange));
return;
}
let body;
if (error && !data) body = h('div', { class: 'load-error', text: error });
else if (!data) body = spinner();
else if (!data.incidents.total && !data.byHour.some((x) => x.count)) {
else if (!data.incidents.total) {
body = emptyState('No data in this range', 'Nothing happened in this window.', 'chart');
} else {
body = h('div', { class: 'stats' },
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
tiles(data.incidents),
data.top.length > 0 && card('Top alerts', topAlerts(data.top)),
card('Alerts by hour (UTC)', columns(
data.byHour.map((x) => ({ label: String(x.hour), value: x.count, tick: x.hour % 6 === 0 })),
'Alerts by hour of day')),
card('Alerts by day', columns(
data.byDay.map((x) => ({ label: x.day_name.slice(0, 3), value: x.count, tick: true })),
'Alerts by day of week')));
data.top.length > 0 && card('Top alerts', topAlerts(data.top), 'Select an alert to see when and how often it fires'),
h('div', { class: 'stats-split' },
card('When incidents open', heatmap(data.heat.grid), 'Weekday × hour (UTC)'),
patternsCard(data)));
}
clear(view(), h('div', {}, chips, body));
clear(view(), h('div', {}, chips(setRange), body));
}
// A missing mean means nothing has been acknowledged or resolved yet.
@@ -112,57 +100,65 @@ function tiles(s) {
tile('Mean time to resolve', mean(s.mttr_seconds)));
}
function card(title, content) {
return h('section', { class: 'chart-card card card-pad' },
h('h3', { class: 'chart-title', text: title }), content);
}
// Ranked names with a bar scaled to the busiest one.
function topAlerts(items) {
const max = Math.max(...items.map((x) => x.count), 1);
return h('ol', { class: 'hbars' }, items.map((x) => {
const fill = h('span', { class: 'hbar-fill' });
fill.style.width = `${Math.max(2, (x.count / max) * 100)}%`;
return h('li', { class: 'hbar' },
h('span', { class: 'hbar-name', title: x.name, text: x.name }),
h('span', { class: 'hbar-track' }, fill),
h('span', { class: 'hbar-count', text: String(x.count) }));
function spark(daily) {
const max = Math.max(...daily, 1);
return h('span', { class: 'spark', 'aria-hidden': 'true' }, daily.map((c) => {
const b = h('span', { class: c ? 'spark-bar on' : 'spark-bar' });
b.style.height = `${c ? 6 + (c / max) * 16 : 2}px`;
return b;
}));
}
const SVG_NS = 'http://www.w3.org/2000/svg';
const lastSeen = (unix) => {
const d = new Date(unix * 1000);
return `${d.toLocaleString('en', { month: 'short', timeZone: 'UTC' })} ${d.getUTCDate()}, ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}`;
};
function svg(tag, attrs = {}, text) {
const el = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
if (text != null) el.textContent = text;
return el;
// Ranked names, each a link to its drilldown: a bar scaled to the busiest, the
// last 30 days as a sparkline, when it last fired and how long it takes to fix.
function topAlerts(items) {
const max = Math.max(...items.map((x) => x.count), 1);
const total = items.reduce((n, x) => n + x.count, 0);
return h('div', { class: 'toplist' },
h('div', { class: 'top-row top-head', 'aria-hidden': 'true' },
['Alert', 'Count', 'Last 30 days', 'Last seen', 'Mean fix'].map((t) => h('span', { text: t }))),
items.map((x) => {
const fill = h('span', { class: 'hbar-fill' });
fill.style.width = `${Math.max(2, (x.count / max) * 100)}%`;
return h('a', { class: 'top-row', href: drill.path(x.name) },
h('span', { class: 'hbar-name', title: x.name, text: x.name }),
h('span', { class: 'top-count' },
h('span', { class: 'hbar-count', text: String(x.count) }),
h('span', { class: 'hbar-track' }, fill),
h('span', { class: 'top-pct', text: `${Math.round((x.count / total) * 100)}%` })),
spark(x.daily),
h('span', { class: 'top-cell', text: lastSeen(x.last_seen) }),
h('span', { class: 'top-cell', text: mean(x.mean_resolve_seconds) }),
icon('chevronRight'));
}));
}
// A column chart: one bar per item, the value in a tooltip, and a label under
// the items marked `tick`.
function columns(items, label) {
const W = 480;
const H = 140;
const base = H - 18;
const step = W / items.length;
const max = Math.max(...items.map((x) => x.value), 1);
const root = svg('svg', {
class: 'columns', viewBox: `0 0 ${W} ${H}`, role: 'img', 'aria-label': label,
});
root.appendChild(svg('line', { class: 'axis', x1: 0, x2: W, y1: base, y2: base }));
items.forEach((it, i) => {
const bh = it.value ? Math.max(2, (it.value / max) * (base - 6)) : 0;
const x = i * step + step * 0.15;
const g = svg('g', { class: 'col' });
g.appendChild(svg('title', {}, `${it.label}: ${it.value}`));
// A full-height transparent hit area, so a tiny bar is still hoverable.
g.appendChild(svg('rect', { class: 'col-hit', x: i * step, y: 0, width: step, height: base }));
if (bh) g.appendChild(svg('rect', { class: 'col-bar', x, y: base - bh, width: step * 0.7, height: bh, rx: 2 }));
root.appendChild(g);
if (it.tick) {
root.appendChild(svg('text', { class: 'col-label', x: i * step + step / 2, y: H - 4, 'text-anchor': 'middle' }, it.label));
}
});
return root;
// Observations drawn from the figures already on the page.
function patternsCard(d) {
const flat = d.heat.grid;
const total = flat.flat().reduce((a, b) => a + b, 0);
const days = flat.map((row) => row.reduce((a, b) => a + b, 0));
const hours = Array.from({ length: 24 }, (_, hr) => flat.reduce((a, row) => a + row[hr], 0));
const topDay = days.indexOf(Math.max(...days));
const topHour = hours.indexOf(Math.max(...hours));
const topAlert = d.top[0];
const full = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const items = [];
if (total > 1) {
items.push([`${full[topDay]}s are the noisiest day`, `${days[topDay]} of ${total} incidents opened on a ${full[topDay]}.`]);
items.push([`Peak hour is ${pad2(topHour)}:00 UTC`, `${hours[topHour]} incidents opened in that single hour.`]);
}
if (topAlert && total > 1 && topAlert.count / total >= 0.4) {
items.push(['One alert drives most of the volume', `${topAlert.name} is ${Math.round((topAlert.count / total) * 100)}% of everything that fired.`]);
}
const body = items.length
? items.map(([t, s]) => h('div', { class: 'pattern' }, h('div', { class: 'pattern-title', text: t }), h('div', { class: 'pattern-sub', text: s })))
: h('p', { class: 'chart-note', text: 'Not enough incidents yet to see a pattern.' });
return card('Patterns', h('div', { class: 'patterns' }, body,
topAlert && h('a', { class: 'btn btn-ghost', href: drill.path(topAlert.name), text: `Open ${topAlert.name} →` })));
}