Files
restic-manager/internal/server/http/ui_alerts.go
T
steve 04dde93acd fix: dispatch alert.acknowledged + alert.resolved on UI ack/resolve
Spotted during the live Playwright sweep: clicking Acknowledge or
Resolve updated the alert row but never fanned out a notification.
The handlers went straight to Store.Acknowledge/Resolve, bypassing
the hub.

Add Engine.Acknowledge and Engine.Resolve that wrap the store call
and dispatch the matching event to every enabled channel. The UI
handlers prefer the engine path when wired, and fall back to the
direct store call so unit tests that construct a Server without an
engine still work.

Use context.WithoutCancel for the goroutine dispatch — the request
context is cancelled the instant the handler returns 204, so the
naive 'go e.hub.Dispatch(ctx, ...)' was racing the response and
losing the channel-list query with 'context canceled'.
2026-05-04 21:00:44 +01:00

178 lines
4.8 KiB
Go

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(r, 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
}
var err error
if s.deps.AlertEngine != nil {
err = s.deps.AlertEngine.Acknowledge(r.Context(), id, u.ID, time.Now().UTC())
} else {
err = s.deps.Store.Acknowledge(r.Context(), id, u.ID, time.Now().UTC())
}
if 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
}
var err error
if s.deps.AlertEngine != nil {
err = s.deps.AlertEngine.Resolve(r.Context(), id, time.Now().UTC())
} else {
err = s.deps.Store.Resolve(r.Context(), id, time.Now().UTC())
}
if 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)
}