Compare commits

...

2 Commits

Author SHA1 Message Date
Niklas Ye 734cd9c5fd Set the chart's placeholder version to 0.21.0
CI / chart (push) Successful in 2s
CI / security (push) Successful in 16s
CI / test (push) Successful in 2m52s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 3s
Release / binaries (push) Successful in 18s
Release / image (push) Successful in 58s
Release / scan-image (push) Successful in 25s
Cosmetic: make helm-package sets the published version and appVersion
from the tag, so these two fields decide nothing (see the comment
above them). Kept in step anyway, same as 43f0044 and e77f04b, so a
tree heading for v0.21.0 doesn't say 0.20.1.
2026-09-25 13:02:08 +02:00
Niklas Ye 423ed9b3a3 Add a Stats page to the web UI
Statistics used to live only in terdut-tui; the account page said so.
The page shows the same figures as the TUI's Stats tab -- incident
counts, MTTA and MTTR, top alerts, and alert frequency by hour (UTC) and
by day of week -- and adds a range picker (Today, 7d, 30d, 90d, All)
that the TUI does not have. The ranges are day-granular because the
server reads from/to as whole UTC dates, so there is no 24h chip.

No server change: the page uses the existing /api/stats/* endpoints,
which already scope to the caller's teams. Charts are inline SVG and
plain elements sized from script, because the CSP forbids inline styles,
inline scripts and CDN libraries.

Removes the "statistics are in terdut-tui" notes from the account page
and the README.
2026-09-25 13:02:08 +02:00
10 changed files with 252 additions and 6 deletions
+3 -2
View File
@@ -51,8 +51,9 @@ archive), who is on call, the alert feed, and an *Account* tab for your own
password and the ntfy topic your pages go to. It is built for a phone first. On a phone
it navigates through a hamburger menu and has a sticky action bar, it follows the
system's dark mode, and it can be added to the home screen. From 900px wide it switches
to a sidebar with the queue and the incident side by side. Statistics remain in
[terdut-tui](https://github.com/yeniklas/terdut-tui) for now.
to a sidebar with the queue and the incident side by side. The Stats page shows
incident counts, MTTA and MTTR, and alert frequency by name, hour and day over a
chosen range.
You sign in with a username and password. Users have no password until one is
set, and a user without one can only use API keys:
+2 -2
View File
@@ -15,5 +15,5 @@ type: application
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing.
version: 0.20.1
appVersion: "v0.20.1"
version: 0.21.0
appVersion: "v0.21.0"
+37
View File
@@ -867,3 +867,40 @@ button.rota-day:hover { background: var(--surface-2); }
.overview-head { display: flex; align-items: baseline; gap: 8px; }
.overview-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; }
.overview-item p { margin: 4px 0 0; }
/* ---------- stats page ---------- */
#view-stats .chips { padding-left: 0; padding-right: 0; }
.stats { display: grid; gap: 14px; padding-bottom: 16px; }
.stat-tiles { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.stat-tile {
--sev: var(--border-strong);
background: var(--surface); border: 1px solid var(--border);
border-left: 3px solid var(--sev); border-radius: var(--radius);
box-shadow: var(--shadow); padding: 12px;
}
.stat-tile.st-triggered { --sev: var(--crit); }
.stat-tile.st-acknowledged { --sev: var(--warn); }
.stat-tile.st-resolved { --sev: var(--ok); }
.stat-value { font-size: 24px; font-weight: 700; font-variant-numeric: tabular-nums; }
.stat-label { margin-top: 2px; font-size: 12px; color: var(--muted); }
.chart-card + .chart-card { margin-top: 0; }
.chart-title {
margin-bottom: 10px; font-size: 13px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted);
}
.hbars { list-style: none; display: grid; gap: 6px; }
.hbar { display: grid; grid-template-columns: minmax(80px, 34%) 1fr auto; align-items: center; gap: 8px; font-size: 13px; }
.hbar-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hbar-track { height: 10px; border-radius: 5px; background: var(--surface-2); overflow: hidden; }
.hbar-fill { display: block; height: 100%; border-radius: 5px; background: var(--ok); }
.hbar-count { min-width: 2ch; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
.columns { display: block; width: 100%; height: auto; }
.columns .axis { stroke: var(--border-strong); stroke-width: 1; }
.columns .col-hit { fill: transparent; }
.columns .col-bar { fill: var(--accent); }
.columns .col:hover .col-bar { opacity: 0.75; }
.columns .col-label { fill: var(--faint); font-size: 10px; font-family: var(--font); }
@media (min-width: 900px) {
.stat-tiles { grid-template-columns: repeat(3, 1fr); }
}
+5
View File
@@ -90,6 +90,10 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
<span class="nav-label">Alerts</span>
</a>
<a class="nav-link" href="/stats" data-section="stats" aria-label="Stats">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h16M7 20v-7M12 20V6M17 20v-10"/></svg>
<span class="nav-label">Stats</span>
</a>
<a class="nav-link" href="/team" data-section="team" aria-label="Team">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>
<span class="nav-label">Team</span>
@@ -128,6 +132,7 @@
<section id="view-oncall" class="view view-page" data-view="oncall" hidden></section>
<section id="view-alerts" class="view view-page" data-view="alerts" hidden></section>
<section id="view-stats" class="view view-page" data-view="stats" hidden></section>
<section id="view-team" class="view view-page" data-view="team" hidden></section>
<section id="view-admin" class="view view-page" data-view="admin" hidden></section>
<!-- One person, at /admin/users/{id}: reached from the Admin tab's user
-1
View File
@@ -35,7 +35,6 @@ function render() {
h('div', { class: 'page-head' }),
h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'),
h('p', { class: 'foot-note', text: 'Statistics are in terdut-tui for now.' }),
);
}
+6
View File
@@ -92,6 +92,12 @@ export const unarchive = (id) => call('DELETE', `/incidents/${id}/archive`);
export const addNote = (id, content) => call('POST', `/incidents/${id}/notes`, { body: { content } });
export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`);
// stats
export const statsIncidents = (query) => call('GET', '/stats/incidents', { query });
export const statsTop = (query) => call('GET', '/stats/alerts/top', { query });
export const statsByHour = (query) => call('GET', '/stats/alerts/by-hour', { query });
export const statsByDay = (query) => call('GET', '/stats/alerts/by-day', { query });
// alerts
export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts });
+4 -1
View File
@@ -8,6 +8,7 @@ import * as queue from './queue.js';
import * as incident from './incident.js';
import * as oncall from './oncall.js';
import * as alerts from './alerts.js';
import * as stats from './stats.js';
import * as account from './account.js';
import * as team from './team.js';
import * as admin from './admin.js';
@@ -23,6 +24,7 @@ const SECTIONS = {
queue: { title: 'Queue', view: queue },
oncall: { title: 'On-call', view: oncall },
alerts: { title: 'Alerts', view: alerts },
stats: { title: 'Stats', view: stats },
team: { title: 'Team', view: team },
admin: { title: 'Admin', view: admin },
adminuser: { title: 'User', view: adminuser, nav: 'admin' },
@@ -36,6 +38,7 @@ const NAV_ITEMS = [
{ path: '/', section: 'queue', label: 'Queue', icon: 'queueList' },
{ path: '/oncall', section: 'oncall', label: 'On-call', icon: 'calendar' },
{ path: '/alerts', section: 'alerts', label: 'Alerts', icon: 'bell' },
{ path: '/stats', section: 'stats', label: 'Stats', icon: 'chart' },
{ path: '/team', section: 'team', label: 'Team', icon: 'team' },
{ path: '/admin', section: 'admin', label: 'Admin', icon: 'shield', adminOnly: true },
{ path: '/more', section: 'more', label: 'Account', icon: 'user' },
@@ -58,7 +61,7 @@ function parseRoute(pathname) {
if (t) return { section: 'admin', tab: t.tab };
const tt = team.TABS.find((x) => x.path === `/${name}`);
if (tt) return { section: 'team', tab: tt.tab };
if (name === 'oncall' || name === 'alerts' || name === 'more') return { section: name };
if (name === 'oncall' || name === 'alerts' || name === 'stats' || name === 'more') return { section: name };
return { section: 'queue', incident: null };
}
+168
View File
@@ -0,0 +1,168 @@
// 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;
}
+1
View File
@@ -43,6 +43,7 @@ const ICONS = {
undo: ['M9 14L4 9l5-5', 'M4 9h10a6 6 0 0 1 0 12h-3'],
user: ['circle:12,8,3.5', 'M5 20a7 7 0 0 1 14 0'],
clock: ['circle:12,12,9', 'M12 7v5l3 2'],
chart: ['M4 20h16M7 20v-7M12 20V6M17 20v-10'],
bell: ['M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z', 'M10 20.5a2 2 0 0 0 4 0'],
note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'],
archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'],
+26
View File
@@ -0,0 +1,26 @@
package web
import (
"io/fs"
"strings"
"testing"
)
func TestStatsPageIsEmbedded(t *testing.T) {
sub, err := fs.Sub(files, "static")
if err != nil {
t.Fatal(err)
}
index, err := fs.ReadFile(sub, "index.html")
if err != nil {
t.Fatal(err)
}
for _, want := range []string{`id="view-stats"`, `data-section="stats"`} {
if !strings.Contains(string(index), want) {
t.Errorf("index.html lacks %s", want)
}
}
if _, err := fs.Stat(sub, "js/stats.js"); err != nil {
t.Errorf("js/stats.js not embedded: %v", err)
}
}