mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 18:32:29 +00:00
5fd05efa83
A Proxmox host wedged on a ZFS deadlock yesterday took the cluster API poll with it (context deadline exceeded). The unified connections aggregator flipped the Connection from active to stale to unreachable, and the Settings / Infrastructure page rendered the right badges, but no top-nav alert ever fired because nothing was actively notifying off that derived state. Patrol's deterministic triage flagged it every minute, but its LLM investigation stage has been broken since 2026-02-26 so flags never escalated into user-visible findings. Result: a 3 hour outage I only noticed because I happened to open Settings. This wires an active notification off the same connection state the Settings badges already use: - internal/alerts/connection.go: new CheckConnection + clearConnectionDegradedAlert that fire connection-degraded after three consecutive stale or unreachable observations. Severity scales: stale warning, unreachable / unauthorized critical. Clear runs through the same recovery-confirmation gate as clearNodeOfflineAlert so a single flap back to active doesn't silently resolve a real outage. Paused, disabled, and non-platform connections are no-ops. - internal/api/connections_alerts.go: snapshot translator that turns api.Connection into the narrow alerts.ConnectionSnapshot view. Keeping the snapshot type inside the alerts package preserves the existing api -> monitoring import direction; the monitor would have cycled if it called back into api directly. - internal/monitoring: new SetConnectionsSnapshotLister hook + a per-tick checkConnectionAlerts call in the main poll loop, alongside the existing evaluate*Agents passes. - internal/api/router.go: register the lister closure on r.monitor so the alerts loop sees the same Connection rows the HTTP handler does. - internal/alerts/specs/types.go: add "connection" to the migration bridge list of accepted ResourceTypes, alongside node / docker-host / proxmox-disk / etc. The connection concept doesn't have a canonical unified resource type yet; this matches the existing pattern for alert-keyed resources that aren't first-class canonical. Test coverage in internal/alerts/connection_test.go covers active never fires, three stale observations escalate from pending to warning, unreachable escalates warning to critical, unauthorized fires critical cold, paused / disabled / agent never fire, recovery confirmation gate, and a stale flap during recovery resets the gate. TestResourceAlertSpecValidateAllowsConnectionMigrationBridgeType mirrors the existing migration-bridge proof tests for the new type.
173 lines
8.0 KiB
Go
173 lines
8.0 KiB
Go
package alerts
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// Manager handles alert monitoring and state
|
|
//
|
|
// Lock Ordering Documentation:
|
|
// The Manager uses two mutexes to prevent deadlocks:
|
|
// 1. m.mu (primary lock) - protects most manager state
|
|
// 2. m.resolvedMutex - protects only recentlyResolved map
|
|
//
|
|
// Lock Ordering Rules:
|
|
// - NEVER hold m.mu when acquiring resolvedMutex
|
|
// - ALWAYS release m.mu before acquiring resolvedMutex
|
|
// - resolvedMutex can be held independently without m.mu
|
|
// - When both locks are needed, acquire m.mu first, then release it before acquiring resolvedMutex
|
|
//
|
|
// This ordering prevents deadlock scenarios where different goroutines acquire locks in different orders.
|
|
type Manager struct {
|
|
mu sync.RWMutex
|
|
saveMu sync.Mutex
|
|
callbacks callbackBus
|
|
alertsDir string
|
|
config AlertConfig
|
|
activeAlerts map[string]*Alert
|
|
activeAlertAlias map[string]string
|
|
historyManager *HistoryManager
|
|
escalationStop chan struct{}
|
|
alertRateLimit map[string][]time.Time // Track alert times for rate limiting
|
|
// New fields for deduplication and suppression
|
|
recentAlerts map[string]*Alert // Track recent alerts for deduplication
|
|
suppressedUntil map[string]time.Time // Track suppression windows
|
|
// Recently resolved alerts (kept for 5 minutes)
|
|
recentlyResolved map[string]*ResolvedAlert
|
|
resolvedAlias map[string]string
|
|
resolvedMutex sync.RWMutex // Secondary lock - see Lock Ordering Documentation above
|
|
// Time threshold tracking
|
|
pendingAlerts map[string]time.Time // Track when thresholds were first exceeded
|
|
// Offline confirmation tracking
|
|
nodeOfflineCount map[string]int // Track consecutive offline counts for nodes (legacy)
|
|
connectionDegradedCount map[string]int // Track consecutive degraded counts for platform connections (pve/pbs/pmg/vmware/truenas)
|
|
offlineConfirmations map[string]int // Track consecutive offline counts for all resources
|
|
offlineRecoveryConfirmations map[string]int // Track consecutive healthy confirmations before clearing poll-driven offline alerts
|
|
dockerOfflineCount map[string]int // Track consecutive offline counts for Docker hosts
|
|
dockerStateConfirm map[string]int // Track consecutive state confirmations for Docker containers
|
|
dockerRestartTracking map[string]*dockerRestartRecord // Track restart counts and times for restart loop detection
|
|
dockerLastExitCode map[string]int // Track last exit code for OOM detection
|
|
dockerUpdateFirstSeen map[string]time.Time // Track when image updates were first detected for alert delay
|
|
// Stable identity tracking prevents update-delay resets when host IDs churn.
|
|
dockerUpdateFirstSeenByIdentity map[string]time.Time
|
|
// PMG quarantine growth tracking
|
|
pmgQuarantineHistory map[string][]pmgQuarantineSnapshot // Track quarantine snapshots for growth detection
|
|
// PMG anomaly detection tracking
|
|
pmgAnomalyTrackers map[string]*pmgAnomalyTracker // Track mail metrics for anomaly detection per PMG instance
|
|
// Persistent acknowledgement state so quick alert rebuilds keep user acknowledgements
|
|
ackState map[string]ackRecord
|
|
// Canonical acknowledgement state is keyed by resource_id + spec_id so later
|
|
// alert-ID migration can preserve user state across storage-key changes.
|
|
ackStateByCanonical map[string]ackRecord
|
|
// Flapping detection tracking
|
|
flappingHistory map[string][]time.Time // Track state change times for flapping detection
|
|
flappingActive map[string]bool // Track which alerts are currently in flapping state
|
|
// Cleanup control
|
|
cleanupStop chan struct{} // Signal to stop cleanup goroutine
|
|
// Host agent deduplication: track hostnames of active host agents
|
|
// When a host agent is running on a Proxmox node, we prefer the host agent
|
|
// alerts and suppress the node alerts to avoid duplicate monitoring.
|
|
hostAgentHostnames map[string]struct{} // Normalized hostnames (lowercase)
|
|
// Node display name caches. Proxmox nodes can share the same raw node name
|
|
// across multiple configured instances, so keep instance-scoped entries in
|
|
// addition to the legacy raw-name cache used by instance-less resources.
|
|
nodeDisplayNames map[string]string
|
|
instanceNodeDisplayNames map[string]string
|
|
// License checking for Pro-only alert features
|
|
hasProFeature func(feature string) bool
|
|
|
|
// Cached timezone for quiet hours
|
|
quietHoursLoc *time.Location
|
|
now func() time.Time
|
|
stopOnce sync.Once
|
|
}
|
|
|
|
type ackRecord struct {
|
|
acknowledged bool
|
|
user string
|
|
time time.Time // When the alert was acknowledged
|
|
inactiveAt time.Time // When the alert was removed (zero if still active)
|
|
}
|
|
|
|
// NewManager creates a new alert manager using the global data directory.
|
|
// For multi-tenant deployments, use NewManagerWithDataDir instead.
|
|
func NewManager() *Manager {
|
|
return NewManagerWithDataDir(utils.GetDataDir())
|
|
}
|
|
|
|
// NewManagerWithDataDir creates a new alert manager with a custom data directory.
|
|
// This enables tenant-scoped alert persistence in multi-tenant deployments.
|
|
func NewManagerWithDataDir(dataDir string) *Manager {
|
|
if strings.TrimSpace(dataDir) == "" {
|
|
dataDir = utils.GetDataDir()
|
|
}
|
|
|
|
alertsDir := filepath.Join(dataDir, "alerts")
|
|
m := &Manager{
|
|
alertsDir: alertsDir,
|
|
activeAlerts: make(map[string]*Alert),
|
|
activeAlertAlias: make(map[string]string),
|
|
historyManager: NewHistoryManager(alertsDir),
|
|
callbacks: newCallbackBus(),
|
|
escalationStop: make(chan struct{}),
|
|
alertRateLimit: make(map[string][]time.Time),
|
|
recentAlerts: make(map[string]*Alert),
|
|
suppressedUntil: make(map[string]time.Time),
|
|
recentlyResolved: make(map[string]*ResolvedAlert),
|
|
resolvedAlias: make(map[string]string),
|
|
pendingAlerts: make(map[string]time.Time),
|
|
nodeOfflineCount: make(map[string]int),
|
|
connectionDegradedCount: make(map[string]int),
|
|
offlineConfirmations: make(map[string]int),
|
|
offlineRecoveryConfirmations: make(map[string]int),
|
|
dockerOfflineCount: make(map[string]int),
|
|
dockerStateConfirm: make(map[string]int),
|
|
dockerRestartTracking: make(map[string]*dockerRestartRecord),
|
|
dockerLastExitCode: make(map[string]int),
|
|
dockerUpdateFirstSeen: make(map[string]time.Time),
|
|
dockerUpdateFirstSeenByIdentity: make(map[string]time.Time),
|
|
pmgQuarantineHistory: make(map[string][]pmgQuarantineSnapshot),
|
|
pmgAnomalyTrackers: make(map[string]*pmgAnomalyTracker),
|
|
ackState: make(map[string]ackRecord),
|
|
ackStateByCanonical: make(map[string]ackRecord),
|
|
flappingHistory: make(map[string][]time.Time),
|
|
flappingActive: make(map[string]bool),
|
|
cleanupStop: make(chan struct{}),
|
|
hostAgentHostnames: make(map[string]struct{}),
|
|
nodeDisplayNames: make(map[string]string),
|
|
instanceNodeDisplayNames: make(map[string]string),
|
|
now: time.Now,
|
|
config: defaultAlertConfig(),
|
|
}
|
|
|
|
// Load saved active alerts
|
|
if err := m.LoadActiveAlerts(); err != nil {
|
|
log.Error().Err(err).Msg("failed to load active alerts")
|
|
}
|
|
|
|
// Start escalation checker
|
|
go m.escalationChecker()
|
|
|
|
// Start periodic save of active alerts
|
|
go m.periodicSaveAlerts()
|
|
|
|
// Start periodic cleanup of stale tracking map entries
|
|
go m.trackingMapCleanup()
|
|
|
|
return m
|
|
}
|
|
|
|
// SetLicenseChecker sets the function used to check Pro license features.
|
|
// This enables gating Pro-only alert features like update alerts.
|
|
func (m *Manager) SetLicenseChecker(checker func(feature string) bool) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.hasProFeature = checker
|
|
}
|