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
137 lines
7.0 KiB
JavaScript
137 lines
7.0 KiB
JavaScript
// The drilldown behind a row of the stats page's top-alerts list: when one
|
|
// alert fired, how fast it was picked up and fixed. An occurrence is an
|
|
// incident that alert opened (alert rows carry no history of their own).
|
|
|
|
import * as api from './api.js';
|
|
import { h, emptyState, spinner } from './ui.js';
|
|
import { duration } from './format.js';
|
|
import { chips, card, columns, rangeQuery, pad2, WEEKDAYS, MONDAY_FIRST } from './charts.js';
|
|
|
|
const sec = (s) => (s == null ? '—' : duration(s * 1000));
|
|
const fmtTime = (unix) => {
|
|
const d = new Date(unix * 1000);
|
|
const mon = d.toLocaleString('en', { month: 'short', timeZone: 'UTC' });
|
|
return `${mon} ${d.getUTCDate()}, ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}`;
|
|
};
|
|
|
|
export const path = (name) => `/stats/alert/${encodeURIComponent(name)}`;
|
|
|
|
export async function load(name) {
|
|
return api.statsAlert(name, rangeQuery());
|
|
}
|
|
|
|
function tiles(d) {
|
|
const tile = (label, value, note) => h('div', { class: 'stat-tile' },
|
|
h('div', { class: 'stat-value', text: String(value) }),
|
|
h('div', { class: 'stat-label', text: label }),
|
|
note && h('div', { class: 'stat-note', text: note }));
|
|
return h('div', { class: 'stat-tiles' },
|
|
tile('Occurrences', d.count),
|
|
tile('Last seen', fmtTime(d.last_seen), 'UTC'),
|
|
tile('Average gap', d.mean_gap_seconds == null ? '—' : sec(d.mean_gap_seconds), 'between occurrences'),
|
|
tile('Mean time to acknowledge', sec(d.mean_ack_seconds), d.median_ack_seconds == null ? '' : `median ${sec(d.median_ack_seconds)}`),
|
|
tile('Mean time to resolve', sec(d.mean_resolve_seconds), d.median_resolve_seconds == null ? '' : `median ${sec(d.median_resolve_seconds)}`),
|
|
tile('Longest open', sec(d.longest_open_seconds)));
|
|
}
|
|
|
|
// Plain-language observations, derived from the same occurrences.
|
|
function patterns(d) {
|
|
const n = d.count;
|
|
const out = [];
|
|
const sunday = d.by_weekday[0];
|
|
const top = d.by_weekday.indexOf(Math.max(...d.by_weekday));
|
|
if (n > 1 && d.by_weekday[top] > n / 2) out.push(`${d.by_weekday[top]} of ${n} fired on a ${['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][top]}`);
|
|
else if (sunday > 0 && n > 1) out.push(`${sunday} of ${n} fired on a Sunday`);
|
|
const peak = d.by_hour.indexOf(Math.max(...d.by_hour));
|
|
if (n > 1 && d.by_hour[peak] > 1) out.push(`Peak hour is ${pad2(peak)}:00 UTC (${d.by_hour[peak]} of ${n})`);
|
|
const times = d.occurrences.map((o) => o.triggered_at).sort((a, b) => a - b);
|
|
const repeats = times.filter((t, i) => i > 0 && t - times[i - 1] < 86400).length;
|
|
if (repeats) out.push(`${repeats} of ${n} fired again within 24 hours of the previous one`);
|
|
return out;
|
|
}
|
|
|
|
function patternStrip(d) {
|
|
const items = patterns(d);
|
|
if (!items.length) return null;
|
|
return h('div', { class: 'pattern-strip card card-pad' },
|
|
h('span', { class: 'chart-title', text: 'Pattern' }),
|
|
items.map((t) => h('span', { text: t })));
|
|
}
|
|
|
|
// One block per occurrence along a time axis from the first to the last, as wide
|
|
// as the incident stayed open. Overlaps drop to a second lane.
|
|
function timeline(d) {
|
|
const occ = [...d.occurrences].sort((a, b) => a.triggered_at - b.triggered_at);
|
|
const start = occ[0].triggered_at;
|
|
const end = Math.max(...occ.map((o) => o.triggered_at + (o.resolve_seconds || 0)), start + 3600);
|
|
const span = end - start;
|
|
let prev = -100;
|
|
const marks = occ.map((o) => {
|
|
const left = ((o.triggered_at - start) / span) * 100;
|
|
const lane = left - prev < 1.5 ? 1 : 0;
|
|
prev = left;
|
|
const m = h('div', {
|
|
class: 'tl-mark',
|
|
title: `${fmtTime(o.triggered_at)} UTC · ${o.resolve_seconds == null ? 'still open' : `open for ${sec(o.resolve_seconds)}`}`,
|
|
});
|
|
m.style.left = `${left}%`;
|
|
m.style.width = `${((o.resolve_seconds || 0) / span) * 100}%`;
|
|
m.style.top = `${8 + lane * 22}px`;
|
|
return m;
|
|
});
|
|
return h('div', {},
|
|
h('div', { class: 'tl', role: 'img', 'aria-label': `Timeline of ${occ.length} occurrences between ${fmtTime(start)} and ${fmtTime(end)} UTC.` }, marks),
|
|
h('div', { class: 'tl-axis' },
|
|
h('span', { text: fmtTime(start) }), h('span', { text: fmtTime(end) })));
|
|
}
|
|
|
|
// Per occurrence: the wait for an acknowledgement, then the time being worked.
|
|
function fixBars(d) {
|
|
const rows = d.occurrences.filter((o) => o.resolve_seconds != null).slice(0, 12);
|
|
if (!rows.length) return h('p', { class: 'chart-note', text: 'Nothing resolved in this range.' });
|
|
const max = Math.max(...rows.map((o) => o.resolve_seconds), 1);
|
|
return h('div', { class: 'fixbars' }, rows.map((o) => {
|
|
const ack = Math.min(o.ack_seconds ?? 0, o.resolve_seconds);
|
|
const a = h('span', { class: 'fix-ack', title: `Waiting for acknowledgement: ${sec(ack)}` });
|
|
const w = h('span', { class: 'fix-work', title: `Being worked: ${sec(o.resolve_seconds - ack)}` });
|
|
a.style.width = `${(ack / max) * 100}%`;
|
|
w.style.width = `${((o.resolve_seconds - ack) / max) * 100}%`;
|
|
return h('div', { class: 'fix-row' },
|
|
h('span', { class: 'fix-label', text: fmtTime(o.triggered_at) }),
|
|
h('span', { class: 'fix-track' }, a, w),
|
|
h('span', { class: 'fix-total', text: sec(o.resolve_seconds) }));
|
|
}));
|
|
}
|
|
|
|
function table(d) {
|
|
return h('div', { class: 'occ-wrap' },
|
|
h('table', { class: 'occ' },
|
|
h('thead', {}, h('tr', {}, ['Fired (UTC)', 'Time to ack', 'Duration', 'Acknowledged by', 'Status'].map((t) => h('th', { text: t })))),
|
|
h('tbody', {}, d.occurrences.map((o) => h('tr', {},
|
|
h('td', {}, h('a', { href: `/incidents/${o.incident_id}`, text: fmtTime(o.triggered_at) }),
|
|
` ${WEEKDAYS[new Date(o.triggered_at * 1000).getUTCDay()]}`),
|
|
h('td', { text: sec(o.ack_seconds) }),
|
|
h('td', { text: sec(o.resolve_seconds) }),
|
|
h('td', { text: o.acknowledged_by || '—' }),
|
|
h('td', { text: o.status }))))));
|
|
}
|
|
|
|
export function body(name, d, error, onRange) {
|
|
const head = h('div', { class: 'stats-head' },
|
|
h('a', { class: 'btn btn-ghost', href: '/stats', text: '← Stats' }),
|
|
h('h2', { class: 'stats-alert-name', text: name }),
|
|
chips(onRange));
|
|
if (error && !d) return h('div', {}, head, h('div', { class: 'load-error', text: error }));
|
|
if (!d) return h('div', {}, head, spinner());
|
|
if (d === 'none') return h('div', {}, head, emptyState('No occurrences in this range', 'This alert did not open an incident in this window.', 'chart'));
|
|
return h('div', {}, head, h('div', { class: 'stats' },
|
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
|
tiles(d),
|
|
patternStrip(d),
|
|
card('When it fired', timeline(d), 'Each block spans how long the incident stayed open'),
|
|
card('By hour (UTC)', columns(d.by_hour.map((v, hr) => ({ label: pad2(hr), value: v, tick: hr % 6 === 0 })), 'Occurrences by hour of day')),
|
|
card('By weekday', columns(MONDAY_FIRST.map((w) => ({ label: WEEKDAYS[w], value: d.by_weekday[w], tick: true })), 'Occurrences by weekday')),
|
|
card('Time to resolve', fixBars(d), 'Amber waited for an acknowledgement; blue was worked'),
|
|
card('All occurrences', table(d), `Newest first${d.count > d.occurrences.length ? `, latest ${d.occurrences.length} of ${d.count}` : ''}`)));
|
|
}
|