91f03c21e8
Addresses the screenshot-review feedback in #26. No framework or build step added — all of this stays within the existing plain HTML/CSS/ vanilla-JS + go:embed architecture. - Nav: re-enable the bottom tab bar that was already built and switched off (Queue/On-call/Alerts/Team + a "More" sheet for Stats/Admin/Account), replacing the hamburger on phone width. - Queue: chip counts, a scroll fade on the filter row, a "Triggered Xh ago" + severity label per row, a chevron on the team switcher so it reads as a dropdown. - On-call: collapse repeated same-person days into shift bars (week view and "your shifts" both), show the week as a date range with the ISO week number as secondary text, split "Current shift" out from "Next shifts" with "ends in Nd", a pill badge + row highlight for "you". - Incident detail: fix the actual bug behind the duplicate "acknowledged" timeline entries (acknowledgeIncident's UPDATE had no guard on the incident's current status, so acknowledging an already-acknowledged incident silently re-logged the event — now idempotent, with regression tests on both the authenticated route and the ntfy ack-button route). Relabel escalation re-pages so they don't look like the same page landing twice. Copy the primary action up near the top. Label the "···" button. Group the timeline by phase (triggered/acknowledged/resolved). Add an "at a glance" summary row (duration/severity/responsible) and collapse the group labels by default. - Team overview: reword the vague copy ("One owner." etc.) into plain labels. - Empty states: fill in missing icons/one-liners across queue, alerts, stats and the incident timeline. - CSS: fix card padding bugs, verify link contrast already passes AA, introduce a --fs-* type-scale token set and migrate the few genuinely isolated cases onto it (left sizes tied to a fixed shape, a deliberately prominent display, or a non-negotiable constraint like the iOS-zoom-prevention input size as documented exceptions rather than guess at a render this change can't see). Verified with the full fmt/lint/test/helm-lint gate, plus a live instance against the test DB with seeded incidents and schedule data to trace the on-call grouping and timeline phase-splitting logic against real API responses. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
169 lines
5.8 KiB
JavaScript
169 lines
5.8 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.
|
|
|
|
import * as api from './api.js';
|
|
import { h, clear, emptyState, spinner } from './ui.js';
|
|
import { duration } from './format.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.
|
|
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 },
|
|
];
|
|
|
|
const view = () => document.getElementById('view-stats');
|
|
|
|
let range = '30d';
|
|
let data = null;
|
|
let error = null;
|
|
|
|
const utcDate = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
|
|
function query(r) {
|
|
if (!r.days) return {};
|
|
const now = Date.now();
|
|
return { from: utcDate(now - (r.days - 1) * DAY_MS), to: utcDate(now) };
|
|
}
|
|
|
|
export function show() {
|
|
render();
|
|
refresh();
|
|
}
|
|
|
|
export async function refresh() {
|
|
const requested = range;
|
|
const q = query(RANGES.find((x) => x.id === range));
|
|
try {
|
|
const [incidents, top, byHour, byDay] = await Promise.all([
|
|
api.statsIncidents(q),
|
|
api.statsTop({ ...q, limit: 10 }),
|
|
api.statsByHour(q),
|
|
api.statsByDay(q),
|
|
]);
|
|
if (requested !== range) return;
|
|
data = { incidents, top, byHour, byDay };
|
|
error = null;
|
|
} catch (err) {
|
|
if (requested !== range) return;
|
|
error = err.message;
|
|
}
|
|
render();
|
|
}
|
|
|
|
function setRange(id) {
|
|
if (id === range) return;
|
|
range = id;
|
|
data = null;
|
|
render();
|
|
refresh();
|
|
}
|
|
|
|
function render() {
|
|
const chips = 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: () => setRange(r.id),
|
|
text: r.label,
|
|
})));
|
|
|
|
let body;
|
|
if (error && !data) body = h('div', { class: 'load-error', text: error });
|
|
else if (!data) body = spinner();
|
|
else if (!data.incidents.total && !data.byHour.some((x) => x.count)) {
|
|
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)),
|
|
card('Alerts by hour (UTC)', columns(
|
|
data.byHour.map((x) => ({ label: String(x.hour), value: x.count, tick: x.hour % 6 === 0 })),
|
|
'Alerts by hour of day')),
|
|
card('Alerts by day', columns(
|
|
data.byDay.map((x) => ({ label: x.day_name.slice(0, 3), value: x.count, tick: true })),
|
|
'Alerts by day of week')));
|
|
}
|
|
clear(view(), h('div', {}, chips, 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 card(title, content) {
|
|
return h('section', { class: 'chart-card card card-pad' },
|
|
h('h3', { class: 'chart-title', text: title }), content);
|
|
}
|
|
|
|
// Ranked names with a bar scaled to the busiest one.
|
|
function topAlerts(items) {
|
|
const max = Math.max(...items.map((x) => x.count), 1);
|
|
return h('ol', { class: 'hbars' }, items.map((x) => {
|
|
const fill = h('span', { class: 'hbar-fill' });
|
|
fill.style.width = `${Math.max(2, (x.count / max) * 100)}%`;
|
|
return h('li', { class: 'hbar' },
|
|
h('span', { class: 'hbar-name', title: x.name, text: x.name }),
|
|
h('span', { class: 'hbar-track' }, fill),
|
|
h('span', { class: 'hbar-count', text: String(x.count) }));
|
|
}));
|
|
}
|
|
|
|
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`.
|
|
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;
|
|
}
|