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
This commit is contained in:
Niklas Ye
2026-09-01 21:47:39 +02:00
parent 9046f6e026
commit 94dec19976
2 changed files with 66 additions and 5 deletions
+55
View File
@@ -653,6 +653,61 @@ func TestStats_Incidents(t *testing.T) {
}
}
// An empty window is a report of zero, not a failure. SUM over no rows is NULL
// in SQLite, which used to come back as a 500 the moment every incident was
// archived — the state a quiet installation settles into.
func TestStats_IncidentsEmptyWindowIsZeroNotAnError(t *testing.T) {
s := newTS(t)
// No incidents at all.
resp := s.req(t, http.MethodGet, "/api/stats/incidents", nil)
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
t.Fatalf("expected 200 on an empty database, got %d", resp.StatusCode)
}
var stats map[string]any
decode(t, resp, &stats)
for _, k := range []string{"total", "triggered", "acknowledged", "resolved"} {
if stats[k].(float64) != 0 {
t.Errorf("expected %s 0, got %v", k, stats[k])
}
}
// And with every incident archived out of the window.
postWebhook(t, s, []map[string]any{
amAlert("fp-s4", "Gone", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
})
s.req(t, http.MethodPost, "/api/incidents/1/archive", nil).Body.Close()
resp = s.req(t, http.MethodGet, "/api/stats/incidents", nil)
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
t.Fatalf("expected 200 when every incident is archived, got %d", resp.StatusCode)
}
stats = nil
decode(t, resp, &stats)
if stats["total"].(float64) != 0 {
t.Errorf("expected total 0, got %v", stats["total"])
}
}
// The alert stats share the same aggregate, and the same empty-window trap.
func TestStats_AlertsEmptyWindowIsZeroNotAnError(t *testing.T) {
s := newTS(t)
resp := s.req(t, http.MethodGet, "/api/stats/alerts", nil)
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
t.Fatalf("expected 200 on an empty database, got %d", resp.StatusCode)
}
var stats map[string]any
decode(t, resp, &stats)
for _, k := range []string{"total", "firing", "resolved"} {
if stats[k].(float64) != 0 {
t.Errorf("expected %s 0, got %v", k, stats[k])
}
}
}
// Nothing acknowledged yet means "no data", which is not the same claim as zero.
func TestStats_IncidentsNullMTTAWhenNothingAcknowledged(t *testing.T) {
s := newTS(t)