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