04dde93acd
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'.
178 lines
4.8 KiB
Go
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)
|
|
}
|