Files
restic-manager/internal/server/http/ui_alerts.go
T
steve 9860b412f7 feat(alerts): live-refresh the table every 15s while the tab is visible
The alerts list is the one screen where staleness is genuinely
harmful — an operator can be looking at an Open tab that's already
been resolved by another admin or auto-resolved by the engine, and
take action on a row that no longer exists.

Add an htmx poll on just the table panel:

  hx-get        same URL with current querystring (filters preserved)
  hx-trigger    every 15s, only when document is visible (no idle CPU)
  hx-select     #alerts-table — pull this element out of the response
  hx-swap       outerHTML

Polling lives on the table div, not the page root, so the filter
strip and header don't flash on each tick. Header gains a small
'live ●' label so the polling is discoverable.

RefreshURL is r.URL.RequestURI() on the server side — keeps any
status/severity/host_id/q params intact across refreshes.

Other screens (dashboard, hosts, jobs) deliberately stay manual-
refresh per the project's anti-flicker stance.
2026-05-04 23:30:19 +01:00

184 lines
4.9 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
RefreshURL string // self-URL for the live-refresh poll
}
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{},
RefreshURL: r.URL.RequestURI(),
}
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)
}