6235a2f737
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
165 lines
6.5 KiB
JavaScript
165 lines
6.5 KiB
JavaScript
// 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} →` })));
|
||
}
|