From 94dec199764b5e8ba4739a1a4cf198fcda7775cd Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Tue, 1 Sep 2026 21:47:39 +0200 Subject: [PATCH] =?UTF-8?q?R=C3=A4kna=20en=20tom=20incidentlista=20som=20n?= =?UTF-8?q?oll=20i=20st=C3=A4llet=20f=C3=B6r=20som=20ett=20fel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/api/incidents_test.go | 55 ++++++++++++++++++++++++++++++++++ internal/api/stats.go | 16 ++++++---- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/internal/api/incidents_test.go b/internal/api/incidents_test.go index 2022609..4820503 100644 --- a/internal/api/incidents_test.go +++ b/internal/api/incidents_test.go @@ -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) diff --git a/internal/api/stats.go b/internal/api/stats.go index 7b20a2c..2572994 100644 --- a/internal/api/stats.go +++ b/internal/api/stats.go @@ -13,11 +13,14 @@ 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(*), - SUM(CASE WHEN status = 'firing' THEN 1 ELSE 0 END), - SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END) + 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 { @@ -167,13 +170,16 @@ 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(*), - SUM(CASE WHEN status = 'triggered' THEN 1 ELSE 0 END), - SUM(CASE WHEN status = 'acknowledged' THEN 1 ELSE 0 END), - SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END), + 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