Drill into a single alert from the stats page

The stats page could only say how many alerts fired. It now answers when
one alert fires, how often, and how fast it is picked up and fixed: each
row of the top-alerts list opens /stats/alert/{name}, with a timeline,
by-hour and by-weekday charts, time-to-resolve per occurrence and a table
of every occurrence linking to its incident. The overview gains a
weekday-by-hour heatmap and a few derived observations.

An occurrence is an incident, not an alert row. Alert rows are reused per
fingerprint and mutated in place, so they carry no history; incidents are
the only record of each time something fired and was answered. That means
/api/stats/alerts/top now counts incidents opened (filtered on
triggered_at) so the list and the drilldown agree. The response keeps name
and count and only adds fields, so existing clients keep working, but the
counts can differ from before.

No migration: the lookups use incidents_signature_idx. Label breakdown,
team and severity filters and period-over-period deltas from the design
draft are left for later.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
Niklas Ye
2026-10-18 21:55:16 +02:00
parent a314083fa9
commit 6235a2f737
11 changed files with 763 additions and 117 deletions
+4 -2
View File
@@ -427,13 +427,15 @@ Every figure counts the caller's own teams only: a report that counted other
teams' incidents would leak their volume, and their alert names through the
top-alerts list, and would not be a number about the reader's work anyway.
All stat endpoints accept optional `?from=YYYY-MM-DD` and `?to=YYYY-MM-DD`, and exclude archived rows to match the default list views. Alert stats filter on `received_at`; incident stats filter on `triggered_at`.
All stat endpoints accept optional `?from=YYYY-MM-DD` and `?to=YYYY-MM-DD`, and exclude archived rows to match the default list views. Alert stats filter on `received_at`; incident stats, and the top, heatmap and per-alert endpoints (an occurrence is an incident), filter on `triggered_at`.
| Method | Path | Description |
|---|---|---|
| `GET` | `/api/stats/incidents` | `{total, triggered, acknowledged, resolved, mtta_seconds, mttr_seconds}` |
| `GET` | `/api/stats/alerts` | `{total, firing, resolved}` counts |
| `GET` | `/api/stats/alerts/top` | Most frequent alert names. `?limit=` (default 10, max 100) |
| `GET` | `/api/stats/alerts/top` | Alert names ranked by incidents opened (filters on `triggered_at`). Each entry: `{name, count, last_seen, mean_resolve_seconds, daily}`; `daily` is 30 per-day counts ending at `?to` (or today), regardless of `?from`. `?limit=` (default 10, max 100) |
| `GET` | `/api/stats/alerts/heatmap` | `{grid}`: incidents by UTC weekday (0=Sunday) × hour, always 7×24 |
| `GET` | `/api/stats/alerts/{name}` | One alert's drilldown over its incidents: `{name, count, first_seen, last_seen, mean_gap_seconds, mean/median_ack_seconds, mean/median_resolve_seconds, longest_open_seconds, by_hour[24], by_weekday[7], occurrences[]}` (newest 200; means are `null` when unmeasurable). 404 when the alert opened no incident in scope |
| `GET` | `/api/stats/alerts/by-hour` | Count per hour-of-day (UTC), all 24 slots returned |
| `GET` | `/api/stats/alerts/by-day` | Count per day-of-week, all 7 slots with names returned |