From 873821b87126b70dec574c2dead7ab246e4a6fe8 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 4 May 2026 19:59:24 +0100 Subject: [PATCH] http: /alerts list + ack/resolve handlers + /api/alerts JSON --- internal/server/http/server.go | 7 ++ internal/server/http/ui_alerts.go | 165 +++++++++++++++++++++++++ internal/server/http/ui_alerts_test.go | 41 ++++++ 3 files changed, 213 insertions(+) create mode 100644 internal/server/http/ui_alerts.go create mode 100644 internal/server/http/ui_alerts_test.go diff --git a/internal/server/http/server.go b/internal/server/http/server.go index fe51489..743c404 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -199,6 +199,9 @@ func (s *Server) routes(r chi.Router) { // Snapshot diff (P3-09). Dispatches a JobDiff against two // snapshots; output streams to the standard live job page. r.Post("/hosts/{id}/snapshots/diff", s.handleSnapshotDiff) + + // Alert list (JSON variant). Same filter shape as the UI page. + r.Get("/alerts", s.handleAPIAlerts) }) // HTMX form variant of diff (mounted outside /api so HTMX forms @@ -302,6 +305,10 @@ func (s *Server) routes(r chi.Router) { r.Get("/hosts/{id}/snapshots/{sid}/restore", s.handleUIRestoreGet) r.Post("/hosts/{id}/restore", s.handleUIRestorePost) r.Get("/hosts/{id}/restore/tree", s.handleUIRestoreTree) + // Alerts list + operator actions. + r.Get("/alerts", s.handleUIAlerts) + r.Post("/alerts/{id}/acknowledge", s.handleUIAlertAcknowledge) + r.Post("/alerts/{id}/resolve", s.handleUIAlertResolve) } // Browser job-log stream (separate from /ws/agent so the auth diff --git a/internal/server/http/ui_alerts.go b/internal/server/http/ui_alerts.go new file mode 100644 index 0000000..7a703e6 --- /dev/null +++ b/internal/server/http/ui_alerts.go @@ -0,0 +1,165 @@ +package http + +import ( + "encoding/json" + "log/slog" + stdhttp "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +type alertsPage struct { + Filter store.AlertFilter + Alerts []store.Alert + Counts alertCounts + HostNames map[string]string // host_id → name for table rendering +} + +type alertCounts struct { + Open int + Acknowledged int + Resolved24h int +} + +// handleUIAlerts renders the alerts page with the chosen filters. +func (s *Server) handleUIAlerts(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + q := r.URL.Query() + f := store.AlertFilter{ + Status: q.Get("status"), + Severity: q.Get("severity"), + HostID: q.Get("host_id"), + Search: strings.TrimSpace(q.Get("q")), + Limit: 200, + } + if f.Status == "" { + f.Status = "open" + } + + alerts, err := s.deps.Store.ListAlerts(r.Context(), f) + if err != nil { + slog.Error("ui alerts: list", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + + page := alertsPage{Filter: f, Alerts: alerts, HostNames: map[string]string{}} + if hosts, err := s.deps.Store.ListHosts(r.Context()); err == nil { + for _, h := range hosts { + page.HostNames[h.ID] = h.Name + } + } + page.Counts = computeAlertCounts(s, r) + + view := s.baseView(u) + view.Title = "Alerts · restic-manager" + view.Active = "alerts" + view.Page = page + if err := s.deps.UI.Render(w, "alerts", view); err != nil { + slog.Error("ui alerts: render", "err", err) + } +} + +func computeAlertCounts(s *Server, r *stdhttp.Request) alertCounts { + open, _ := s.deps.Store.ListAlerts(r.Context(), + store.AlertFilter{Status: "open"}) + acked, _ := s.deps.Store.ListAlerts(r.Context(), + store.AlertFilter{Status: "acknowledged"}) + cutoff := time.Now().UTC().Add(-24 * time.Hour) + all, _ := s.deps.Store.ListAlerts(r.Context(), + store.AlertFilter{Status: "resolved"}) + res := 0 + for _, a := range all { + if a.ResolvedAt != nil && a.ResolvedAt.After(cutoff) { + res++ + } + } + return alertCounts{Open: len(open), Acknowledged: len(acked), Resolved24h: res} +} + +// handleAPIAlerts is the JSON list — same filter shape. +func (s *Server) handleAPIAlerts(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if _, ok := s.requireUser(r); !ok { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") + return + } + q := r.URL.Query() + f := store.AlertFilter{ + Status: q.Get("status"), + Severity: q.Get("severity"), + HostID: q.Get("host_id"), + Search: strings.TrimSpace(q.Get("q")), + Limit: 200, + } + alerts, err := s.deps.Store.ListAlerts(r.Context(), f) + if err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "") + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(alerts) +} + +// handleUIAlertAcknowledge is POST /alerts/{id}/acknowledge. +func (s *Server) handleUIAlertAcknowledge(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + id := chi.URLParam(r, "id") + if id == "" { + stdhttp.Error(w, "missing id", stdhttp.StatusBadRequest) + return + } + if err := s.deps.Store.Acknowledge(r.Context(), id, u.ID, time.Now().UTC()); err != nil { + slog.Warn("ui alerts: ack", "err", err) + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: &u.ID, Actor: "user", + Action: "alert.acknowledge", + TargetKind: ptr("alert"), TargetID: &id, + TS: time.Now().UTC(), + }) + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Redirect", "/alerts?"+r.URL.RawQuery) + w.WriteHeader(stdhttp.StatusNoContent) + return + } + stdhttp.Redirect(w, r, "/alerts", stdhttp.StatusSeeOther) +} + +// handleUIAlertResolve is POST /alerts/{id}/resolve. +func (s *Server) handleUIAlertResolve(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + id := chi.URLParam(r, "id") + if id == "" { + stdhttp.Error(w, "missing id", stdhttp.StatusBadRequest) + return + } + if err := s.deps.Store.Resolve(r.Context(), id, time.Now().UTC()); err != nil { + slog.Warn("ui alerts: resolve", "err", err) + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: &u.ID, Actor: "user", + Action: "alert.resolve", + TargetKind: ptr("alert"), TargetID: &id, + TS: time.Now().UTC(), + }) + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Redirect", "/alerts?"+r.URL.RawQuery) + w.WriteHeader(stdhttp.StatusNoContent) + return + } + stdhttp.Redirect(w, r, "/alerts", stdhttp.StatusSeeOther) +} diff --git a/internal/server/http/ui_alerts_test.go b/internal/server/http/ui_alerts_test.go new file mode 100644 index 0000000..633773f --- /dev/null +++ b/internal/server/http/ui_alerts_test.go @@ -0,0 +1,41 @@ +package http + +import ( + "context" + "encoding/json" + stdhttp "net/http" + "testing" + "time" + + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +func TestAPIAlertsListsOpen(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServer(t) + hostID, _ := enrolHostForWS(t, srv, st, "host-alerts") + _, _, _ = st.RaiseOrTouch(context.Background(), hostID, + "backup_failed", "warning", "x", time.Now().UTC()) + cookie := loginAsAdmin(t, st) + + req, _ := stdhttp.NewRequest("GET", ts.URL+"/api/alerts?status=open", nil) + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer res.Body.Close() + if res.StatusCode != 200 { + t.Fatalf("status: %d", res.StatusCode) + } + var got []store.Alert + if err := json.NewDecoder(res.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 1 || got[0].Kind != "backup_failed" { + t.Fatalf("got %+v", got) + } + _ = ulid.Make() // import keep +}