http: /alerts list + ack/resolve handlers + /api/alerts JSON
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user