// 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. // /stats/alert/{name} is the drilldown behind a row of the top-alerts list. import * as api from './api.js'; import { h, clear, emptyState, spinner, icon } from './ui.js'; import { duration } from './format.js'; import { chips, card, heatmap, rangeQuery, getRange, setRangeId, pad2 } from './charts.js'; import * as drill from './statsalert.js'; const view = () => document.getElementById('view-stats'); // What the page shows: the overview (null) or one alert's drilldown. let alertName = null; let data = null; let error = null; export function show(route) { const next = route?.alert ?? null; if (next !== alertName) data = null; alertName = next; error = null; render(); refresh(); } export async function refresh() { const requested = getRange(); const name = alertName; const q = rangeQuery(); try { if (name != null) { try { data = await drill.load(name); } catch (err) { if (err.status !== 404) throw err; data = 'none'; } } else { const [incidents, top, heat] = await Promise.all([ api.statsIncidents(q), api.statsTop({ ...q, limit: 10 }), api.statsHeatmap(q), ]); data = { incidents, top, heat }; } if (requested !== getRange() || name !== alertName) return; error = null; } catch (err) { if (requested !== getRange() || name !== alertName) return; error = err.message; } render(); } function setRange(id) { if (id === getRange()) return; setRangeId(id); data = null; render(); refresh(); } function render() { if (alertName != null) { clear(view(), drill.body(alertName, data, error, setRange)); return; } let body; if (error && !data) body = h('div', { class: 'load-error', text: error }); else if (!data) body = spinner(); else if (!data.incidents.total) { 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), 'Select an alert to see when and how often it fires'), h('div', { class: 'stats-split' }, card('When incidents open', heatmap(data.heat.grid), 'Weekday × hour (UTC)'), patternsCard(data))); } clear(view(), h('div', {}, chips(setRange), 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 spark(daily) { const max = Math.max(...daily, 1); return h('span', { class: 'spark', 'aria-hidden': 'true' }, daily.map((c) => { const b = h('span', { class: c ? 'spark-bar on' : 'spark-bar' }); b.style.height = `${c ? 6 + (c / max) * 16 : 2}px`; return b; })); } const lastSeen = (unix) => { const d = new Date(unix * 1000); return `${d.toLocaleString('en', { month: 'short', timeZone: 'UTC' })} ${d.getUTCDate()}, ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}`; }; // Ranked names, each a link to its drilldown: a bar scaled to the busiest, the // last 30 days as a sparkline, when it last fired and how long it takes to fix. function topAlerts(items) { const max = Math.max(...items.map((x) => x.count), 1); const total = items.reduce((n, x) => n + x.count, 0); return h('div', { class: 'toplist' }, h('div', { class: 'top-row top-head', 'aria-hidden': 'true' }, ['Alert', 'Count', 'Last 30 days', 'Last seen', 'Mean fix'].map((t) => h('span', { text: t }))), items.map((x) => { const fill = h('span', { class: 'hbar-fill' }); fill.style.width = `${Math.max(2, (x.count / max) * 100)}%`; return h('a', { class: 'top-row', href: drill.path(x.name) }, h('span', { class: 'hbar-name', title: x.name, text: x.name }), h('span', { class: 'top-count' }, h('span', { class: 'hbar-count', text: String(x.count) }), h('span', { class: 'hbar-track' }, fill), h('span', { class: 'top-pct', text: `${Math.round((x.count / total) * 100)}%` })), spark(x.daily), h('span', { class: 'top-cell', text: lastSeen(x.last_seen) }), h('span', { class: 'top-cell', text: mean(x.mean_resolve_seconds) }), icon('chevronRight')); })); } // Observations drawn from the figures already on the page. function patternsCard(d) { const flat = d.heat.grid; const total = flat.flat().reduce((a, b) => a + b, 0); const days = flat.map((row) => row.reduce((a, b) => a + b, 0)); const hours = Array.from({ length: 24 }, (_, hr) => flat.reduce((a, row) => a + row[hr], 0)); const topDay = days.indexOf(Math.max(...days)); const topHour = hours.indexOf(Math.max(...hours)); const topAlert = d.top[0]; const full = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; const items = []; if (total > 1) { items.push([`${full[topDay]}s are the noisiest day`, `${days[topDay]} of ${total} incidents opened on a ${full[topDay]}.`]); items.push([`Peak hour is ${pad2(topHour)}:00 UTC`, `${hours[topHour]} incidents opened in that single hour.`]); } if (topAlert && total > 1 && topAlert.count / total >= 0.4) { items.push(['One alert drives most of the volume', `${topAlert.name} is ${Math.round((topAlert.count / total) * 100)}% of everything that fired.`]); } const body = items.length ? items.map(([t, s]) => h('div', { class: 'pattern' }, h('div', { class: 'pattern-title', text: t }), h('div', { class: 'pattern-sub', text: s }))) : h('p', { class: 'chart-note', text: 'Not enough incidents yet to see a pattern.' }); return card('Patterns', h('div', { class: 'patterns' }, body, topAlert && h('a', { class: 'btn btn-ghost', href: drill.path(topAlert.name), text: `Open ${topAlert.name} →` }))); }