Files
terdut-server/internal/web/static/js/stats.js
T
Niklas Ye 6235a2f737 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
2026-10-18 21:55:16 +02:00

165 lines
6.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, icon } from './ui.js';
import { duration } from './format.js';
import { chips, card, heatmap, rangeQuery, getRange, setRangeId, pad2 } from './charts.js';
import * as drill from './statsalert.js';
const view = () => document.getElementById('view-stats');
// What the page shows: the overview (null) or one alert's drilldown.
let alertName = null;
let data = null;
let error = null;
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 = getRange();
const name = alertName;
const q = rangeQuery();
try {
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 !== getRange() || name !== alertName) return;
error = err.message;
}
render();
}
function setRange(id) {
if (id === getRange()) return;
setRangeId(id);
data = null;
render();
refresh();
}
function render() {
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) {
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), '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(setRange), body));
}
// A missing mean means nothing has been acknowledged or resolved yet.
const mean = (s) => (s == null ? '—' : duration(s * 1000));
function tiles(s) {
const tile = (label, value, cls = '') => h('div', { class: `stat-tile ${cls}` },
h('div', { class: 'stat-value', text: String(value) }),
h('div', { class: 'stat-label', text: label }));
return h('div', { class: 'stat-tiles' },
tile('Incidents', s.total),
tile('Triggered', s.triggered, 'st-triggered'),
tile('Acknowledged', s.acknowledged, 'st-acknowledged'),
tile('Resolved', s.resolved, 'st-resolved'),
tile('Mean time to acknowledge', mean(s.mtta_seconds)),
tile('Mean time to resolve', mean(s.mttr_seconds)));
}
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 lastSeen = (unix) => {
const d = new Date(unix * 1000);
return `${d.toLocaleString('en', { month: 'short', timeZone: 'UTC' })} ${d.getUTCDate()}, ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}`;
};
// 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'));
}));
}
// 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} →` })));
}