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
118 lines
4.6 KiB
JavaScript
118 lines
4.6 KiB
JavaScript
// Shared by the stats overview and the per-alert drilldown: the time-range
|
||
// chips, the chart cards and the SVG column chart.
|
||
|
||
import { h } from './ui.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.
|
||
export 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 },
|
||
];
|
||
|
||
// The chosen range outlives navigating between the overview and a drilldown.
|
||
let range = '30d';
|
||
export const getRange = () => range;
|
||
export const setRangeId = (id) => { range = id; };
|
||
|
||
const utcDate = (ms) => new Date(ms).toISOString().slice(0, 10);
|
||
|
||
export function rangeQuery() {
|
||
const r = RANGES.find((x) => x.id === range);
|
||
if (!r.days) return {};
|
||
const now = Date.now();
|
||
return { from: utcDate(now - (r.days - 1) * DAY_MS), to: utcDate(now) };
|
||
}
|
||
|
||
export function chips(onPick, ranges = RANGES) {
|
||
return 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: () => onPick(r.id),
|
||
text: r.label,
|
||
})));
|
||
}
|
||
|
||
export function card(title, content, note) {
|
||
return h('section', { class: 'chart-card card card-pad' },
|
||
h('div', { class: 'chart-head' },
|
||
h('h3', { class: 'chart-title', text: title }),
|
||
note && h('span', { class: 'chart-note', text: note })),
|
||
content);
|
||
}
|
||
|
||
export const pad2 = (n) => String(n).padStart(2, '0');
|
||
export const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||
// Monday first, as the mockup has it; server weekdays are 0=Sunday.
|
||
export const MONDAY_FIRST = [1, 2, 3, 4, 5, 6, 0];
|
||
|
||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||
|
||
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;
|
||
}
|
||
|
||
// A column chart: one bar per item, the value in a tooltip, and a label under
|
||
// the items marked `tick`.
|
||
export 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;
|
||
}
|
||
|
||
// A 7×24 weekday-by-hour grid from `grid[weekday][hour]` (0=Sunday); cell
|
||
// opacity steps with the count relative to the busiest cell.
|
||
export function heatmap(grid) {
|
||
const max = Math.max(...grid.flat(), 1);
|
||
const level = (c) => (c === 0 ? 0 : Math.min(4, Math.ceil((c / max) * 4)));
|
||
const rows = MONDAY_FIRST.map((w) => h('div', { class: 'heat-row' },
|
||
h('span', { class: 'heat-day', text: WEEKDAYS[w] }),
|
||
h('div', { class: 'heat-cells' }, grid[w].map((c, hr) => h('div', {
|
||
class: `heat-cell lv${level(c)}`,
|
||
title: `${WEEKDAYS[w]} ${pad2(hr)}:00 UTC: ${c}`,
|
||
})))));
|
||
const axis = h('div', { class: 'heat-row' },
|
||
h('span', { class: 'heat-day' }),
|
||
h('div', { class: 'heat-cells' }, Array.from({ length: 24 }, (_, hr) =>
|
||
h('span', { class: 'heat-hour', text: hr % 3 === 0 ? pad2(hr) : '' }))));
|
||
const peak = grid.flatMap((row, d) => row.map((c, hr) => ({ d, hr, c }))).reduce((a, b) => (b.c > a.c ? b : a));
|
||
return h('div', {
|
||
class: 'heatmap',
|
||
role: 'img',
|
||
'aria-label': peak.c
|
||
? `Heatmap of incidents by weekday and hour in UTC. The busiest slot is ${WEEKDAYS[peak.d]} at ${pad2(peak.hr)}:00 with ${peak.c}.`
|
||
: 'Heatmap of incidents by weekday and hour in UTC. Nothing in this range.',
|
||
}, rows, axis);
|
||
}
|