8521bdcef7
The stats pages were laid out for a desktop width and did not fit a phone. Below 600px the top-alerts rows now take three lines (name, count and sparkline, last seen and mean fix) instead of six columns, the heatmap shrinks to the screen with fewer hour labels, and the drilldown's time-to-resolve bars and occurrences table fold into stacked lines and cards rather than scrolling sideways. Grid children on the stats pages may now shrink below their content width, which is what let the heatmap push the page wider than the screen. CSS and one data-label attribute only: no API, schema or behaviour change at wider widths. Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
140 lines
7.3 KiB
JavaScript
140 lines
7.3 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) }));
|
|
}));
|
|
}
|
|
|
|
// On a phone each row folds into a card; the labels come from data-label.
|
|
const OCC_COLS = ['Fired (UTC)', 'Time to ack', 'Duration', 'Acknowledged by', 'Status'];
|
|
|
|
function table(d) {
|
|
return h('div', { class: 'occ-wrap' },
|
|
h('table', { class: 'occ' },
|
|
h('thead', {}, h('tr', {}, OCC_COLS.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', { 'data-label': OCC_COLS[1], text: sec(o.ack_seconds) }),
|
|
h('td', { 'data-label': OCC_COLS[2], text: sec(o.resolve_seconds) }),
|
|
h('td', { 'data-label': OCC_COLS[3], text: o.acknowledged_by || '—' }),
|
|
h('td', { 'data-label': OCC_COLS[4], 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}` : ''}`)));
|
|
}
|