Files
pulse/internal/alerts/active_lifecycle.go
T
rcourtman feb2eb2f1b refactor: split active alert lifecycle
Move active alert acknowledgement, manual clear, recovery clear, state preservation, resolved registration, and no-lock removal helpers into internal/alerts/active_lifecycle.go.

Record active_lifecycle.go in the alerts subsystem contract and add a canonical-alias manual-clear characterization test.

Proof: go test ./internal/alerts/... -run 'TestClearAlertMarksResolutionAndReturnsStatus|TestClearAlertByCanonicalAliasRemovesActiveState|TestAddRecentlyResolvedUsesCanonicalStorageKey|TestAcknowledgeAlertNotFound|TestUnacknowledgeAlertSuccess|TestUnacknowledgeAlertByCanonicalAlias' -count=1

Proof: go test ./internal/alerts/... -count=1

Proof: go test ./internal/api -run Alert -count=1
2026-05-06 14:44:04 +01:00

343 lines
9.0 KiB
Go

package alerts
import (
"fmt"
"strings"
"time"
"github.com/rs/zerolog/log"
)
// addRecentlyResolvedUnlocked records a resolved alert assuming the caller does not hold m.mu.
func (m *Manager) addRecentlyResolvedUnlocked(resolved *ResolvedAlert) {
m.resolvedMutex.Lock()
if resolved == nil || resolved.Alert == nil {
m.resolvedMutex.Unlock()
return
}
storageKey := activeAlertStorageKey(resolved.Alert, resolved.Alert.ID)
m.recentlyResolved[storageKey] = resolved
m.registerResolvedAliasUnlocked(storageKey, resolved)
m.resolvedMutex.Unlock()
}
// addRecentlyResolvedWithPrimaryLock records a resolved alert while preserving the caller's
// ownership of m.mu. Callers must hold m.mu before invoking this helper.
func (m *Manager) addRecentlyResolvedWithPrimaryLock(resolved *ResolvedAlert) {
m.mu.Unlock()
m.addRecentlyResolvedUnlocked(resolved)
m.mu.Lock()
}
// clearAlert removes an alert if it exists.
func (m *Manager) clearAlert(alertID string) {
m.mu.Lock()
alert, exists := m.getActiveAlertNoLock(alertID)
if exists {
m.removeActiveAlertNoLock(alertID)
}
m.mu.Unlock()
if !exists {
return
}
publicID := effectiveAlertID(alert, alertID)
resolvedAlert := &ResolvedAlert{
Alert: alert,
ResolvedTime: time.Now(),
}
m.addRecentlyResolvedUnlocked(resolvedAlert)
m.safeCallResolvedAlertCallback(alert, publicID, false)
log.Info().
Str("alertID", publicID).
Msg("Alert cleared")
}
// AcknowledgeAlert acknowledges an alert.
func (m *Manager) AcknowledgeAlert(alertID, user string) error {
m.mu.Lock()
key, exists := m.resolveActiveAlertKeyNoLock(alertID)
if !exists {
m.mu.Unlock()
return fmt.Errorf("%w: %s", ErrAlertNotFound, alertID)
}
alert, ok := m.getActiveAlertNoLock(key)
if !ok || alert == nil {
m.mu.Unlock()
return fmt.Errorf("%w: %s", ErrAlertNotFound, alertID)
}
alert.Acknowledged = true
now := time.Now()
alert.AckTime = &now
alert.AckUser = user
m.setActiveAlertNoLock(key, alert)
m.setAckRecordNoLock(alert, alertID, ackRecord{
acknowledged: true,
user: user,
time: now,
})
alertCopy := alert.Clone()
m.mu.Unlock()
log.Debug().
Str("alertID", alertID).
Str("user", user).
Time("ackTime", now).
Msg("Alert acknowledgment recorded")
m.safeCallAcknowledgedCallback(alertCopy, user)
return nil
}
// UnacknowledgeAlert removes the acknowledged status from an alert.
func (m *Manager) UnacknowledgeAlert(alertID string) error {
m.mu.Lock()
key, exists := m.resolveActiveAlertKeyNoLock(alertID)
if !exists {
m.mu.Unlock()
return fmt.Errorf("%w: %s", ErrAlertNotFound, alertID)
}
alert, ok := m.getActiveAlertNoLock(key)
if !ok || alert == nil {
m.mu.Unlock()
return fmt.Errorf("%w: %s", ErrAlertNotFound, alertID)
}
alert.Acknowledged = false
alert.AckTime = nil
alert.AckUser = ""
m.setActiveAlertNoLock(key, alert)
m.deleteAckRecordNoLock(alert, alertID)
alertCopy := alert.Clone()
m.mu.Unlock()
log.Info().
Str("alertID", alertID).
Msg("Alert unacknowledged")
m.safeCallUnacknowledgedCallback(alertCopy, "")
return nil
}
// preserveAlertState copies acknowledgement and escalation metadata from an existing alert
// into a freshly constructed alert before it replaces the existing entry in the map. This
// prevents UI state from regressing when alerts are rebuilt during polling.
func (m *Manager) preserveAlertState(alertID string, updated *Alert) {
if updated == nil {
return
}
backfillCanonicalIdentity(updated)
if updated.NodeDisplayName == "" && updated.Node != "" {
updated.NodeDisplayName = m.resolveNodeDisplayName(updated.Instance, updated.Node)
}
existing, exists := m.getActiveAlertNoLock(alertID)
if exists && existing != nil {
updated.StartTime = existing.StartTime
if existing.LastNotified != nil {
t := *existing.LastNotified
updated.LastNotified = &t
} else {
updated.LastNotified = nil
}
updated.Acknowledged = existing.Acknowledged
updated.AckUser = existing.AckUser
if existing.AckTime != nil {
t := *existing.AckTime
updated.AckTime = &t
} else {
updated.AckTime = nil
}
updated.LastEscalation = existing.LastEscalation
if len(existing.EscalationTimes) > 0 {
updated.EscalationTimes = append([]time.Time(nil), existing.EscalationTimes...)
} else {
updated.EscalationTimes = nil
}
log.Debug().
Str("alertID", alertID).
Time("originalStartTime", existing.StartTime).
Dur("currentDuration", time.Since(existing.StartTime)).
Msg("Preserving alert state including StartTime")
return
}
if record, ok := m.getAckRecordNoLock(updated, alertID); ok && record.acknowledged {
updated.Acknowledged = true
updated.AckUser = record.user
t := record.time
updated.AckTime = &t
}
}
func (m *Manager) removeActiveAlertNoLock(alertID string) {
publicID := alertID
var currentAlert *Alert
key, exists := m.resolveActiveAlertKeyNoLock(alertID)
if !exists {
key, exists = m.resolveActiveAlertKeyByCanonicalStateNoLock(alertID)
}
if alert, ok := m.getActiveAlertNoLock(alertID); exists && ok && alert != nil {
currentAlert = alert
backfillCanonicalIdentity(alert)
publicID = effectiveAlertID(alert, alertID)
m.historyManager.UpdateAlertLastSeenForAlert(alert, alert.LastSeen)
m.unregisterActiveAlertAliasNoLock(key, alert)
}
if exists {
delete(m.offlineRecoveryConfirmations, key)
delete(m.activeAlerts, key)
}
delete(m.offlineRecoveryConfirmations, alertID)
// Preserve acknowledgement state so quick alert rebuilds keep user intent.
if exists {
m.markAckInactiveNoLock(currentAlert, publicID, time.Now())
}
}
func (m *Manager) confirmOfflineRecoveryNoLock(alertID string, required int) (int, bool) {
alertID = strings.TrimSpace(alertID)
if alertID == "" {
return 0, false
}
if required <= 1 {
delete(m.offlineRecoveryConfirmations, alertID)
return required, true
}
m.offlineRecoveryConfirmations[alertID]++
confirmations := m.offlineRecoveryConfirmations[alertID]
if confirmations < required {
return confirmations, false
}
delete(m.offlineRecoveryConfirmations, alertID)
return confirmations, true
}
// clearResourceOfflineAlert removes an offline alert when a poll-driven resource
// stays healthy for enough consecutive polls to confirm recovery.
func (m *Manager) clearResourceOfflineAlert(resourceID, resourceName, host, resourceKind string, requiredRecoveryCount int) {
alertID := canonicalConnectivityStateID(resourceID)
m.mu.Lock()
defer m.mu.Unlock()
if count, exists := m.offlineConfirmations[resourceID]; exists && count > 0 {
log.Debug().
Str(strings.ToLower(resourceKind), resourceName).
Int("previousCount", count).
Msg(resourceKind + " is online, resetting offline confirmation count")
delete(m.offlineConfirmations, resourceID)
}
alert, exists := m.getActiveAlertNoLock(alertID)
if !exists {
delete(m.offlineRecoveryConfirmations, alertID)
return
}
recoveryCount, confirmed := m.confirmOfflineRecoveryNoLock(alertID, requiredRecoveryCount)
if !confirmed {
log.Debug().
Str(strings.ToLower(resourceKind), resourceName).
Int("confirmations", recoveryCount).
Int("required", requiredRecoveryCount).
Msg(resourceKind + " appears back online, waiting for recovery confirmation")
return
}
m.removeActiveAlertNoLock(alertID)
resolvedAlert := &ResolvedAlert{
Alert: alert,
ResolvedTime: time.Now(),
}
m.addRecentlyResolvedWithPrimaryLock(resolvedAlert)
m.safeCallResolvedAlertCallback(alert, alertID, true)
log.Info().
Str(strings.ToLower(resourceKind), resourceName).
Str("host", host).
Dur("downtime", time.Since(alert.StartTime)).
Msg(resourceKind + " instance is back online")
}
// ClearAlert removes an alert from active alerts while keeping it in history.
func (m *Manager) ClearAlert(alertID string) bool {
m.mu.Lock()
alert, exists := m.getActiveAlertNoLock(alertID)
if !exists || alert == nil {
m.mu.Unlock()
return false
}
trackingKey := canonicalTrackingKeyForAlert(alert)
m.clearAlertNoLock(alertID)
delete(m.recentAlerts, alertID)
delete(m.pendingAlerts, alertID)
delete(m.suppressedUntil, alertID)
delete(m.alertRateLimit, alertID)
if trackingKey != "" && trackingKey != alertID {
delete(m.recentAlerts, trackingKey)
delete(m.pendingAlerts, trackingKey)
delete(m.suppressedUntil, trackingKey)
delete(m.alertRateLimit, trackingKey)
}
m.mu.Unlock()
m.saveActiveAlertsAsync("manual-clear")
return true
}
// clearAlertNoLock clears an alert without locking. Caller must hold m.mu.
func (m *Manager) clearAlertNoLock(alertID string) {
alert, exists := m.getActiveAlertNoLock(alertID)
if !exists {
return
}
publicID := effectiveAlertID(alert, alertID)
if recordAlertResolved != nil {
recordAlertResolved(alert)
}
m.removeActiveAlertNoLock(alertID)
resolvedAlert := &ResolvedAlert{
Alert: alert,
ResolvedTime: time.Now(),
}
m.addRecentlyResolvedWithPrimaryLock(resolvedAlert)
m.safeCallResolvedAlertCallback(alert, publicID, true)
log.Info().
Str("alertID", publicID).
Msg("Alert cleared")
}
func (m *Manager) clearActiveAlertIfPresentNoLock(alertID string) bool {
if _, exists := m.getActiveAlertNoLock(alertID); !exists {
return false
}
m.clearAlertNoLock(alertID)
return true
}