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.all()..., ).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 } } 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 %s`, where, args.add(limit)), args.all()...) 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 EXTRACT(HOUR FROM to_timestamp(received_at) AT TIME ZONE 'UTC')::int AS hr, COUNT(*) AS cnt FROM alerts WHERE %s GROUP BY hr ORDER BY hr ASC`, where), args.all()...) 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") // Postgres EXTRACT(DOW …) → 0=Sunday … 6=Saturday, the same numbering // SQLite's strftime('%w') returned, so the frontend needs no change. rows, err := db.QueryContext(r.Context(), fmt.Sprintf(` SELECT EXTRACT(DOW FROM to_timestamp(received_at) AT TIME ZONE 'UTC')::int AS dow, COUNT(*) AS cnt FROM alerts WHERE %s GROUP BY dow ORDER BY dow ASC`, where), args.all()...) 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.all()..., ).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 *sqlArgs) { args = &sqlArgs{} 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.add(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.add(t.UTC().AddDate(0, 0, 1).Unix())) } } return strings.Join(clauses, " AND "), args }