Files
terdut-server/internal/web/static/js/charts.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

118 lines
4.6 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.
// 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);
}