// 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', '', '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; }