03504b61be
CI / test (push) Successful in 5s
The rename to git.ryuvia.com/niklas/... was a plain string substitution, so it left the import blocks in their old order. The new path sorts before github.com/go-chi/..., where the old one sorted after, which gofmt considers unformatted. go vet does not look at import order, so CI had nothing to say about it.
164 lines
4.6 KiB
Go
164 lines
4.6 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// alertSelectFrom is the shared SELECT … FROM … clause used by all alert queries.
|
|
// The subquery resolves the alert's most recent incident: membership is kept in
|
|
// incident_alerts rather than as a column here, because one alert row is reused
|
|
// across occurrences and belongs to a different incident each time.
|
|
const alertSelectFrom = `
|
|
SELECT a.id, a.fingerprint, a.name, a.status,
|
|
a.labels, a.annotations,
|
|
a.starts_at, a.ends_at, a.generator_url, a.received_at,
|
|
(SELECT ia.incident_id
|
|
FROM incident_alerts ia
|
|
JOIN incidents i ON i.id = ia.incident_id
|
|
WHERE ia.alert_id = a.id
|
|
ORDER BY i.triggered_at DESC, i.id DESC
|
|
LIMIT 1),
|
|
a.resolution_source, a.archived_at
|
|
FROM alerts a`
|
|
|
|
func handleListAlerts(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
|
|
where := []string{}
|
|
args := []any{}
|
|
|
|
if status := q.Get("status"); status != "" {
|
|
where = append(where, "a.status = ?")
|
|
args = append(args, status)
|
|
}
|
|
if name := q.Get("name"); name != "" {
|
|
where = append(where, "a.name = ?")
|
|
args = append(args, name)
|
|
}
|
|
if archived := q.Get("archived"); archived == "true" {
|
|
where = append(where, "a.archived_at IS NOT NULL")
|
|
} else {
|
|
where = append(where, "a.archived_at IS NULL")
|
|
}
|
|
if incidentID := q.Get("incident_id"); incidentID != "" {
|
|
if n, err := strconv.ParseInt(incidentID, 10, 64); err == nil {
|
|
where = append(where, "a.id IN (SELECT alert_id FROM incident_alerts WHERE incident_id = ?)")
|
|
args = append(args, n)
|
|
}
|
|
}
|
|
|
|
if from := q.Get("from"); from != "" {
|
|
if t, err := time.Parse("2006-01-02", from); err == nil {
|
|
where = append(where, "a.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 {
|
|
where = append(where, "a.received_at < ?")
|
|
args = append(args, t.UTC().AddDate(0, 0, 1).Unix())
|
|
}
|
|
}
|
|
|
|
limit := 50
|
|
if l := q.Get("limit"); l != "" {
|
|
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 500 {
|
|
limit = n
|
|
}
|
|
}
|
|
|
|
clause := "1=1"
|
|
if len(where) > 0 {
|
|
clause = strings.Join(where, " AND ")
|
|
}
|
|
args = append(args, limit)
|
|
|
|
rows, err := db.QueryContext(r.Context(),
|
|
fmt.Sprintf("%s WHERE %s ORDER BY a.received_at DESC LIMIT ?", alertSelectFrom, clause),
|
|
args...)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
alerts := []models.Alert{}
|
|
for rows.Next() {
|
|
a, err := scanAlert(rows)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
alerts = append(alerts, a)
|
|
}
|
|
respond(w, http.StatusOK, alerts)
|
|
}
|
|
}
|
|
|
|
func handleGetAlert(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
if err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid alert id"))
|
|
return
|
|
}
|
|
a, err := fetchAlert(r.Context(), db, id)
|
|
if err == sql.ErrNoRows {
|
|
respond(w, http.StatusNotFound, errResp("alert not found"))
|
|
return
|
|
}
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, a)
|
|
}
|
|
}
|
|
|
|
// fetchAlert loads a single alert by ID using the shared query.
|
|
func fetchAlert(ctx context.Context, db *sql.DB, id int64) (models.Alert, error) {
|
|
return scanAlert(db.QueryRowContext(ctx, alertSelectFrom+" WHERE a.id = ?", id))
|
|
}
|
|
|
|
// scanner is satisfied by both *sql.Row and *sql.Rows.
|
|
type scanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanAlert(s scanner) (models.Alert, error) {
|
|
var a models.Alert
|
|
var labelsJSON, annotationsJSON string
|
|
var startsAtUnix, receivedAtUnix int64
|
|
var endsAtUnix, archivedAtUnix *int64
|
|
|
|
if err := s.Scan(
|
|
&a.ID, &a.Fingerprint, &a.Name, &a.Status,
|
|
&labelsJSON, &annotationsJSON,
|
|
&startsAtUnix, &endsAtUnix,
|
|
&a.GeneratorURL, &receivedAtUnix,
|
|
&a.IncidentID,
|
|
&a.ResolutionSource, &archivedAtUnix,
|
|
); err != nil {
|
|
return a, err
|
|
}
|
|
|
|
json.Unmarshal([]byte(labelsJSON), &a.Labels) //nolint:errcheck
|
|
json.Unmarshal([]byte(annotationsJSON), &a.Annotations) //nolint:errcheck
|
|
a.StartsAt = time.Unix(startsAtUnix, 0).UTC()
|
|
a.ReceivedAt = time.Unix(receivedAtUnix, 0).UTC()
|
|
a.EndsAt = unixPtr(endsAtUnix)
|
|
a.ArchivedAt = unixPtr(archivedAtUnix)
|
|
return a, nil
|
|
}
|