Files
terdut-server/internal/api/alerts.go
T
Niklas Ye debc4bf78c
Release / build (amd64, darwin) (push) Failing after 2m46s
Release / build (amd64, linux) (push) Failing after 2m26s
Release / build (arm64, darwin) (push) Failing after 1m40s
Release / build (arm64, linux) (push) Failing after 10s
Release / release (push) Has been skipped
Release / chart (push) Failing after 11s
Release / docker (push) Failing after 19s
Add alert archiving
Alerts can be manually archived (POST /api/alerts/{id}/archive) or
unarchived (DELETE /api/alerts/{id}/archive). A background goroutine
auto-archives resolved alerts older than TERDUT_ARCHIVE_AFTER (default 7d).
GET /api/alerts hides archived alerts by default; ?archived=true shows them.
2026-05-22 13:22:45 +02:00

257 lines
7.0 KiB
Go

package api
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/yeniklas/terdut-server/internal/models"
)
// alertSelectFrom is the shared SELECT … FROM … clause used by all alert queries.
// It LEFT JOINs users so acknowledged_by username is always available.
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,
a.acknowledged_by, a.acknowledged_at, u.username,
a.archived_at
FROM alerts a
LEFT JOIN users u ON u.id = a.acknowledged_by`
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 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)
}
}
func handleAcknowledge(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
}
user, _ := userFromContext(r.Context())
res, err := db.ExecContext(r.Context(),
"UPDATE alerts SET acknowledged_by = ?, acknowledged_at = ? WHERE id = ?",
user.ID, time.Now().Unix(), id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("alert not found"))
return
}
a, _ := fetchAlert(r.Context(), db, id)
respond(w, http.StatusOK, a)
}
}
func handleUnacknowledge(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
}
res, err := db.ExecContext(r.Context(),
"UPDATE alerts SET acknowledged_by = NULL, acknowledged_at = NULL WHERE id = ?", id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("alert not found"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// fetchAlert loads a single alert by ID using the shared JOIN 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, ackAtUnix, archivedAtUnix *int64
var ackByID *int64
var ackByUser *string
if err := s.Scan(
&a.ID, &a.Fingerprint, &a.Name, &a.Status,
&labelsJSON, &annotationsJSON,
&startsAtUnix, &endsAtUnix,
&a.GeneratorURL, &receivedAtUnix,
&ackByID, &ackAtUnix, &ackByUser,
&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()
if endsAtUnix != nil {
t := time.Unix(*endsAtUnix, 0).UTC()
a.EndsAt = &t
}
if ackByID != nil {
t := time.Unix(*ackAtUnix, 0).UTC()
a.AcknowledgedByID = ackByID
a.AcknowledgedByUser = ackByUser
a.AcknowledgedAt = &t
}
if archivedAtUnix != nil {
t := time.Unix(*archivedAtUnix, 0).UTC()
a.ArchivedAt = &t
}
return a, nil
}
func handleArchive(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
}
res, err := db.ExecContext(r.Context(),
"UPDATE alerts SET archived_at = unixepoch() WHERE id = ?", id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("alert not found"))
return
}
a, _ := fetchAlert(r.Context(), db, id)
respond(w, http.StatusOK, a)
}
}
func handleUnarchive(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
}
res, err := db.ExecContext(r.Context(),
"UPDATE alerts SET archived_at = NULL WHERE id = ?", id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("alert not found"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}