From 04dde93acd680b27536f21965c86aee9d87f96d1 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 4 May 2026 21:00:44 +0100 Subject: [PATCH] fix: dispatch alert.acknowledged + alert.resolved on UI ack/resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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'. --- internal/alert/rules.go | 54 +++++++++++++++++++++++++++++++ internal/server/http/ui_alerts.go | 16 +++++++-- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/internal/alert/rules.go b/internal/alert/rules.go index 3207d63..e55cfe7 100644 --- a/internal/alert/rules.go +++ b/internal/alert/rules.go @@ -68,6 +68,60 @@ func (e *Engine) raiseAndNotify(ctx context.Context, hostID, kind, severity, mes }) } +// Acknowledge updates the alert row and fans out alert.acknowledged to +// every enabled channel. Best-effort: store errors are logged but the +// dispatch still fires only when the store update succeeds. +func (e *Engine) Acknowledge(ctx context.Context, alertID, userID string, when time.Time) error { + if err := e.store.Acknowledge(ctx, alertID, userID, when); err != nil { + return err + } + a, lerr := e.store.GetAlert(ctx, alertID) + if lerr != nil || a == nil { + // Acknowledge already succeeded; dispatch is best-effort. + return nil //nolint:nilerr + } + p := alertPayload(ctx, e.store, notification.EventAcknowledged, a) + go e.hub.Dispatch(context.WithoutCancel(ctx), p) + return nil +} + +// Resolve marks the alert resolved and fans out alert.resolved. +func (e *Engine) Resolve(ctx context.Context, alertID string, when time.Time) error { + a, _ := e.store.GetAlert(ctx, alertID) + if err := e.store.Resolve(ctx, alertID, when); err != nil { + return err + } + if a == nil { + return nil + } + p := alertPayload(ctx, e.store, notification.EventResolved, a) + go e.hub.Dispatch(context.WithoutCancel(ctx), p) + return nil +} + +// alertPayload builds a Payload from a stored Alert, looking up the host +// name when HostID is set. +func alertPayload(ctx context.Context, st *store.Store, ev notification.Event, a *store.Alert) notification.Payload { + hostID, hostName := "", "" + if a.HostID != nil { + hostID = *a.HostID + hostName = hostID + if h, err := st.GetHost(ctx, hostID); err == nil && h != nil { + hostName = h.Name + } + } + return notification.Payload{ + Event: ev, + AlertID: a.ID, + Severity: a.Severity, + Kind: a.Kind, + HostID: hostID, + HostName: hostName, + Message: a.Message, + RaisedAt: a.CreatedAt, + } +} + // resolveAndNotify clears every open (or acknowledged) alert for // (host_id, kind) via store.AutoResolve, then fires alert.resolved // for each row that was actually open. Best-effort — errors are diff --git a/internal/server/http/ui_alerts.go b/internal/server/http/ui_alerts.go index 1a736b3..06c82fb 100644 --- a/internal/server/http/ui_alerts.go +++ b/internal/server/http/ui_alerts.go @@ -119,7 +119,13 @@ func (s *Server) handleUIAlertAcknowledge(w stdhttp.ResponseWriter, r *stdhttp.R stdhttp.Error(w, "missing id", stdhttp.StatusBadRequest) return } - if err := s.deps.Store.Acknowledge(r.Context(), id, u.ID, time.Now().UTC()); err != nil { + 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{ @@ -147,7 +153,13 @@ func (s *Server) handleUIAlertResolve(w stdhttp.ResponseWriter, r *stdhttp.Reque stdhttp.Error(w, "missing id", stdhttp.StatusBadRequest) return } - if err := s.deps.Store.Resolve(r.Context(), id, time.Now().UTC()); err != nil { + 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{