diff --git a/internal/api/router.go b/internal/api/router.go index 26cbf82..a18243a 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -43,6 +43,11 @@ func NewRouter(db *sql.DB) http.Handler { r.Get("/api/schedule/current", handleCurrentSchedule(db)) // must be before /{id} r.Get("/api/schedule", handleListSchedule(db)) r.Delete("/api/schedule/{id}", handleDeleteSchedule(db)) + + r.Get("/api/stats/alerts", handleStatsAlerts(db)) + r.Get("/api/stats/alerts/top", handleStatsTop(db)) + r.Get("/api/stats/alerts/by-hour", handleStatsByHour(db)) + r.Get("/api/stats/alerts/by-day", handleStatsByDay(db)) }) return r diff --git a/internal/api/stats.go b/internal/api/stats.go new file mode 100644 index 0000000..640975b --- /dev/null +++ b/internal/api/stats.go @@ -0,0 +1,181 @@ +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()) + + 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) + FROM alerts WHERE %s`, where), args..., + ).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()) + + 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 + } + } + args = append(args, limit) + + 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 ?`, where), args...) + 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()) + + rows, err := db.QueryContext(r.Context(), fmt.Sprintf(` + SELECT CAST(strftime('%%H', datetime(received_at, 'unixepoch')) AS INTEGER) AS hr, + COUNT(*) AS cnt + FROM alerts + WHERE %s + GROUP BY hr + ORDER BY hr ASC`, where), args...) + 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()) + + // SQLite strftime('%w') → 0=Sunday … 6=Saturday + rows, err := db.QueryContext(r.Context(), fmt.Sprintf(` + SELECT CAST(strftime('%%w', datetime(received_at, 'unixepoch')) AS INTEGER) AS dow, + COUNT(*) AS cnt + FROM alerts + WHERE %s + GROUP BY dow + ORDER BY dow ASC`, where), args...) + 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) + } +} + +// statsFilter builds a WHERE clause and args from optional ?from and ?to query params. +func statsFilter(q url.Values) (where string, args []any) { + clauses := []string{} + if from := q.Get("from"); from != "" { + if t, err := time.Parse("2006-01-02", from); err == nil { + clauses = append(clauses, "received_at >= ?") + args = append(args, t.UTC().Unix()) + } + } + if to := q.Get("to"); to != "" { + if t, err := time.Parse("2006-01-02", to); err == nil { + clauses = append(clauses, "received_at < ?") + args = append(args, t.UTC().AddDate(0, 0, 1).Unix()) + } + } + if len(clauses) == 0 { + return "1=1", args + } + return strings.Join(clauses, " AND "), args +}