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'.
This commit is contained in:
2026-05-04 21:00:44 +01:00
parent 809c4ed910
commit 04dde93acd
2 changed files with 68 additions and 2 deletions
+54
View File
@@ -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
+14 -2
View File
@@ -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{