mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
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.
254 lines
8 KiB
Go
254 lines
8 KiB
Go
package alerts
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
alertspecs "github.com/rcourtman/pulse-go-rewrite/internal/alerts/specs"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// ConnectionState mirrors api.ConnectionState as a free-standing string so the
|
|
// alerts package can react to derived connection state without importing the
|
|
// api package (which would create a cycle through api → monitoring → alerts).
|
|
// Values stay in lockstep with api.ConnectionState by convention; the
|
|
// connections_aggregator translator is the single producer.
|
|
type ConnectionState string
|
|
|
|
const (
|
|
ConnectionStateActive ConnectionState = "active"
|
|
ConnectionStateStale ConnectionState = "stale"
|
|
ConnectionStateUnreachable ConnectionState = "unreachable"
|
|
ConnectionStateUnauthorized ConnectionState = "unauthorized"
|
|
ConnectionStatePaused ConnectionState = "paused"
|
|
ConnectionStatePending ConnectionState = "pending"
|
|
)
|
|
|
|
// ConnectionType narrows the set of connections that participate in the
|
|
// connection-degraded alert. Agent, availability, docker, and kubernetes
|
|
// connections have their own dedicated alert lifecycles, so the aggregator
|
|
// must omit them when handing snapshots to CheckConnection.
|
|
type ConnectionType string
|
|
|
|
const (
|
|
ConnectionTypePVE ConnectionType = "pve"
|
|
ConnectionTypePBS ConnectionType = "pbs"
|
|
ConnectionTypePMG ConnectionType = "pmg"
|
|
ConnectionTypeVMware ConnectionType = "vmware"
|
|
ConnectionTypeTrueNAS ConnectionType = "truenas"
|
|
)
|
|
|
|
// ConnectionErrorSnapshot mirrors the api.ConnectionError shape that the
|
|
// aggregator computes per target. Only the fields used in alert metadata are
|
|
// carried.
|
|
type ConnectionErrorSnapshot struct {
|
|
At time.Time
|
|
Message string
|
|
Category string
|
|
}
|
|
|
|
// ConnectionSnapshot is the alerts-side view of one unified connection row.
|
|
// The api package's aggregator translates each platform api.Connection into
|
|
// this shape before invoking CheckConnection so alerts does not depend on
|
|
// api.
|
|
type ConnectionSnapshot struct {
|
|
ID string
|
|
Name string
|
|
Type ConnectionType
|
|
State ConnectionState
|
|
StateReason string
|
|
Enabled bool
|
|
LastSeen *time.Time
|
|
LastError *ConnectionErrorSnapshot
|
|
}
|
|
|
|
// connectionDegradedAlertType is the alert.Type emitted for connection-degraded
|
|
// alerts. Surfacing this as a constant keeps notification routing and history
|
|
// indexing in lockstep with the producer.
|
|
const connectionDegradedAlertType = "connection-degraded"
|
|
|
|
// connectionDegradedStateKey is the DiscreteState spec stateKey for the
|
|
// connection-degraded canonical alert. The full state-ID derived by
|
|
// canonicalDiscreteStateStateID becomes "<connectionID>-<connectionDegradedStateKey>".
|
|
const connectionDegradedStateKey = "connection-degraded"
|
|
|
|
func isPlatformConnectionType(t ConnectionType) bool {
|
|
switch t {
|
|
case ConnectionTypePVE, ConnectionTypePBS, ConnectionTypePMG, ConnectionTypeVMware, ConnectionTypeTrueNAS:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// CheckConnection raises or clears the connection-degraded alert for one
|
|
// platform connection. Severity scales with observed state: stale → warning,
|
|
// unreachable / unauthorized → critical. State=active runs through the
|
|
// recovery-confirmation gate before resolving. Paused, disabled, and
|
|
// non-platform connections are skipped.
|
|
func (m *Manager) CheckConnection(snap ConnectionSnapshot) {
|
|
if !isPlatformConnectionType(snap.Type) {
|
|
return
|
|
}
|
|
if strings.TrimSpace(snap.ID) == "" {
|
|
return
|
|
}
|
|
if !snap.Enabled || snap.State == ConnectionStatePaused {
|
|
m.clearConnectionDegradedAlert(snap)
|
|
return
|
|
}
|
|
|
|
switch snap.State {
|
|
case ConnectionStateActive:
|
|
m.clearConnectionDegradedAlert(snap)
|
|
return
|
|
case ConnectionStateStale, ConnectionStateUnreachable, ConnectionStateUnauthorized:
|
|
// fall through and fire
|
|
default:
|
|
// pending/unknown — no alert, but reset the consecutive count so a
|
|
// later degraded run starts from zero instead of accumulating across
|
|
// a transient pending blip.
|
|
m.mu.Lock()
|
|
delete(m.connectionDegradedCount, snap.ID)
|
|
m.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
severity := AlertLevelWarning
|
|
if snap.State == ConnectionStateUnreachable || snap.State == ConnectionStateUnauthorized {
|
|
severity = AlertLevelCritical
|
|
}
|
|
|
|
// Another degraded observation invalidates any in-flight recovery
|
|
// confirmations — recovery must build back up from zero.
|
|
alertID := canonicalDiscreteStateStateID(snap.ID, connectionDegradedStateKey)
|
|
m.mu.Lock()
|
|
delete(m.offlineRecoveryConfirmations, alertID)
|
|
m.mu.Unlock()
|
|
|
|
spec, err := buildCanonicalDiscreteStateSpec(
|
|
snap.ID,
|
|
snap.Name,
|
|
unifiedresources.ResourceType("connection"),
|
|
severity,
|
|
3,
|
|
false,
|
|
connectionDegradedStateKey,
|
|
[]string{
|
|
string(ConnectionStateStale),
|
|
string(ConnectionStateUnreachable),
|
|
string(ConnectionStateUnauthorized),
|
|
},
|
|
)
|
|
if err != nil {
|
|
log.Warn().
|
|
Err(err).
|
|
Str("connection", snap.Name).
|
|
Str("connectionID", snap.ID).
|
|
Msg("Skipping invalid canonical connection-degraded spec")
|
|
return
|
|
}
|
|
|
|
reason := strings.TrimSpace(snap.StateReason)
|
|
message := fmt.Sprintf("Connection '%s' is %s", snap.Name, snap.State)
|
|
if reason != "" {
|
|
message = fmt.Sprintf("%s: %s", message, reason)
|
|
}
|
|
|
|
metadata := map[string]interface{}{
|
|
"resourceType": "connection",
|
|
"connectionType": string(snap.Type),
|
|
"state": string(snap.State),
|
|
}
|
|
if reason != "" {
|
|
metadata["stateReason"] = reason
|
|
}
|
|
if snap.LastSeen != nil {
|
|
metadata["lastSeen"] = snap.LastSeen.UTC()
|
|
}
|
|
if snap.LastError != nil && strings.TrimSpace(snap.LastError.Message) != "" {
|
|
metadata["lastError"] = snap.LastError.Message
|
|
if !snap.LastError.At.IsZero() {
|
|
metadata["lastErrorAt"] = snap.LastError.At.UTC()
|
|
}
|
|
if snap.LastError.Category != "" {
|
|
metadata["lastErrorCategory"] = snap.LastError.Category
|
|
}
|
|
}
|
|
|
|
_, _ = m.evaluateCanonicalLifecycleAlert(canonicalLifecycleAlertParams{
|
|
Spec: spec,
|
|
Evidence: alertspecs.AlertEvidence{
|
|
ObservedAt: time.Now(),
|
|
DiscreteState: &alertspecs.DiscreteStateEvidence{
|
|
StateKey: connectionDegradedStateKey,
|
|
Observed: string(snap.State),
|
|
},
|
|
},
|
|
Tracking: m.connectionDegradedCount,
|
|
TrackingKey: snap.ID,
|
|
AlertID: alertID,
|
|
AlertType: connectionDegradedAlertType,
|
|
ResourceID: snap.ID,
|
|
ResourceName: snap.Name,
|
|
Instance: snap.Name,
|
|
Message: message,
|
|
Metadata: metadata,
|
|
AddToRecent: true,
|
|
AddToHistory: true,
|
|
RateLimit: true,
|
|
DispatchAsync: false,
|
|
})
|
|
}
|
|
|
|
// clearConnectionDegradedAlert resolves an active connection-degraded alert
|
|
// after enough consecutive healthy observations to confirm recovery. Mirrors
|
|
// clearNodeOfflineAlert so a single flap back to active does not silently
|
|
// resolve a real outage. Callers hold no manager locks.
|
|
func (m *Manager) clearConnectionDegradedAlert(snap ConnectionSnapshot) {
|
|
alertID := canonicalDiscreteStateStateID(snap.ID, connectionDegradedStateKey)
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
if m.connectionDegradedCount[snap.ID] > 0 {
|
|
log.Debug().
|
|
Str("connection", snap.Name).
|
|
Int("previousCount", m.connectionDegradedCount[snap.ID]).
|
|
Msg("Connection healthy, resetting degraded count")
|
|
delete(m.connectionDegradedCount, snap.ID)
|
|
}
|
|
|
|
alert, exists := m.getActiveAlertNoLock(alertID)
|
|
if !exists {
|
|
delete(m.offlineRecoveryConfirmations, alertID)
|
|
return
|
|
}
|
|
|
|
recoveryCount, confirmed := m.confirmOfflineRecoveryNoLock(alertID, offlineRecoveryConfirmationsDefault)
|
|
if !confirmed {
|
|
log.Debug().
|
|
Str("connection", snap.Name).
|
|
Int("confirmations", recoveryCount).
|
|
Int("required", offlineRecoveryConfirmationsDefault).
|
|
Msg("Connection appears healthy, 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("connection", snap.Name).
|
|
Str("connectionID", snap.ID).
|
|
Dur("downtime", time.Since(alert.StartTime)).
|
|
Msg("Connection back to active")
|
|
}
|