3613fd5732
Incident list, the three stats breakdowns and the user list could return a truncated result as if complete when the scan failed partway.
240 lines
7.7 KiB
Go
240 lines
7.7 KiB
Go
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", callerTeamIDs(r.Context()))
|
|
|
|
// 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", callerTeamIDs(r.Context()))
|
|
|
|
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)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
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", callerTeamIDs(r.Context()))
|
|
|
|
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
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
|
|
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", callerTeamIDs(r.Context()))
|
|
|
|
// 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
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
|
|
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", callerTeamIDs(r.Context()))
|
|
|
|
// 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.
|
|
//
|
|
// teamIDs scopes every figure to the caller's own teams: a report that counted
|
|
// other teams' incidents would leak their volume and their names through the
|
|
// top-alerts list, and would not be a number about the reader's work anyway.
|
|
func statsFilter(q url.Values, timeCol string, teamIDs []int64) (where string, args *sqlArgs) {
|
|
args = &sqlArgs{}
|
|
clauses := []string{"archived_at IS NULL", "team_id = ANY(" + args.add(teamIDs) + ")"}
|
|
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
|
|
}
|