Files
terdut-server/internal/api/stats.go
T
Niklas Ye 94dec19976 Räkna en tom incidentlista som noll i stället för som ett fel
SUM över noll rader är NULL i SQLite, inte 0. handleStatsIncidents läste de
tre statusräknarna rakt in i int64, så i samma stund som filtret inte
matchade någon rad föll skanningen på "converting NULL to int64 is
unsupported" och hela /api/stats/incidents svarade 500. COUNT(*) ger
däremot 0 utan knot, vilket är precis varför felet inte syns förrän
tabellen töms — det är det enda uttrycket i satsen som klarar noll rader.

Filtret är alltid på: statsFilter lägger på archived_at IS NULL (923fc8b,
flyttat hit i 279ef6c). En installation som varit tyst ett tag arkiverar
därmed sig själv in i felet. Det är sluttillståndet för en lugn vecka, inte
ett kantfall, och klustret står i det nu.

Symptomet pekade åt fel håll. terdut-tui hämtar listan och statistiken i
samma uppdatering, så incidentvyn såg trasig ut medan /api/incidents
svarade 200 med []. Loggen i klustret visar de två anropen bredvid
varandra, det ena grönt och det andra rött. Ingen ändring i terdut-tui
behövs: dess ListIncidents är oförändrad sedan 0.7.2 och skickar samma
parametrar som förut.

handleStatsAlerts bar samma fel och rättas likadant, innan någon hittar
det på samma sätt. COALESCE i SQL i stället för sql.NullInt64 i Go,
eftersom jämförelserna redan bor i satserna här (severityRankSQL, 279ef6c).

mtta_seconds och mttr_seconds lämnas medvetet utan COALESCE. null betyder
"inget att mäta ännu" och 0 skulle läsas som "omedelbart" — två olika
påståenden, och testet från 279ef6c låser fast skillnaden.

Claude-Session: https://claude.ai/code/session_01S7R4gWTz5wh5xCY4nCSJjN
2026-09-01 21:47:39 +02:00

226 lines
6.8 KiB
Go

package api
import (
"database/sql"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
func handleStatsAlerts(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
where, args := statsFilter(r.URL.Query(), "received_at")
// COALESCE because SUM over zero rows is NULL, not 0, and a count of
// nothing is 0 — without it an empty window is a 500 rather than a
// legitimately empty report.
var total, firing, resolved int64
err := db.QueryRowContext(r.Context(), fmt.Sprintf(`
SELECT COUNT(*),
COALESCE(SUM(CASE WHEN status = 'firing' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END), 0)
FROM alerts WHERE %s`, where), args...,
).Scan(&total, &firing, &resolved)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, map[string]int64{
"total": total,
"firing": firing,
"resolved": resolved,
})
}
}
func handleStatsTop(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
where, args := statsFilter(r.URL.Query(), "received_at")
limit := 10
if l := r.URL.Query().Get("limit"); l != "" {
var n int
fmt.Sscanf(l, "%d", &n)
if n > 0 && n <= 100 {
limit = n
}
}
args = append(args, limit)
rows, err := db.QueryContext(r.Context(), fmt.Sprintf(`
SELECT name, COUNT(*) AS cnt
FROM alerts
WHERE %s
GROUP BY name
ORDER BY cnt DESC
LIMIT ?`, where), args...)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer rows.Close()
type entry struct {
Name string `json:"name"`
Count int64 `json:"count"`
}
result := []entry{}
for rows.Next() {
var e entry
if err := rows.Scan(&e.Name, &e.Count); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
result = append(result, e)
}
respond(w, http.StatusOK, result)
}
}
func handleStatsByHour(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
where, args := statsFilter(r.URL.Query(), "received_at")
rows, err := db.QueryContext(r.Context(), fmt.Sprintf(`
SELECT CAST(strftime('%%H', datetime(received_at, 'unixepoch')) AS INTEGER) AS hr,
COUNT(*) AS cnt
FROM alerts
WHERE %s
GROUP BY hr
ORDER BY hr ASC`, where), args...)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer rows.Close()
counts := make(map[int]int64, 24)
for rows.Next() {
var hr int
var cnt int64
if err := rows.Scan(&hr, &cnt); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
counts[hr] = cnt
}
type entry struct {
Hour int `json:"hour"`
Count int64 `json:"count"`
}
result := make([]entry, 24)
for h := range result {
result[h] = entry{Hour: h, Count: counts[h]}
}
respond(w, http.StatusOK, result)
}
}
func handleStatsByDay(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
where, args := statsFilter(r.URL.Query(), "received_at")
// SQLite strftime('%w') → 0=Sunday … 6=Saturday
rows, err := db.QueryContext(r.Context(), fmt.Sprintf(`
SELECT CAST(strftime('%%w', datetime(received_at, 'unixepoch')) AS INTEGER) AS dow,
COUNT(*) AS cnt
FROM alerts
WHERE %s
GROUP BY dow
ORDER BY dow ASC`, where), args...)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer rows.Close()
counts := make(map[int]int64, 7)
for rows.Next() {
var dow int
var cnt int64
if err := rows.Scan(&dow, &cnt); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
counts[dow] = cnt
}
dayNames := [7]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
type entry struct {
Day int `json:"day"`
DayName string `json:"day_name"`
Count int64 `json:"count"`
}
result := make([]entry, 7)
for d := range result {
result[d] = entry{Day: d, DayName: dayNames[d], Count: counts[d]}
}
respond(w, http.StatusOK, result)
}
}
// handleStatsIncidents reports the queue and the two numbers a rota actually
// cares about: how long it takes someone to pick work up, and how long it takes
// to finish. Neither was computable before incidents existed — alert rows are
// mutated in place and carry no acknowledgement or closure time.
func handleStatsIncidents(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
where, args := statsFilter(r.URL.Query(), "triggered_at")
// The counts are COALESCEd because SUM over zero rows is NULL, not 0.
// The averages are not: mtta and mttr stay null on purpose, since zero
// would read as "instant" rather than "nothing to measure yet".
var total, triggered, acknowledged, resolved int64
var mtta, mttr *float64
err := db.QueryRowContext(r.Context(), fmt.Sprintf(`
SELECT COUNT(*),
COALESCE(SUM(CASE WHEN status = 'triggered' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'acknowledged' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END), 0),
AVG(CASE WHEN acknowledged_at IS NOT NULL
THEN acknowledged_at - triggered_at END),
AVG(CASE WHEN resolved_at IS NOT NULL
THEN resolved_at - triggered_at END)
FROM incidents WHERE %s`, where), args...,
).Scan(&total, &triggered, &acknowledged, &resolved, &mtta, &mttr)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, map[string]any{
"total": total,
"triggered": triggered,
"acknowledged": acknowledged,
"resolved": resolved,
// Null until something has actually been acknowledged or resolved —
// zero would read as "instant", which is a different claim.
"mtta_seconds": mtta,
"mttr_seconds": mttr,
})
}
}
// statsFilter builds a WHERE clause and args from optional ?from and ?to query
// params, filtering on timeCol. Archived rows are always excluded, matching the
// default list views.
func statsFilter(q url.Values, timeCol string) (where string, args []any) {
clauses := []string{"archived_at IS NULL"}
if from := q.Get("from"); from != "" {
if t, err := time.Parse("2006-01-02", from); err == nil {
clauses = append(clauses, timeCol+" >= ?")
args = append(args, t.UTC().Unix())
}
}
if to := q.Get("to"); to != "" {
if t, err := time.Parse("2006-01-02", to); err == nil {
clauses = append(clauses, timeCol+" < ?")
args = append(args, t.UTC().AddDate(0, 0, 1).Unix())
}
}
return strings.Join(clauses, " AND "), args
}