Files
pulse/internal/alerts/host.go
T

1307 lines
41 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/models"
"github.com/rcourtman/pulse-go-rewrite/internal/storagehealth"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rs/zerolog/log"
)
const HostOfflineAlertType = "host-offline"
func hostResourceID(hostID string) string {
trimmed := strings.TrimSpace(hostID)
if trimmed == "" {
return "agent:unknown"
}
return fmt.Sprintf("agent:%s", trimmed)
}
func stripHostResourcePrefix(resourceID string) string {
trimmed := strings.TrimSpace(resourceID)
trimmed = strings.TrimPrefix(trimmed, "agent:")
return strings.TrimSpace(trimmed)
}
func hostDisplayName(host models.Host) string {
base := "Agent"
if name := strings.TrimSpace(host.DisplayName); name != "" {
base = name
} else if name := strings.TrimSpace(host.Hostname); name != "" {
base = name
} else if host.ID != "" {
base = host.ID
}
// When a host agent is linked to a Proxmox node/VM/container, qualify its
// name so its alerts are not confused with the linked resource's alerts.
if strings.TrimSpace(host.LinkedNodeID) != "" ||
strings.TrimSpace(host.LinkedVMID) != "" ||
strings.TrimSpace(host.LinkedContainerID) != "" {
if strings.EqualFold(base, "Agent") {
return "Host Agent"
}
if !strings.Contains(strings.ToLower(base), "host agent") {
return fmt.Sprintf("%s (Host Agent)", base)
}
}
return base
}
func hostInstanceName(host models.Host) string {
if platform := strings.TrimSpace(host.Platform); platform != "" {
return platform
}
if osName := strings.TrimSpace(host.OSName); osName != "" {
return osName
}
return "Agent"
}
// resolveHostThresholdsNoLock resolves the effective thresholds for a host agent.
// Explicit host-agent overrides win. Otherwise, linked node/guest overrides are
// inherited so the host agent follows the logical resource it augments.
// Callers must hold m.mu when reading config through this helper.
func (m *Manager) resolveHostThresholdsNoLock(hostID, linkedNodeID, linkedVMID, linkedContainerID string) ThresholdConfig {
base := m.defaultThresholdsForResourceType("agent")
if override, exists := m.hostThresholdOverrideNoLock(hostID, linkedNodeID, linkedVMID, linkedContainerID); exists {
return m.applyThresholdOverride(base, override)
}
return base
}
// hostThresholdOverrideNoLock returns the first override in the host-agent
// resolution chain (host, linked node, linked guest), mirroring
// resolveHostThresholdsNoLock's precedence. Callers must hold m.mu.
func (m *Manager) hostThresholdOverrideNoLock(hostID, linkedNodeID, linkedVMID, linkedContainerID string) (ThresholdConfig, bool) {
if hostID = strings.TrimSpace(hostID); hostID != "" {
if override, exists := m.config.Overrides[hostID]; exists {
return override, true
}
}
if linkedNodeID = strings.TrimSpace(linkedNodeID); linkedNodeID != "" {
if override, exists := m.config.Overrides[linkedNodeID]; exists {
return override, true
}
}
if linkedVMID = strings.TrimSpace(linkedVMID); linkedVMID != "" {
if override, exists := lookupGuestOverride(m.config.Overrides, nil, linkedVMID); exists {
return override, true
}
}
if linkedContainerID = strings.TrimSpace(linkedContainerID); linkedContainerID != "" {
if override, exists := lookupGuestOverride(m.config.Overrides, nil, linkedContainerID); exists {
return override, true
}
}
return ThresholdConfig{}, false
}
// resolveHostAlertThresholdsNoLock resolves thresholds for persisted host-agent alerts.
// Alert metadata carries the link context needed to inherit node/guest overrides.
// Callers must hold m.mu when reading config through this helper.
func (m *Manager) resolveHostAlertThresholdsNoLock(alert *Alert, resourceID string) ThresholdConfig {
hostID := stripHostResourcePrefix(resourceID)
if idx := strings.Index(hostID, "/"); idx >= 0 {
hostID = hostID[:idx]
}
linkedNodeID := ""
linkedVMID := ""
linkedContainerID := ""
if alert != nil {
if metadataHostID := metadataStringValue(alert.Metadata, "hostId"); metadataHostID != "" {
hostID = metadataHostID
}
linkedNodeID = metadataStringValue(alert.Metadata, "linkedNodeId")
linkedVMID = metadataStringValue(alert.Metadata, "linkedVmId")
linkedContainerID = metadataStringValue(alert.Metadata, "linkedContainerId")
}
return m.resolveHostThresholdsNoLock(hostID, linkedNodeID, linkedVMID, linkedContainerID)
}
func sanitizeHostComponent(value string) string {
value = strings.TrimSpace(strings.ToLower(value))
if value == "" {
return "unknown"
}
var builder strings.Builder
lastHyphen := false
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
lastHyphen = false
case r >= '0' && r <= '9':
builder.WriteRune(r)
lastHyphen = false
default:
if !lastHyphen {
builder.WriteRune('-')
lastHyphen = true
}
}
}
sanitized := strings.Trim(builder.String(), "-")
if sanitized == "" {
return "unknown"
}
return sanitized
}
// sanitizeRAIDDevice sanitizes RAID device names for use in resource IDs.
func sanitizeRAIDDevice(device string) string {
// Remove /dev/ prefix if present
device = strings.TrimPrefix(device, "/dev/")
return sanitizeHostComponent(device)
}
func hostDiskResourceIDWithPrefix(host models.Host, disk models.Disk, resourcePrefix string) (string, string) {
label := strings.TrimSpace(disk.Mountpoint)
if label == "" {
label = strings.TrimSpace(disk.Device)
}
if label == "" {
label = "disk"
}
resourceID := fmt.Sprintf("%s/disk:%s", resourcePrefix, sanitizeHostComponent(label))
resourceName := fmt.Sprintf("%s (%s)", hostDisplayName(host), label)
return resourceID, resourceName
}
func hostDiskResourceID(host models.Host, disk models.Disk) (string, string) {
return hostDiskResourceIDWithPrefix(host, disk, hostResourceID(host.ID))
}
func hostSMARTDiskResourceID(host models.Host, disk models.HostDiskSMART) (string, string) {
label := strings.TrimSpace(strings.TrimPrefix(disk.Device, "/dev/"))
if label == "" {
label = strings.TrimSpace(disk.Serial)
}
if label == "" {
label = strings.TrimSpace(disk.WWN)
}
if label == "" {
label = strings.TrimSpace(disk.Model)
}
if label == "" {
label = "smart-disk"
}
resourceID := fmt.Sprintf("%s/disk:%s", hostResourceID(host.ID), sanitizeHostComponent(label))
resourceName := fmt.Sprintf("%s (%s)", hostDisplayName(host), label)
return resourceID, resourceName
}
// CheckHost evaluates host agent telemetry for alerts.
func (m *Manager) CheckHost(host models.Host) {
if host.ID == "" {
return
}
// Register this host agent hostname for deduplication with Proxmox nodes.
// This prevents duplicate alerts when both a Node and Host agent monitor the same machine.
if host.Hostname != "" {
m.RegisterHostAgentHostname(host.Hostname)
}
// Cache display name so host alerts show the user-configured name.
m.UpdateNodeDisplayName("", host.Hostname, host.DisplayName)
// Fresh telemetry marks the host as online and clears offline tracking.
m.HandleHostOnline(host)
m.mu.RLock()
alertsEnabled := m.config.Enabled
disableAllAgents := m.config.DisableAllAgents
thresholds := m.resolveHostThresholdsNoLock(host.ID, host.LinkedNodeID, host.LinkedVMID, host.LinkedContainerID)
// An explicit disk temperature override (host or inherited linked-resource)
// beats the per-type defaults in DiskTempByType.
diskTempOverridden := false
if override, exists := m.hostThresholdOverrideNoLock(host.ID, host.LinkedNodeID, host.LinkedVMID, host.LinkedContainerID); exists && override.DiskTemperature != nil {
diskTempOverridden = true
}
m.mu.RUnlock()
if !alertsEnabled {
return
}
if disableAllAgents {
// Clear any existing host alerts when all host alerts are disabled
m.clearHostMetricAlerts(host.ID)
m.clearHostDiskAlerts(host.ID)
m.clearHostRAIDAlerts(host.ID)
m.clearHostUnraidAlerts(host.ID)
m.clearHostCustomSensorAlerts(host.ID)
return
}
if thresholds.Disabled {
m.clearHostMetricAlerts(host.ID)
m.clearHostDiskAlerts(host.ID)
m.clearHostRAIDAlerts(host.ID)
m.clearHostUnraidAlerts(host.ID)
m.clearHostCustomSensorAlerts(host.ID)
return
}
resourceID := hostResourceID(host.ID)
resourceName := hostDisplayName(host)
nodeName := strings.TrimSpace(host.Hostname)
instanceName := hostInstanceName(host)
baseMetadata := map[string]interface{}{
"resourceType": "agent",
"hostId": host.ID,
"hostname": host.Hostname,
"displayName": host.DisplayName,
"platform": host.Platform,
"osName": host.OSName,
"osVersion": host.OSVersion,
"agentVersion": host.AgentVersion,
"architecture": host.Architecture,
}
if linkedNodeID := strings.TrimSpace(host.LinkedNodeID); linkedNodeID != "" {
baseMetadata["linkedNodeId"] = linkedNodeID
}
if linkedVMID := strings.TrimSpace(host.LinkedVMID); linkedVMID != "" {
baseMetadata["linkedVmId"] = linkedVMID
}
if linkedContainerID := strings.TrimSpace(host.LinkedContainerID); linkedContainerID != "" {
baseMetadata["linkedContainerId"] = linkedContainerID
}
if len(host.Tags) > 0 {
baseMetadata["tags"] = append([]string(nil), host.Tags...)
}
m.syncHostCustomSensorAlerts(host, nodeName, instanceName, baseMetadata)
if thresholds.CPU != nil {
cpuMetadata := cloneMetadata(baseMetadata)
cpuMetadata["metric"] = "cpu"
cpuMetadata["cpuUsagePercent"] = host.CPUUsage
if host.CPUCount > 0 {
cpuMetadata["cpuCount"] = host.CPUCount
}
spec, err := buildCanonicalMetricSpec(resourceID, resourceName, unifiedresources.ResourceTypeAgent, "cpu", thresholds.CPU)
if err != nil {
log.Warn().
Err(err).
Str("resourceID", resourceID).
Str("host", resourceName).
Msg("Skipping invalid canonical host CPU metric spec")
} else {
m.checkMetricWithCanonicalSpec(spec, resourceName, nodeName, instanceName, "agent", host.CPUUsage, thresholds.CPU, &metricOptions{Metadata: cpuMetadata})
}
} else {
m.clearHostMetricAlerts(host.ID, "cpu")
}
if thresholds.Memory != nil && host.Memory.HasKnownUsage() {
memMetadata := cloneMetadata(baseMetadata)
memMetadata["metric"] = "memory"
memMetadata["memoryUsagePercent"] = host.Memory.Usage
if host.Memory.Total > 0 {
memMetadata["memoryTotalBytes"] = host.Memory.Total
memMetadata["memoryUsedBytes"] = host.Memory.Used
memMetadata["memoryFreeBytes"] = host.Memory.Free
}
spec, err := buildCanonicalMetricSpec(resourceID, resourceName, unifiedresources.ResourceTypeAgent, "memory", thresholds.Memory)
if err != nil {
log.Warn().
Err(err).
Str("resourceID", resourceID).
Str("host", resourceName).
Msg("Skipping invalid canonical host memory metric spec")
} else {
m.checkMetricWithCanonicalSpec(spec, resourceName, nodeName, instanceName, "agent", host.Memory.Usage, thresholds.Memory, &metricOptions{Metadata: memMetadata})
}
} else if thresholds.Memory == nil {
m.clearHostMetricAlerts(host.ID, "memory")
}
if thresholds.DiskTemperature != nil && thresholds.DiskTemperature.Trigger > 0 {
if len(host.Sensors.SMART) > 0 {
for _, disk := range host.Sensors.SMART {
if disk.Temperature > 0 && !disk.Standby {
effectiveTempThreshold := thresholds.DiskTemperature
if diskType := strings.ToLower(strings.TrimSpace(disk.Type)); diskType != "" && !diskTempOverridden {
m.mu.RLock()
if th, ok := m.config.DiskTempByType[diskType]; ok {
t := th
effectiveTempThreshold = &t
}
m.mu.RUnlock()
}
// Use specific resource ID for the disk: hostID/disk-temp:device
tempResourceID := fmt.Sprintf("%s/disk_temp:%s", hostResourceID(host.ID), sanitizeHostComponent(disk.Device))
tempResourceName := fmt.Sprintf("%s (%s Temp)", hostDisplayName(host), disk.Device)
diskTempMetadata := cloneMetadata(baseMetadata)
diskTempMetadata["metric"] = "diskTemperature"
diskTempMetadata["device"] = disk.Device
diskTempMetadata["temperature"] = disk.Temperature
diskTempMetadata["model"] = disk.Model
diskTempMetadata["diskType"] = disk.Type
spec, err := buildCanonicalMetricSpec(tempResourceID, tempResourceName, unifiedresources.ResourceType("agent-disk"), "diskTemperature", effectiveTempThreshold)
if err != nil {
log.Warn().
Err(err).
Str("resourceID", tempResourceID).
Str("host", resourceName).
Str("device", disk.Device).
Msg("Skipping invalid canonical host disk temperature metric spec")
continue
}
m.checkMetricWithCanonicalSpec(spec, tempResourceName, nodeName, disk.Device, "agent", float64(disk.Temperature), effectiveTempThreshold, &metricOptions{Metadata: diskTempMetadata})
}
}
}
} else {
// We can't easily clear all disk temp alerts without tracking them,
// but checkMetric logic handles auto-resolution if value drops.
// If feature is disabled, ideally we should clear existing alerts.
// For now simple implementation.
}
seenDisks := make(map[string]struct{}, len(host.Disks))
if len(host.Sensors.SMART) > 0 {
for _, disk := range host.Sensors.SMART {
diskResourceID, diskName := hostSMARTDiskResourceID(host, disk)
if host.LinkedNodeID == "" {
seenDisks[diskResourceID] = struct{}{}
m.syncHostSMARTDiskRiskAlerts(host, disk, diskResourceID, diskName, nodeName, instanceName, baseMetadata)
continue
}
m.syncHostSMARTDiskAlert(host, disk, diskResourceID, diskName, nodeName, instanceName, baseMetadata, "disk-health", nil)
m.syncHostSMARTDiskAlert(host, disk, diskResourceID, diskName, nodeName, instanceName, baseMetadata, "disk-wearout", nil)
}
}
for _, disk := range host.Disks {
diskResourceID, diskName := hostDiskResourceID(host, disk)
seenDisks[diskResourceID] = struct{}{}
// Check for disk-specific override
m.mu.RLock()
diskOverride, hasDiskOverride := m.config.Overrides[diskResourceID]
m.mu.RUnlock()
// Determine the effective disk threshold
var effectiveDiskThreshold *HysteresisThreshold
if hasDiskOverride {
// If disk is disabled via override, skip alerting
if diskOverride.Disabled {
m.clearAlert(canonicalMetricStateID(diskResourceID, "disk"))
continue
}
// Use disk-specific threshold if set
if diskOverride.Disk != nil {
effectiveDiskThreshold = ensureHysteresisThreshold(diskOverride.Disk)
}
}
// Per-type override: consult DiskFillByType if hardware type is inferable
// from the device path and no disk-specific override applied above.
if effectiveDiskThreshold == nil && thresholds.Disk != nil && thresholds.Disk.Trigger > 0 {
if hwType := inferDiskHardwareType(disk.Device); hwType != "" {
m.mu.RLock()
if th, ok := m.config.DiskFillByType[hwType]; ok {
t := th
effectiveDiskThreshold = &t
}
m.mu.RUnlock()
}
}
// Fall back to host-level threshold
if effectiveDiskThreshold == nil {
effectiveDiskThreshold = thresholds.Disk
}
// Skip if no threshold configured (nil)
// We DO NOT skip if Trigger <= 0 because we need to call checkMetric to clear any existing alerts.
if effectiveDiskThreshold == nil {
continue
}
diskMetadata := cloneMetadata(baseMetadata)
diskMetadata["metric"] = "disk"
diskMetadata["mountpoint"] = disk.Mountpoint
diskMetadata["device"] = disk.Device
diskMetadata["diskType"] = disk.Type
diskMetadata["diskUsagePercent"] = disk.Usage
if disk.Total > 0 {
diskMetadata["diskTotalBytes"] = disk.Total
diskMetadata["diskUsedBytes"] = disk.Used
diskMetadata["diskFreeBytes"] = disk.Free
}
spec, err := buildCanonicalMetricSpec(diskResourceID, diskName, unifiedresources.ResourceType("agent-disk"), "disk", effectiveDiskThreshold)
if err != nil {
log.Warn().
Err(err).
Str("resourceID", diskResourceID).
Str("host", resourceName).
Str("mountpoint", disk.Mountpoint).
Msg("Skipping invalid canonical host disk metric spec")
continue
}
m.checkMetricWithCanonicalSpec(spec, diskName, nodeName, instanceName, "agent-disk", disk.Usage, effectiveDiskThreshold, &metricOptions{Metadata: diskMetadata})
}
// Clear all disk alerts if host-level disk alerting is completely disabled and no disk-specific overrides
if thresholds.Disk == nil || thresholds.Disk.Trigger <= 0 {
// Only clear alerts for disks that don't have their own overrides
m.mu.RLock()
var disksToClear []string
for _, disk := range host.Disks {
diskResourceID, _ := hostDiskResourceID(host, disk)
_, hasDiskOverride := m.config.Overrides[diskResourceID]
if !hasDiskOverride {
disksToClear = append(disksToClear, canonicalMetricStateID(diskResourceID, "disk"))
}
}
m.mu.RUnlock()
for _, alertID := range disksToClear {
m.clearAlert(alertID)
}
}
m.cleanupHostDiskAlerts(host, seenDisks)
if host.Unraid != nil {
m.syncHostUnraidStorageAlert(host, nodeName, instanceName, resourceName, baseMetadata)
} else {
m.clearHostUnraidAlerts(host.ID)
}
// Clear vendor-managed system-array alerts even when host state has already
// been normalized to exclude them.
m.clearVendorManagedHostRAIDAlerts(host)
// Check RAID arrays for degraded or failed state
if len(host.RAID) > 0 {
for _, array := range host.RAID {
// Skip vendor-managed system arrays that are not customer-facing storage pools.
if storagehealth.IsVendorManagedSystemRAIDArray(host, array) {
// Still clear any existing alerts for these devices
raidSpecResourceID := fmt.Sprintf("%s/raid:%s", hostResourceID(host.ID), sanitizeRAIDDevice(array.Device))
m.clearAlert(buildCanonicalStateID(raidSpecResourceID, raidSpecResourceID+"-health"))
continue
}
raidResourceID := fmt.Sprintf("host-%s-raid-%s", host.ID, sanitizeRAIDDevice(array.Device))
raidName := fmt.Sprintf("%s - %s (%s)", resourceName, array.Device, array.Level)
raidSpecResourceID := fmt.Sprintf("%s/raid:%s", hostResourceID(host.ID), sanitizeRAIDDevice(array.Device))
raidMetadata := cloneMetadata(baseMetadata)
raidMetadata["metric"] = "raid"
raidMetadata["raidDevice"] = array.Device
raidMetadata["raidLevel"] = array.Level
raidMetadata["raidState"] = array.State
raidMetadata["raidTotalDevices"] = array.TotalDevices
raidMetadata["raidActiveDevices"] = array.ActiveDevices
raidMetadata["raidFailedDevices"] = array.FailedDevices
raidMetadata["raidSpareDevices"] = array.SpareDevices
if array.UUID != "" {
raidMetadata["raidUUID"] = array.UUID
}
if array.RebuildPercent > 0 {
raidMetadata["raidRebuildPercent"] = array.RebuildPercent
}
if array.Operation != "" {
raidMetadata["raidOperation"] = array.Operation
}
alertID := fmt.Sprintf("host-%s-raid-%s", host.ID, sanitizeRAIDDevice(array.Device))
assessment := storagehealth.AssessHostRAIDArray(array)
result, _ := m.syncCanonicalHealthAssessmentAlert(canonicalHealthAssessmentAlertParams{
SpecID: raidSpecResourceID + "-health",
Signal: "host-raid",
Codes: raidAssessmentCodes,
Reasons: assessment.Reasons,
AlertID: alertID,
AlertType: "raid",
SpecResourceID: raidSpecResourceID,
ResourceID: raidResourceID,
ResourceName: raidName,
ResourceType: unifiedresources.ResourceTypeAgent,
Node: nodeName,
Instance: instanceName,
Metadata: raidMetadata,
MessageBuilder: func(result alertspecs.EvaluationResult) (string, float64, float64) {
message := strings.Join(storageHealthReasonSummaries(assessment.Reasons), "; ")
switch result.State.Severity {
case alertspecs.AlertSeverityCritical:
return message, float64(array.FailedDevices), 0
case alertspecs.AlertSeverityWarning:
return message, array.RebuildPercent, 100
default:
return message, 0, 0
}
},
})
if result.Transition != nil && result.Transition.Kind == alertspecs.EvaluationTransitionActivated {
switch result.State.Severity {
case alertspecs.AlertSeverityCritical:
log.Error().
Str("host", resourceName).
Str("hostID", host.ID).
Str("raidDevice", array.Device).
Str("raidLevel", array.Level).
Int("failedDevices", array.FailedDevices).
Msg("CRITICAL: RAID array degraded")
case alertspecs.AlertSeverityWarning:
log.Warn().
Str("host", resourceName).
Str("hostID", host.ID).
Str("raidDevice", array.Device).
Str("raidLevel", array.Level).
Float64("rebuildPercent", array.RebuildPercent).
Msg("WARNING: RAID array rebuilding")
}
}
}
}
}
// HandleHostOnline clears offline tracking and alerts for a host agent.
func (m *Manager) HandleHostOnline(host models.Host) {
if host.ID == "" {
return
}
resourceKey := hostResourceID(host.ID)
alertID := canonicalConnectivityStateID(resourceKey)
m.mu.Lock()
delete(m.offlineConfirmations, resourceKey)
exists := m.hasActiveAlertNoLock(alertID)
m.mu.Unlock()
if exists {
m.clearAlert(alertID)
}
}
// HandleHostRemoved clears alerts and tracking when a host agent is deleted.
func (m *Manager) HandleHostRemoved(host models.Host) {
if host.ID == "" {
return
}
// Unregister the host agent hostname since it's being removed.
if host.Hostname != "" {
m.UnregisterHostAgentHostname(host.Hostname)
}
m.HandleHostOnline(host)
m.clearHostMetricAlerts(host.ID)
m.clearHostDiskAlerts(host.ID)
m.clearHostRAIDAlerts(host.ID)
m.clearHostUnraidAlerts(host.ID)
m.clearHostCustomSensorAlerts(host.ID)
}
// HandleHostTelemetryExpired re-evaluates transient storage-operation evidence
// after the host reporting lease ends. Connectivity remains a separate signal
// evaluated by HandleHostOffline; non-transient metric and disk alerts retain
// their existing confirmation policy until that connectivity transition.
func (m *Manager) HandleHostTelemetryExpired(host models.Host) {
if host.ID == "" {
return
}
if host.Unraid != nil {
unraid := *host.Unraid
unraid.SyncAction = ""
unraid.SyncProgress = 0
host.Unraid = &unraid
}
host.RAID = append([]models.HostRAIDArray(nil), host.RAID...)
for i := range host.RAID {
host.RAID[i].Operation = ""
host.RAID[i].RebuildPercent = 0
host.RAID[i].RebuildSpeed = ""
}
m.mu.RLock()
alertsEnabled := m.config.Enabled
disableAllAgents := m.config.DisableAllAgents
thresholds := m.resolveHostThresholdsNoLock(host.ID, host.LinkedNodeID, host.LinkedVMID, host.LinkedContainerID)
m.mu.RUnlock()
if !alertsEnabled || disableAllAgents || thresholds.Disabled {
m.clearHostRAIDAlerts(host.ID)
m.clearHostUnraidAlerts(host.ID)
m.clearHostCustomSensorAlerts(host.ID)
return
}
m.clearHostCustomSensorAlerts(host.ID)
if host.Unraid == nil {
m.clearHostUnraidAlerts(host.ID)
} else {
baseMetadata := map[string]interface{}{
"resourceType": "agent",
"hostId": host.ID,
"hostname": host.Hostname,
"displayName": host.DisplayName,
"platform": host.Platform,
"osName": host.OSName,
"osVersion": host.OSVersion,
"agentVersion": host.AgentVersion,
"architecture": host.Architecture,
}
if linkedNodeID := strings.TrimSpace(host.LinkedNodeID); linkedNodeID != "" {
baseMetadata["linkedNodeId"] = linkedNodeID
}
if linkedVMID := strings.TrimSpace(host.LinkedVMID); linkedVMID != "" {
baseMetadata["linkedVmId"] = linkedVMID
}
if linkedContainerID := strings.TrimSpace(host.LinkedContainerID); linkedContainerID != "" {
baseMetadata["linkedContainerId"] = linkedContainerID
}
if len(host.Tags) > 0 {
baseMetadata["tags"] = append([]string(nil), host.Tags...)
}
m.syncHostUnraidStorageAlert(
host,
strings.TrimSpace(host.Hostname),
hostInstanceName(host),
hostDisplayName(host),
baseMetadata,
)
}
if len(host.RAID) == 0 {
m.clearHostRAIDAlerts(host.ID)
return
}
for _, array := range host.RAID {
assessment := storagehealth.AssessHostRAIDArray(array)
if assessment.Level != storagehealth.RiskHealthy {
continue
}
resourceID := fmt.Sprintf("%s/raid:%s", hostResourceID(host.ID), sanitizeRAIDDevice(array.Device))
m.clearAlert(buildCanonicalStateID(resourceID, resourceID+"-health"))
}
}
// HandleHostOffline raises an alert when a host agent stops reporting.
func (m *Manager) HandleHostOffline(host models.Host) {
if host.ID == "" {
return
}
// Unregister the host agent hostname since it's no longer actively monitoring.
// This allows node alerts to resume if a Proxmox node with the same hostname exists.
if host.Hostname != "" {
m.UnregisterHostAgentHostname(host.Hostname)
}
m.HandleHostTelemetryExpired(host)
m.mu.RLock()
if !m.config.Enabled {
m.mu.RUnlock()
return
}
disableHostsOffline := m.config.DisableAllAgentsOffline
thresholds := m.resolveHostThresholdsNoLock(host.ID, host.LinkedNodeID, host.LinkedVMID, host.LinkedContainerID)
m.mu.RUnlock()
resourceKey := hostResourceID(host.ID)
alertID := canonicalConnectivityStateID(resourceKey)
resourceName := hostDisplayName(host)
nodeName := strings.TrimSpace(host.Hostname)
instanceName := hostInstanceName(host)
if disableHostsOffline {
m.mu.Lock()
delete(m.offlineConfirmations, resourceKey)
m.mu.Unlock()
m.clearAlert(alertID)
return
}
if thresholds.Disabled || thresholds.DisableConnectivity {
m.clearAlert(alertID)
m.mu.Lock()
delete(m.offlineConfirmations, resourceKey)
m.mu.Unlock()
return
}
spec, err := buildCanonicalConnectivitySpec(resourceKey, resourceName, unifiedresources.ResourceTypeAgent, AlertLevelCritical, 3, false)
if err != nil {
log.Warn().
Err(err).
Str("host", resourceName).
Str("hostID", host.ID).
Msg("Skipping invalid canonical host connectivity spec")
return
}
result, ok := m.evaluateCanonicalLifecycleAlert(canonicalLifecycleAlertParams{
Spec: spec,
Evidence: alertspecs.AlertEvidence{
ObservedAt: time.Now(),
Connectivity: &alertspecs.ConnectivityEvidence{
Signal: "status",
Connected: false,
},
},
Tracking: m.offlineConfirmations,
TrackingKey: resourceKey,
AlertID: alertID,
AlertType: HostOfflineAlertType,
ResourceID: resourceKey,
ResourceName: resourceName,
Node: nodeName,
Instance: instanceName,
Message: fmt.Sprintf("Host '%s' is offline", resourceName),
Metadata: map[string]interface{}{
"resourceType": "agent",
"hostId": host.ID,
"hostname": host.Hostname,
"displayName": host.DisplayName,
"platform": host.Platform,
"osName": host.OSName,
"osVersion": host.OSVersion,
"linkedNodeId": strings.TrimSpace(host.LinkedNodeID),
"linkedVmId": strings.TrimSpace(host.LinkedVMID),
"linkedContainerId": strings.TrimSpace(host.LinkedContainerID),
},
AddToRecent: true,
AddToHistory: true,
RateLimit: true,
DispatchAsync: false,
})
if !ok {
return
}
if result.State.State == alertspecs.AlertStatePending {
log.Debug().
Str("host", resourceName).
Str("hostID", host.ID).
Int("confirmations", result.State.ConsecutiveMatches).
Int("required", 3).
Msg("Host agent appears offline, awaiting confirmation")
return
}
if result.Transition == nil || result.Transition.Kind != alertspecs.EvaluationTransitionActivated {
return
}
// Host is confirmed offline. Clear all host-scoped metrics and storage-health alerts
// so the connectivity alert becomes the only active signal for this agent.
m.mu.Lock()
for _, mt := range []string{"cpu", "memory"} {
m.clearAlertNoLock(canonicalMetricStateID(resourceKey, mt))
}
diskResourcePrefixes := []string{
fmt.Sprintf("%s/disk:", resourceKey),
}
raidAlertPrefix := fmt.Sprintf("host-%s-raid-", host.ID)
var alertsToClear []string
for activeAlertID, a := range m.activeAlerts {
if a == nil {
continue
}
matchesDiskPrefix := false
for _, diskResourcePrefix := range diskResourcePrefixes {
if strings.HasPrefix(a.ResourceID, diskResourcePrefix) {
matchesDiskPrefix = true
break
}
}
if matchesDiskPrefix || strings.HasPrefix(activeAlertID, raidAlertPrefix) {
alertsToClear = append(alertsToClear, activeAlertID)
}
}
for _, staleAlertID := range alertsToClear {
m.clearAlertNoLock(staleAlertID)
}
m.mu.Unlock()
m.clearHostRAIDAlerts(host.ID)
m.clearHostUnraidAlerts(host.ID)
log.Error().
Str("host", resourceName).
Str("hostID", host.ID).
Str("hostname", host.Hostname).
Msg("CRITICAL: Host agent is offline")
}
func (m *Manager) clearHostMetricAlerts(hostID string, metrics ...string) {
if hostID == "" {
return
}
resourceIDs := []string{
hostResourceID(hostID),
}
if len(metrics) == 0 {
metrics = []string{"cpu", "memory"}
}
for _, resourceID := range resourceIDs {
for _, metric := range metrics {
m.clearAlert(canonicalMetricStateID(resourceID, metric))
}
}
}
func (m *Manager) clearHostDiskAlerts(hostID string) {
if hostID == "" {
return
}
prefixes := []string{
fmt.Sprintf("%s/disk:", hostResourceID(hostID)),
}
m.mu.Lock()
defer m.mu.Unlock()
for storageKey, alert := range m.activeAlerts {
alertID := effectiveAlertID(alert, storageKey)
if alert == nil {
continue
}
matches := false
for _, prefix := range prefixes {
if strings.HasPrefix(alert.ResourceID, prefix) {
matches = true
break
}
}
if !matches {
continue
}
m.clearAlertNoLock(alertID)
}
}
var customSensorAssessmentCodes = []string{
"custom_sensor_warning",
"custom_sensor_critical",
"custom_sensor_error",
}
func (m *Manager) syncHostCustomSensorAlerts(host models.Host, nodeName, instanceName string, baseMetadata map[string]interface{}) {
seen := make(map[string]struct{}, len(host.Sensors.Custom))
for _, metric := range host.Sensors.Custom {
metricID := sanitizeHostComponent(strings.TrimSpace(metric.ID))
if metricID == "" {
continue
}
resourceID := fmt.Sprintf("%s/custom:%s", hostResourceID(host.ID), metricID)
seen[resourceID] = struct{}{}
reasons := make([]storagehealth.Reason, 0, 1)
valueText := "unavailable"
if metric.Value != nil {
valueText = fmt.Sprintf("%g", *metric.Value)
if unit := strings.TrimSpace(metric.Unit); unit != "" {
valueText += " " + unit
}
}
switch strings.ToLower(strings.TrimSpace(metric.Status)) {
case "critical":
reasons = append(reasons, storagehealth.Reason{
Code: "custom_sensor_critical",
Severity: storagehealth.RiskCritical,
Summary: fmt.Sprintf("%s is critical at %s", metric.Name, valueText),
})
case "warning":
reasons = append(reasons, storagehealth.Reason{
Code: "custom_sensor_warning",
Severity: storagehealth.RiskWarning,
Summary: fmt.Sprintf("%s is warning at %s", metric.Name, valueText),
})
case "error":
if metric.AlertOnError {
message := strings.TrimSpace(metric.Error)
if message == "" {
message = "collector execution failed"
}
reasons = append(reasons, storagehealth.Reason{
Code: "custom_sensor_error",
Severity: storagehealth.RiskWarning,
Summary: fmt.Sprintf("%s custom sensor error: %s", metric.Name, message),
})
}
}
metadata := cloneMetadata(baseMetadata)
metadata["metric"] = "customSensor"
metadata["customSensorId"] = metric.ID
metadata["customSensorName"] = metric.Name
metadata["customSensorGroup"] = metric.Group
metadata["customSensorSubgroup"] = metric.Subgroup
metadata["customSensorKind"] = metric.Kind
metadata["customSensorUnit"] = metric.Unit
metadata["customSensorStatus"] = metric.Status
metadata["customSensorStale"] = metric.Stale
if metric.Value != nil {
metadata["customSensorValue"] = *metric.Value
}
if metric.Error != "" {
metadata["customSensorError"] = metric.Error
}
if metric.EventAt != nil {
metadata["customSensorEventAt"] = metric.EventAt.UTC().Format(time.RFC3339)
}
resourceName := fmt.Sprintf("%s - %s", hostDisplayName(host), metric.Name)
_, _ = m.syncCanonicalHealthAssessmentAlert(canonicalHealthAssessmentAlertParams{
SpecID: resourceID + "-health",
Signal: "custom-sensor",
Codes: customSensorAssessmentCodes,
Reasons: reasons,
AlertID: fmt.Sprintf("host-%s-custom-%s", host.ID, metricID),
AlertType: "custom-sensor",
SpecResourceID: resourceID,
ResourceID: resourceID,
ResourceName: resourceName,
ResourceType: unifiedresources.ResourceTypeAgent,
Node: nodeName,
Instance: instanceName,
Metadata: metadata,
MessageBuilder: func(result alertspecs.EvaluationResult) (string, float64, float64) {
message := strings.Join(storageHealthReasonSummaries(reasons), "; ")
value := 0.0
if metric.Value != nil {
value = *metric.Value
}
return message, value, 0
},
})
}
m.cleanupHostCustomSensorAlerts(host.ID, seen)
}
func (m *Manager) clearHostCustomSensorAlerts(hostID string) {
m.cleanupHostCustomSensorAlerts(hostID, nil)
}
func (m *Manager) cleanupHostCustomSensorAlerts(hostID string, seen map[string]struct{}) {
if strings.TrimSpace(hostID) == "" {
return
}
prefix := hostResourceID(hostID) + "/custom:"
m.mu.Lock()
defer m.mu.Unlock()
for storageKey, alert := range m.activeAlerts {
if alert == nil || !strings.HasPrefix(alert.ResourceID, prefix) {
continue
}
if seen != nil {
if _, exists := seen[alert.ResourceID]; exists {
continue
}
}
m.clearAlertNoLock(storageKey)
}
}
func (m *Manager) clearGuestMetricAlerts(guestID string, metrics ...string) int {
if guestID == "" {
return 0
}
allowedMetrics := make(map[string]struct{}, len(metrics))
for _, metric := range metrics {
metric = strings.TrimSpace(metric)
if metric == "" {
continue
}
allowedMetrics[metric] = struct{}{}
}
perDiskPrefix := fmt.Sprintf("%s-disk-", guestID)
m.mu.Lock()
defer m.mu.Unlock()
cleared := 0
for storageKey, alert := range m.activeAlerts {
if alert == nil || !isMetricThresholdAlertType(alert.Type) {
continue
}
if alert.ResourceID != guestID && !strings.HasPrefix(alert.ResourceID, perDiskPrefix) &&
!guestAlertBelongsToGuest(alert.ResourceID, guestID) {
continue
}
if len(allowedMetrics) > 0 {
if _, ok := allowedMetrics[alert.Type]; !ok {
continue
}
}
m.clearAlertNoLock(storageKey)
cleared++
}
return cleared
}
func (m *Manager) cleanupGuestDiskAlerts(guestID string, seen map[string]struct{}) int {
if guestID == "" {
return 0
}
prefix := fmt.Sprintf("%s-disk-", guestID)
m.mu.Lock()
defer m.mu.Unlock()
cleared := 0
for storageKey, alert := range m.activeAlerts {
if alert == nil {
continue
}
if !strings.HasPrefix(alert.ResourceID, prefix) && !guestDiskAlertBelongsToGuest(alert.ResourceID, guestID) {
continue
}
if seen != nil {
if _, exists := seen[alert.ResourceID]; exists {
continue
}
}
m.clearAlertNoLock(storageKey)
cleared++
}
return cleared
}
func (m *Manager) clearVendorManagedHostRAIDAlerts(host models.Host) {
if host.ID == "" {
return
}
for _, device := range storagehealth.VendorManagedSystemRAIDDevices(host) {
raidSpecResourceID := fmt.Sprintf("%s/raid:%s", hostResourceID(host.ID), sanitizeRAIDDevice(device))
m.clearAlert(buildCanonicalStateID(raidSpecResourceID, raidSpecResourceID+"-health"))
}
}
func (m *Manager) cleanupHostDiskAlerts(host models.Host, seen map[string]struct{}) {
if host.ID == "" {
return
}
prefixes := []string{
fmt.Sprintf("%s/disk:", hostResourceID(host.ID)),
}
m.mu.Lock()
defer m.mu.Unlock()
for storageKey, alert := range m.activeAlerts {
alertID := effectiveAlertID(alert, storageKey)
if alert == nil {
continue
}
matches := false
for _, prefix := range prefixes {
if strings.HasPrefix(alert.ResourceID, prefix) {
matches = true
break
}
}
if !matches {
continue
}
if _, exists := seen[alert.ResourceID]; exists {
continue
}
m.clearAlertNoLock(alertID)
}
}
func (m *Manager) syncHostSMARTDiskRiskAlerts(host models.Host, disk models.HostDiskSMART, resourceID, resourceName, nodeName, instanceName string, baseMetadata map[string]interface{}) {
assessment := storagehealth.AssessHostSMARTDisk(disk)
healthReasons, wearReasons := splitSMARTAlertReasons(assessment.Reasons)
m.syncHostSMARTDiskAlert(host, disk, resourceID, resourceName, nodeName, instanceName, baseMetadata, "disk-health", healthReasons)
m.syncHostSMARTDiskAlert(host, disk, resourceID, resourceName, nodeName, instanceName, baseMetadata, "disk-wearout", wearReasons)
}
func splitSMARTAlertReasons(reasons []storagehealth.Reason) ([]storagehealth.Reason, []storagehealth.Reason) {
healthReasons := make([]storagehealth.Reason, 0, len(reasons))
wearReasons := make([]storagehealth.Reason, 0, len(reasons))
for _, reason := range reasons {
if reason.Severity != storagehealth.RiskWarning && reason.Severity != storagehealth.RiskCritical {
continue
}
switch reason.Code {
case "wearout_low", "nvme_available_spare_low", "nvme_percentage_used_high":
wearReasons = append(wearReasons, reason)
case "temperature_high":
continue
default:
healthReasons = append(healthReasons, reason)
}
}
return healthReasons, wearReasons
}
var (
smartHealthAssessmentCodes = []string{
"health_status",
"pending_sectors",
"offline_uncorrectable",
"media_errors",
"reallocated_sectors",
}
smartWearoutAssessmentCodes = []string{
"wearout_low",
"nvme_available_spare_low",
"nvme_percentage_used_high",
}
raidAssessmentCodes = []string{
"raid_degraded",
"raid_unavailable",
"raid_rebuilding",
}
)
func (m *Manager) syncHostSMARTDiskAlert(host models.Host, disk models.HostDiskSMART, resourceID, resourceName, nodeName, instanceName string, baseMetadata map[string]interface{}, alertType string, reasons []storagehealth.Reason) {
alertID := fmt.Sprintf("host-%s-%s-%s", host.ID, alertType, strings.TrimPrefix(resourceID, hostResourceID(host.ID)+"/disk:"))
reasonCodes := storageHealthReasonCodes(reasons)
reasonSummaries := storageHealthReasonSummaries(reasons)
metadata := cloneMetadata(baseMetadata)
metadata["metric"] = alertType
metadata["device"] = disk.Device
metadata["model"] = disk.Model
metadata["serial"] = disk.Serial
metadata["wwn"] = disk.WWN
metadata["diskHealth"] = disk.Health
metadata["riskCodes"] = reasonCodes
metadata["riskSummaries"] = reasonSummaries
if disk.Temperature > 0 {
metadata["temperature"] = disk.Temperature
}
specCodes := smartHealthAssessmentCodes
if alertType == "disk-wearout" {
specCodes = smartWearoutAssessmentCodes
}
_, _ = m.syncCanonicalHealthAssessmentAlert(canonicalHealthAssessmentAlertParams{
SpecID: resourceID + "-" + alertType,
Signal: "host-smart",
Codes: specCodes,
Reasons: reasons,
AlertID: alertID,
AlertType: alertType,
SpecResourceID: resourceID,
ResourceID: resourceID,
ResourceName: resourceName,
ResourceType: unifiedresources.ResourceTypeAgent,
Node: nodeName,
Instance: instanceName,
Metadata: metadata,
})
}
func (m *Manager) clearHostRAIDAlerts(hostID string) {
if hostID == "" {
return
}
resourcePrefix := hostResourceID(hostID) + "/raid:"
m.mu.Lock()
defer m.mu.Unlock()
for storageKey, alert := range m.activeAlerts {
if alert == nil || alert.Type != "raid" {
continue
}
if strings.HasPrefix(alert.ResourceID, resourcePrefix) || strings.HasPrefix(alert.CanonicalSpecID, resourcePrefix) {
m.clearAlertNoLock(storageKey)
}
}
}
func (m *Manager) clearHostUnraidAlerts(hostID string) {
if hostID == "" {
return
}
resourceID := fmt.Sprintf("%s/storage:unraid-array", hostResourceID(hostID))
m.clearAlert(buildCanonicalStateID(resourceID, resourceID+"-health"))
}
func (m *Manager) syncHostUnraidStorageAlert(host models.Host, nodeName, instanceName, resourceName string, baseMetadata map[string]interface{}) {
if host.Unraid == nil {
m.clearHostUnraidAlerts(host.ID)
return
}
assessment := storagehealth.AssessUnraidStorage(*host.Unraid)
reasons := make([]storagehealth.Reason, 0, len(assessment.Reasons))
for _, reason := range assessment.Reasons {
if reason.Severity == storagehealth.RiskWarning || reason.Severity == storagehealth.RiskCritical {
reasons = append(reasons, reason)
}
}
alertID := fmt.Sprintf("host-%s-unraid-array", host.ID)
reasonCodes := storageHealthReasonCodes(reasons)
reasonSummaries := storageHealthReasonSummaries(reasons)
metadata := cloneMetadata(baseMetadata)
metadata["metric"] = "storageTopology"
metadata["storagePlatform"] = "unraid"
metadata["storageTopology"] = "array"
metadata["arrayState"] = host.Unraid.ArrayState
metadata["syncAction"] = host.Unraid.SyncAction
metadata["syncProgress"] = host.Unraid.SyncProgress
metadata["numProtected"] = host.Unraid.NumProtected
metadata["numDisabled"] = host.Unraid.NumDisabled
metadata["numInvalid"] = host.Unraid.NumInvalid
metadata["numMissing"] = host.Unraid.NumMissing
metadata["riskCodes"] = reasonCodes
metadata["riskSummaries"] = reasonSummaries
resourceID := fmt.Sprintf("%s/storage:unraid-array", hostResourceID(host.ID))
resourceLabel := fmt.Sprintf("%s - Unraid Array", resourceName)
_, _ = m.syncCanonicalHealthAssessmentAlert(canonicalHealthAssessmentAlertParams{
SpecID: resourceID + "-health",
Signal: "unraid-storage",
Reasons: reasons,
AlertID: alertID,
AlertType: "storage-topology",
SpecResourceID: resourceID,
ResourceID: resourceID,
ResourceName: resourceLabel,
ResourceType: unifiedresources.ResourceTypeAgent,
Node: nodeName,
Instance: instanceName,
Metadata: metadata,
})
}