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 i279ef6c). 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ån279ef6clåser fast skillnaden. Claude-Session: https://claude.ai/code/session_01S7R4gWTz5wh5xCY4nCSJjN
This commit is contained in:
@@ -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.
|
// Nothing acknowledged yet means "no data", which is not the same claim as zero.
|
||||||
func TestStats_IncidentsNullMTTAWhenNothingAcknowledged(t *testing.T) {
|
func TestStats_IncidentsNullMTTAWhenNothingAcknowledged(t *testing.T) {
|
||||||
s := newTS(t)
|
s := newTS(t)
|
||||||
|
|||||||
+11
-5
@@ -13,11 +13,14 @@ func handleStatsAlerts(db *sql.DB) http.HandlerFunc {
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
where, args := statsFilter(r.URL.Query(), "received_at")
|
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
|
var total, firing, resolved int64
|
||||||
err := db.QueryRowContext(r.Context(), fmt.Sprintf(`
|
err := db.QueryRowContext(r.Context(), fmt.Sprintf(`
|
||||||
SELECT COUNT(*),
|
SELECT COUNT(*),
|
||||||
SUM(CASE WHEN status = 'firing' THEN 1 ELSE 0 END),
|
COALESCE(SUM(CASE WHEN status = 'firing' THEN 1 ELSE 0 END), 0),
|
||||||
SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END)
|
COALESCE(SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END), 0)
|
||||||
FROM alerts WHERE %s`, where), args...,
|
FROM alerts WHERE %s`, where), args...,
|
||||||
).Scan(&total, &firing, &resolved)
|
).Scan(&total, &firing, &resolved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -167,13 +170,16 @@ func handleStatsIncidents(db *sql.DB) http.HandlerFunc {
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
where, args := statsFilter(r.URL.Query(), "triggered_at")
|
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 total, triggered, acknowledged, resolved int64
|
||||||
var mtta, mttr *float64
|
var mtta, mttr *float64
|
||||||
err := db.QueryRowContext(r.Context(), fmt.Sprintf(`
|
err := db.QueryRowContext(r.Context(), fmt.Sprintf(`
|
||||||
SELECT COUNT(*),
|
SELECT COUNT(*),
|
||||||
SUM(CASE WHEN status = 'triggered' THEN 1 ELSE 0 END),
|
COALESCE(SUM(CASE WHEN status = 'triggered' THEN 1 ELSE 0 END), 0),
|
||||||
SUM(CASE WHEN status = 'acknowledged' THEN 1 ELSE 0 END),
|
COALESCE(SUM(CASE WHEN status = 'acknowledged' THEN 1 ELSE 0 END), 0),
|
||||||
SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END),
|
COALESCE(SUM(CASE WHEN status = 'resolved' THEN 1 ELSE 0 END), 0),
|
||||||
AVG(CASE WHEN acknowledged_at IS NOT NULL
|
AVG(CASE WHEN acknowledged_at IS NOT NULL
|
||||||
THEN acknowledged_at - triggered_at END),
|
THEN acknowledged_at - triggered_at END),
|
||||||
AVG(CASE WHEN resolved_at IS NOT NULL
|
AVG(CASE WHEN resolved_at IS NOT NULL
|
||||||
|
|||||||
Reference in New Issue
Block a user