Pulse/internal/monitoring/monitor_alert_sync.go
rcourtman 61fce38a71 alerts(history): carry alert metadata to the frontend so resourceType resolves
The alert engine stamps metadata.resourceType on every alert, but the
websocket state path converts alerts.Alert to models.Alert, which had no
Metadata field, so every active alert reached the frontend stripped. The
history Type badge then fell back to unified-store lookups that miss
nodes (alert.resourceId is the platform-native node ID while unified
resources mint canonical ids, and alert.resourceName is the raw node
name while unified resources prefer the display name), rendering
Unknown. In mock mode the generated history rows had the same gap.

models.Alert gained the Metadata field in f62f35e24 (it rode along with
the memory-cache commit); this completes the transport:

- copy Metadata in activeAlertsSnapshot (websocket active alerts),
  GetRecentlyResolved (resolved alerts to state), and the mock
  UpdateAlertSnapshots conversion; sources are deep clones already
- deep-copy Metadata in models cloneAlert to keep the snapshot
  clone contract honest
- stamp resourceType in the mock history generator using the real
  engine vocabulary (node, vm, system-container)
- recognize system-container in the history Type badge map; that is
  what the v6 engine stamps for LXC guests

Verified live in mock mode: history previously resolved 277 of 780
rows to Unknown (all node alerts); now 718/718 rows and 19/19 active
alerts carry resourceType and zero badges render Unknown.
2026-06-11 19:53:45 +01:00

181 lines
4.8 KiB
Go

package monitoring
import (
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/logging"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// syncAlertsToState copies the latest alert manager data into the shared state snapshot.
// This keeps WebSocket broadcasts aligned with in-memory acknowledgement updates.
func (m *Monitor) syncAlertsToState() {
if m.pruneStaleDockerAlerts() {
if logging.IsLevelEnabled(zerolog.DebugLevel) {
log.Debug().Msg("pruned stale docker alerts during sync")
}
}
modelAlerts := m.activeAlertsSnapshot()
for _, alert := range modelAlerts {
if alert.Acknowledged && logging.IsLevelEnabled(zerolog.DebugLevel) {
log.Debug().Str("alertID", alert.ID).Interface("ackTime", alert.AckTime).Msg("syncing acknowledged alert")
}
}
m.state.UpdateActiveAlerts(modelAlerts)
recentlyResolved := m.alertManager.GetRecentlyResolved()
if len(recentlyResolved) > 0 && logging.IsLevelEnabled(zerolog.DebugLevel) {
log.Debug().Int("count", len(recentlyResolved)).Msg("syncing recently resolved alerts")
}
m.state.UpdateRecentlyResolved(recentlyResolved)
}
// SyncAlertState is the exported wrapper used by APIs that mutate alerts outside the poll loop.
func (m *Monitor) SyncAlertState() {
m.syncAlertsToState()
}
func (m *Monitor) activeAlertsSnapshot() []models.Alert {
if m == nil {
return nil
}
if m.alertManager == nil {
if m.state == nil {
return nil
}
return m.state.GetSnapshot().ActiveAlerts
}
activeAlerts := m.alertManager.GetActiveAlerts()
modelAlerts := make([]models.Alert, 0, len(activeAlerts))
for _, alert := range activeAlerts {
modelAlerts = append(modelAlerts, models.Alert{
ID: alert.ID,
Type: alert.Type,
Level: string(alert.Level),
ResourceID: alert.ResourceID,
ResourceName: alert.ResourceName,
Node: alert.Node,
NodeDisplayName: alert.NodeDisplayName,
Instance: alert.Instance,
Message: alert.Message,
Value: alert.Value,
Threshold: alert.Threshold,
StartTime: alert.StartTime,
Acknowledged: alert.Acknowledged,
AckTime: alert.AckTime,
AckUser: alert.AckUser,
// GetActiveAlerts returns deep clones, so the map is already private.
Metadata: alert.Metadata,
})
}
return modelAlerts
}
func (m *Monitor) recentlyResolvedAlertsSnapshot() []models.ResolvedAlert {
if m == nil {
return nil
}
if m.alertManager == nil {
if m.state == nil {
return nil
}
return m.state.GetSnapshot().RecentlyResolved
}
return m.alertManager.GetRecentlyResolved()
}
func (m *Monitor) syncUnifiedResourceAlertsToState(resources []unifiedresources.Resource) {
if m == nil || m.alertManager == nil {
return
}
m.alertManager.CheckUnifiedResourceMetrics(resources)
m.alertManager.SyncUnifiedResourceIncidents(resources)
m.syncAlertsToState()
}
// pruneStaleDockerAlerts removes docker alerts that reference hosts no longer present in state.
func (m *Monitor) pruneStaleDockerAlerts() bool {
if m.alertManager == nil {
return false
}
readState := m.GetUnifiedReadStateOrSnapshot()
if readState == nil {
return false
}
hosts := readState.DockerHosts()
knownHosts := make(map[string]struct{}, len(hosts)*2)
for _, host := range hosts {
hostID := strings.TrimSpace(host.ID())
if hostID != "" {
knownHosts[hostID] = struct{}{}
}
if sourceID := strings.TrimSpace(host.HostSourceID()); sourceID != "" {
knownHosts[sourceID] = struct{}{}
}
}
if len(knownHosts) == 0 {
// Still allow stale entries to be cleared if no hosts remain.
}
active := m.alertManager.GetActiveAlerts()
processed := make(map[string]struct{})
cleared := false
for _, alert := range active {
var hostID string
switch {
case alert.Type == "docker-host-offline":
hostID = strings.TrimPrefix(strings.TrimSpace(alert.ResourceID), "docker:")
case strings.HasPrefix(alert.ResourceID, "docker:"):
resource := strings.TrimPrefix(alert.ResourceID, "docker:")
if idx := strings.Index(resource, "/"); idx >= 0 {
hostID = resource[:idx]
} else {
hostID = resource
}
default:
continue
}
hostID = strings.TrimSpace(hostID)
if hostID == "" {
continue
}
if _, known := knownHosts[hostID]; known {
continue
}
if _, alreadyCleared := processed[hostID]; alreadyCleared {
continue
}
host := models.DockerHost{
ID: hostID,
DisplayName: alert.ResourceName,
Hostname: alert.Node,
}
if host.DisplayName == "" {
host.DisplayName = hostID
}
if host.Hostname == "" {
host.Hostname = hostID
}
m.alertManager.HandleDockerHostRemoved(host)
processed[hostID] = struct{}{}
cleared = true
}
return cleared
}