From 77282bd3a6b1b68128fcb321b3f36054c3f81d83 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 25 Oct 2025 14:28:32 +0000 Subject: [PATCH] Implement Pulse tag overrides and alert clear persistence --- internal/alerts/alerts.go | 289 +++++++++++++++++-- internal/alerts/alerts_test.go | 163 +++++++++++ internal/api/alerts.go | 26 +- internal/monitoring/monitor.go | 12 +- internal/notifications/notifications.go | 6 + internal/notifications/notifications_test.go | 40 +++ 6 files changed, 500 insertions(+), 36 deletions(-) diff --git a/internal/alerts/alerts.go b/internal/alerts/alerts.go index 2846110..9ce52e7 100644 --- a/internal/alerts/alerts.go +++ b/internal/alerts/alerts.go @@ -707,6 +707,15 @@ func (m *Manager) dispatchAlert(alert *Alert, async bool) bool { return false } + if isMonitorOnlyAlert(alert) { + log.Info(). + Str("alertID", alert.ID). + Str("resource", alert.ResourceName). + Bool("monitorOnly", true). + Msg("Monitor-only alert detected, skipping alert dispatch") + return false + } + alertCopy := alert.Clone() if async { go func(a *Alert) { @@ -739,6 +748,22 @@ func (m *Manager) dispatchAlert(alert *Alert, async bool) bool { return true } +func isMonitorOnlyAlert(alert *Alert) bool { + if alert == nil || alert.Metadata == nil { + return false + } + + if value, ok := alert.Metadata["monitorOnly"]; ok { + switch v := value.(type) { + case bool: + return v + case string: + return strings.EqualFold(v, "true") + } + } + return false +} + // ensureValidHysteresis ensures clear < trigger for hysteresis thresholds func ensureValidHysteresis(threshold *HysteresisThreshold, metricName string) { if threshold == nil { @@ -1653,6 +1678,7 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) { var cpu, memUsage, diskUsage float64 var diskRead, diskWrite, netIn, netOut int64 var disks []models.Disk + var tags []string // Extract data based on guest type switch g := guest.(type) { @@ -1670,6 +1696,9 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) { netIn = g.NetworkIn netOut = g.NetworkOut disks = g.Disks + if len(g.Tags) > 0 { + tags = append(tags, g.Tags...) + } // Debug logging for high memory VMs if memUsage > 85 { @@ -1693,6 +1722,9 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) { netIn = g.NetworkIn netOut = g.NetworkOut disks = g.Disks + if len(g.Tags) > 0 { + tags = append(tags, g.Tags...) + } default: log.Debug(). Str("type", fmt.Sprintf("%T", guest)). @@ -1700,6 +1732,25 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) { return } + settings := parsePulseTags(tags) + if settings.Suppress { + if cleared := m.suppressGuestAlerts(guestID); cleared { + m.saveActiveAlertsAsync("pulse-no-alerts") + } + log.Debug(). + Str("guestID", guestID). + Msg("Pulse no-alerts tag active; suppressing guest alerts") + return + } + + monitorOnly := settings.MonitorOnly + if monitorOnly || m.guestHasMonitorOnlyAlerts(guestID) { + log.Info(). + Str("guest", name). + Bool("monitorOnly", monitorOnly). + Msg("Pulse monitor-only status applied") + } + // Handle non-running guests // Proxmox VM states: running, stopped, paused, suspended if status != "running" { @@ -1712,7 +1763,7 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) { m.mu.Unlock() m.clearAlert(fmt.Sprintf("guest-powered-off-%s", guestID)) } else { - m.checkGuestPoweredOff(guestID, name, node, instanceName, guestType) + m.checkGuestPoweredOff(guestID, name, node, instanceName, guestType, monitorOnly) } } else { // For paused/suspended, clear powered-off alert @@ -1754,6 +1805,14 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) { thresholds := m.getGuestThresholds(guest, guestID) m.mu.RUnlock() + if settings.Relaxed { + thresholds = applyRelaxedGuestThresholds(thresholds) + log.Info(). + Str("guest", name). + Float64("trigger", thresholds.CPU.Trigger). + Msg("Applied relaxed thresholds for pulse-relaxed tag") + } + // If alerts are disabled for this guest, clear any existing alerts and return if thresholds.Disabled { m.mu.Lock() @@ -1780,9 +1839,19 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) { Msg("Checking guest thresholds") // Check thresholds (checkMetric will skip if threshold is nil or <= 0) - m.checkMetric(guestID, name, node, instanceName, guestType, "cpu", cpu, thresholds.CPU, nil) - m.checkMetric(guestID, name, node, instanceName, guestType, "memory", memUsage, thresholds.Memory, nil) - m.checkMetric(guestID, name, node, instanceName, guestType, "disk", diskUsage, thresholds.Disk, nil) + cpuOpts := &metricOptions{MonitorOnly: monitorOnly} + memOpts := &metricOptions{MonitorOnly: monitorOnly} + diskOpts := &metricOptions{MonitorOnly: monitorOnly} + + if !monitorOnly { + cpuOpts = nil + memOpts = nil + diskOpts = nil + } + + m.checkMetric(guestID, name, node, instanceName, guestType, "cpu", cpu, thresholds.CPU, cpuOpts) + m.checkMetric(guestID, name, node, instanceName, guestType, "memory", memUsage, thresholds.Memory, memOpts) + m.checkMetric(guestID, name, node, instanceName, guestType, "disk", diskUsage, thresholds.Disk, diskOpts) if thresholds.Disk != nil && thresholds.Disk.Trigger > 0 && len(disks) > 0 { seenDisks := make(map[string]struct{}) @@ -1840,24 +1909,41 @@ func (m *Manager) CheckGuest(guest interface{}, instanceName string) { } m.checkMetric(perDiskResourceID, name, node, instanceName, guestType, "disk", disk.Usage, thresholds.Disk, &metricOptions{ - Metadata: metadata, - Message: message, + Metadata: metadata, + Message: message, + MonitorOnly: monitorOnly, }) } } // Check I/O metrics (convert bytes/s to MB/s) - checkMetric will skip if threshold is nil or <= 0 if thresholds.DiskRead != nil && thresholds.DiskRead.Trigger > 0 { - m.checkMetric(guestID, name, node, instanceName, guestType, "diskRead", float64(diskRead)/1024/1024, thresholds.DiskRead, nil) + readOpts := &metricOptions{MonitorOnly: monitorOnly} + if !monitorOnly { + readOpts = nil + } + m.checkMetric(guestID, name, node, instanceName, guestType, "diskRead", float64(diskRead)/1024/1024, thresholds.DiskRead, readOpts) } if thresholds.DiskWrite != nil && thresholds.DiskWrite.Trigger > 0 { - m.checkMetric(guestID, name, node, instanceName, guestType, "diskWrite", float64(diskWrite)/1024/1024, thresholds.DiskWrite, nil) + writeOpts := &metricOptions{MonitorOnly: monitorOnly} + if !monitorOnly { + writeOpts = nil + } + m.checkMetric(guestID, name, node, instanceName, guestType, "diskWrite", float64(diskWrite)/1024/1024, thresholds.DiskWrite, writeOpts) } if thresholds.NetworkIn != nil && thresholds.NetworkIn.Trigger > 0 { - m.checkMetric(guestID, name, node, instanceName, guestType, "networkIn", float64(netIn)/1024/1024, thresholds.NetworkIn, nil) + netInOpts := &metricOptions{MonitorOnly: monitorOnly} + if !monitorOnly { + netInOpts = nil + } + m.checkMetric(guestID, name, node, instanceName, guestType, "networkIn", float64(netIn)/1024/1024, thresholds.NetworkIn, netInOpts) } if thresholds.NetworkOut != nil && thresholds.NetworkOut.Trigger > 0 { - m.checkMetric(guestID, name, node, instanceName, guestType, "networkOut", float64(netOut)/1024/1024, thresholds.NetworkOut, nil) + netOutOpts := &metricOptions{MonitorOnly: monitorOnly} + if !monitorOnly { + netOutOpts = nil + } + m.checkMetric(guestID, name, node, instanceName, guestType, "networkOut", float64(netOut)/1024/1024, thresholds.NetworkOut, netOutOpts) } } @@ -4349,6 +4435,8 @@ func canonicalResourceTypeKeys(resourceType string) []string { type metricOptions struct { Metadata map[string]interface{} Message string + // MonitorOnly suppresses external notifications while still tracking the alert. + MonitorOnly bool } func (m *Manager) checkMetric(resourceID, resourceName, node, instance, resourceType, metricType string, value float64, threshold *HysteresisThreshold, opts *metricOptions) { @@ -4371,6 +4459,7 @@ func (m *Manager) checkMetric(resourceID, resourceName, node, instance, resource defer m.mu.Unlock() existingAlert, exists := m.activeAlerts[alertID] + monitorOnly := opts != nil && opts.MonitorOnly // Check for suppression if suppressUntil, suppressed := m.suppressedUntil[alertID]; suppressed && time.Now().Before(suppressUntil) { @@ -4477,6 +4566,7 @@ func (m *Manager) checkMetric(resourceID, resourceName, node, instance, resource alertMetadata[k] = v } } + alertMetadata["monitorOnly"] = monitorOnly alert := &Alert{ ID: alertID, @@ -4565,6 +4655,7 @@ func (m *Manager) checkMetric(resourceID, resourceName, node, instance, resource } existingAlert.Metadata["resourceType"] = resourceType existingAlert.Metadata["clearThreshold"] = threshold.Clear + existingAlert.Metadata["monitorOnly"] = monitorOnly if opts != nil { if opts.Message != "" { existingAlert.Message = opts.Message @@ -6364,7 +6455,7 @@ func (m *Manager) clearStorageOfflineAlert(storage models.Storage) { } // checkGuestPoweredOff creates an alert for powered-off guests -func (m *Manager) checkGuestPoweredOff(guestID, name, node, instanceName, guestType string) { +func (m *Manager) checkGuestPoweredOff(guestID, name, node, instanceName, guestType string, monitorOnly bool) { alertID := fmt.Sprintf("guest-powered-off-%s", guestID) m.mu.Lock() @@ -6398,6 +6489,10 @@ func (m *Manager) checkGuestPoweredOff(guestID, name, node, instanceName, guestT // Alert already exists, just update LastSeen alert.LastSeen = time.Now() alert.Level = severity + if alert.Metadata == nil { + alert.Metadata = map[string]interface{}{} + } + alert.Metadata["monitorOnly"] = monitorOnly return } @@ -6438,6 +6533,9 @@ func (m *Manager) checkGuestPoweredOff(guestID, name, node, instanceName, guestT StartTime: time.Now(), LastSeen: time.Now(), Acknowledged: false, + Metadata: map[string]interface{}{ + "monitorOnly": monitorOnly, + }, } m.preserveAlertState(alertID, alert) @@ -6504,16 +6602,22 @@ func (m *Manager) clearGuestPoweredOffAlert(guestID, name string) { } // ClearAlert removes an alert from active alerts (but keeps in history) -func (m *Manager) ClearAlert(alertID string) { +func (m *Manager) ClearAlert(alertID string) bool { m.mu.Lock() - defer m.mu.Unlock() - - // Remove from active alerts only - m.removeActiveAlertNoLock(alertID) - - if m.onResolved != nil { - go m.onResolved(alertID) + if _, exists := m.activeAlerts[alertID]; !exists { + m.mu.Unlock() + return false } + + m.clearAlertNoLock(alertID) + delete(m.recentAlerts, alertID) + delete(m.pendingAlerts, alertID) + delete(m.suppressedUntil, alertID) + delete(m.alertRateLimit, alertID) + m.mu.Unlock() + + m.saveActiveAlertsAsync("manual-clear") + return true } // Cleanup removes old acknowledged alerts and cleans up tracking maps @@ -6625,6 +6729,20 @@ func cloneThreshold(threshold *HysteresisThreshold) *HysteresisThreshold { return &clone } +func cloneThresholdConfig(cfg ThresholdConfig) ThresholdConfig { + clone := cfg + clone.CPU = cloneThreshold(cfg.CPU) + clone.Memory = cloneThreshold(cfg.Memory) + clone.Disk = cloneThreshold(cfg.Disk) + clone.DiskRead = cloneThreshold(cfg.DiskRead) + clone.DiskWrite = cloneThreshold(cfg.DiskWrite) + clone.NetworkIn = cloneThreshold(cfg.NetworkIn) + clone.NetworkOut = cloneThreshold(cfg.NetworkOut) + clone.Temperature = cloneThreshold(cfg.Temperature) + clone.Usage = cloneThreshold(cfg.Usage) + return clone +} + func (m *Manager) applyThresholdOverride(base ThresholdConfig, override ThresholdConfig) ThresholdConfig { result := base @@ -6699,6 +6817,120 @@ func ensureHysteresisThreshold(threshold *HysteresisThreshold) *HysteresisThresh return threshold } +type pulseTagSettings struct { + Suppress bool + MonitorOnly bool + Relaxed bool +} + +func parsePulseTags(tags []string) pulseTagSettings { + settings := pulseTagSettings{} + for _, raw := range tags { + tag := strings.TrimSpace(strings.ToLower(raw)) + switch tag { + case "pulse-no-alerts": + settings.Suppress = true + case "pulse-monitor-only": + settings.MonitorOnly = true + case "pulse-relaxed": + settings.Relaxed = true + } + } + return settings +} + +func applyRelaxedGuestThresholds(cfg ThresholdConfig) ThresholdConfig { + relaxed := cloneThresholdConfig(cfg) + + adjust := func(th **HysteresisThreshold, minTrigger float64) { + if *th == nil { + *th = &HysteresisThreshold{Trigger: minTrigger, Clear: minTrigger - 5} + return + } + ensureHysteresisThreshold(*th) + if (*th).Trigger < minTrigger { + (*th).Trigger = minTrigger + } + if (*th).Clear >= (*th).Trigger { + (*th).Clear = (*th).Trigger - 5 + } + if (*th).Clear < 0 { + (*th).Clear = 0 + } + } + + adjust(&relaxed.CPU, 95) + adjust(&relaxed.Memory, 92) + adjust(&relaxed.Disk, 95) + + return relaxed +} + +func (m *Manager) suppressGuestAlerts(guestID string) bool { + m.mu.Lock() + defer m.mu.Unlock() + + cleared := false + + for alertID, alert := range m.activeAlerts { + if alert == nil { + continue + } + if alert.ResourceID == guestID || strings.HasPrefix(alert.ResourceID, guestID+"/") || strings.HasPrefix(alertID, guestID) { + m.clearAlertNoLock(alertID) + delete(m.recentAlerts, alertID) + delete(m.pendingAlerts, alertID) + delete(m.suppressedUntil, alertID) + delete(m.alertRateLimit, alertID) + cleared = true + } + } + + for key := range m.pendingAlerts { + if strings.HasPrefix(key, guestID) { + delete(m.pendingAlerts, key) + } + } + for key := range m.recentAlerts { + if strings.HasPrefix(key, guestID) { + delete(m.recentAlerts, key) + } + } + for key := range m.suppressedUntil { + if strings.HasPrefix(key, guestID) { + delete(m.suppressedUntil, key) + } + } + for key := range m.alertRateLimit { + if strings.HasPrefix(key, guestID) { + delete(m.alertRateLimit, key) + } + } + + delete(m.offlineConfirmations, guestID) + + return cleared +} + +func (m *Manager) guestHasMonitorOnlyAlerts(guestID string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + + for _, alert := range m.activeAlerts { + if alert == nil { + continue + } + if alert.ResourceID != guestID { + continue + } + if isMonitorOnlyAlert(alert) { + return true + } + } + + return false +} + // evaluateFilterCondition evaluates a single filter condition against a guest func (m *Manager) evaluateFilterCondition(guest interface{}, condition FilterCondition) bool { switch g := guest.(type) { @@ -7150,6 +7382,25 @@ func (m *Manager) SaveActiveAlerts() error { return nil } +func (m *Manager) saveActiveAlertsAsync(context string) { + go func() { + defer func() { + if r := recover(); r != nil { + log.Error(). + Interface("panic", r). + Str("context", context). + Msg("Panic in SaveActiveAlerts goroutine") + } + }() + if err := m.SaveActiveAlerts(); err != nil { + log.Error(). + Err(err). + Str("context", context). + Msg("Failed to save active alerts") + } + }() +} + // LoadActiveAlerts restores active alerts from disk func (m *Manager) LoadActiveAlerts() error { m.mu.Lock() diff --git a/internal/alerts/alerts_test.go b/internal/alerts/alerts_test.go index ba470b1..35d748b 100644 --- a/internal/alerts/alerts_test.go +++ b/internal/alerts/alerts_test.go @@ -72,6 +72,7 @@ func TestCheckGuestSkipsAlertsWhenMetricDisabled(t *testing.T) { m.mu.Lock() m.config.TimeThreshold = 0 m.config.TimeThresholds = map[string]int{} + m.config.ActivationState = ActivationActive m.mu.Unlock() var dispatched []*Alert @@ -132,6 +133,7 @@ func TestCheckGuestSkipsAlertsWhenMetricDisabled(t *testing.T) { m.mu.Lock() m.config.TimeThreshold = 0 m.config.TimeThresholds = map[string]int{} + m.config.ActivationState = ActivationActive m.mu.Unlock() // Clear dispatched slice to capture only post-disable notifications. @@ -172,6 +174,167 @@ func TestCheckGuestSkipsAlertsWhenMetricDisabled(t *testing.T) { } } +func TestPulseNoAlertsSuppressesGuestAlerts(t *testing.T) { + m := NewManager() + m.ClearActiveAlerts() + m.mu.Lock() + m.config.TimeThreshold = 0 + m.config.TimeThresholds = map[string]int{} + m.config.ActivationState = ActivationActive + m.mu.Unlock() + + var dispatched int + m.SetAlertCallback(func(alert *Alert) { + dispatched++ + }) + + vm := models.VM{ + ID: "inst/qemu/101", + Name: "test-vm", + Node: "node1", + Instance: "inst", + Status: "running", + CPU: 1.0, + Memory: models.Memory{ + Usage: 95, + }, + Disk: models.Disk{ + Usage: 95, + }, + Tags: []string{"pulse-no-alerts"}, + } + + m.CheckGuest(vm, "inst") + + if dispatched != 0 { + t.Fatalf("expected no alert dispatch, got %d", dispatched) + } + + if alerts := m.GetActiveAlerts(); len(alerts) != 0 { + t.Fatalf("expected no active alerts, got %d", len(alerts)) + } +} + +func TestPulseMonitorOnlySkipsDispatchButRetainsAlert(t *testing.T) { + m := NewManager() + m.ClearActiveAlerts() + m.mu.Lock() + m.config.TimeThreshold = 0 + m.config.TimeThresholds = map[string]int{} + m.config.ActivationState = ActivationActive + m.mu.Unlock() + + var dispatched int + m.SetAlertCallback(func(alert *Alert) { + dispatched++ + }) + + vm := models.VM{ + ID: "inst/qemu/102", + Name: "monitor-vm", + Node: "node1", + Instance: "inst", + Status: "running", + CPU: 1.0, + Memory: models.Memory{Usage: 90}, + Disk: models.Disk{Usage: 50}, + Tags: []string{"pulse-monitor-only"}, + } + + m.CheckGuest(vm, "inst") + + if dispatched != 0 { + t.Fatalf("expected monitor-only alert to skip dispatch, got %d callbacks", dispatched) + } + + alerts := m.GetActiveAlerts() + if len(alerts) == 0 { + t.Fatalf("expected monitor-only alert to remain active") + } + + if alerts[0].Metadata == nil || alerts[0].Metadata["monitorOnly"] != true { + t.Fatalf("expected alert metadata to mark monitorOnly, got %+v", alerts[0].Metadata) + } +} + +func TestPulseRelaxedThresholdsIncreaseCpuTrigger(t *testing.T) { + m := NewManager() + m.ClearActiveAlerts() + m.mu.Lock() + m.config.TimeThreshold = 0 + m.config.TimeThresholds = map[string]int{} + m.mu.Unlock() + + vm := models.VM{ + ID: "inst/qemu/103", + Name: "relaxed-vm", + Node: "node1", + Instance: "inst", + Status: "running", + CPU: 0.9, // 90% + Memory: models.Memory{Usage: 60}, + Disk: models.Disk{Usage: 40}, + Tags: []string{"pulse-relaxed"}, + } + + m.CheckGuest(vm, "inst") + + if alerts := m.GetActiveAlerts(); len(alerts) != 0 { + t.Fatalf("expected no alerts at 90%% CPU with relaxed thresholds, got %d", len(alerts)) + } + + vm.CPU = 1.0 + m.CheckGuest(vm, "inst") + + if alerts := m.GetActiveAlerts(); len(alerts) == 0 { + t.Fatalf("expected alert once CPU exceeds relaxed threshold") + } +} + +func TestClearAlertMarksResolutionAndReturnsStatus(t *testing.T) { + m := NewManager() + m.ClearActiveAlerts() + m.mu.Lock() + m.config.TimeThreshold = 0 + m.config.TimeThresholds = map[string]int{} + m.mu.Unlock() + + vm := models.VM{ + ID: "inst/qemu/104", + Name: "clear-vm", + Node: "node1", + Instance: "inst", + Status: "running", + CPU: 1.0, + Memory: models.Memory{Usage: 80}, + Disk: models.Disk{Usage: 80}, + } + + m.CheckGuest(vm, "inst") + alerts := m.GetActiveAlerts() + if len(alerts) == 0 { + t.Fatalf("expected alert to be active before clearing") + } + + alertID := alerts[0].ID + if ok := m.ClearAlert(alertID); !ok { + t.Fatalf("expected manual clear to succeed") + } + + if remaining := m.GetActiveAlerts(); len(remaining) != 0 { + t.Fatalf("expected no active alerts after clear, found %d", len(remaining)) + } + + resolved := m.GetRecentlyResolved() + if len(resolved) == 0 || resolved[0].Alert.ID != alertID { + t.Fatalf("expected alert %s to be tracked as recently resolved", alertID) + } + + if ok := m.ClearAlert(alertID); ok { + t.Fatalf("expected second clear to report missing alert") + } +} + func TestHandleDockerHostRemovedClearsAlertsAndTracking(t *testing.T) { m := NewManager() host := models.DockerHost{ID: "host1", DisplayName: "Host One", Hostname: "host-one"} diff --git a/internal/api/alerts.go b/internal/api/alerts.go index a7dda15..47e09de 100644 --- a/internal/api/alerts.go +++ b/internal/api/alerts.go @@ -80,14 +80,13 @@ func (h *AlertHandlers) UpdateAlertConfig(w http.ResponseWriter, r *http.Request h.monitor.GetAlertManager().UpdateConfig(config) // Update notification manager with schedule settings - if config.Schedule.Cooldown > 0 { - h.monitor.GetNotificationManager().SetCooldown(config.Schedule.Cooldown) - } - if config.Schedule.GroupingWindow > 0 { - h.monitor.GetNotificationManager().SetGroupingWindow(config.Schedule.GroupingWindow) - } else if config.Schedule.Grouping.Window > 0 { - h.monitor.GetNotificationManager().SetGroupingWindow(config.Schedule.Grouping.Window) + h.monitor.GetNotificationManager().SetCooldown(config.Schedule.Cooldown) + + groupWindow := config.Schedule.Grouping.Window + if groupWindow == 0 && config.Schedule.GroupingWindow != 0 { + groupWindow = config.Schedule.GroupingWindow } + h.monitor.GetNotificationManager().SetGroupingWindow(groupWindow) h.monitor.GetNotificationManager().SetGroupingOptions( config.Schedule.Grouping.ByNode, config.Schedule.Grouping.ByGuest, @@ -537,7 +536,10 @@ func (h *AlertHandlers) ClearAlert(w http.ResponseWriter, r *http.Request) { return } - h.monitor.GetAlertManager().ClearAlert(alertID) + if !h.monitor.GetAlertManager().ClearAlert(alertID) { + http.Error(w, "Alert not found", http.StatusNotFound) + return + } h.monitor.SyncAlertState() // Send response immediately @@ -643,8 +645,12 @@ func (h *AlertHandlers) BulkClearAlerts(w http.ResponseWriter, r *http.Request) "alertId": alertID, "success": true, } - h.monitor.GetAlertManager().ClearAlert(alertID) - anySuccess = true + if h.monitor.GetAlertManager().ClearAlert(alertID) { + anySuccess = true + } else { + result["success"] = false + result["error"] = "alert not found" + } results = append(results, result) } diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index a1521b5..c5181e2 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -2823,14 +2823,12 @@ func New(cfg *config.Config) (*Monitor, error) { if alertConfig, err := m.configPersist.LoadAlertConfig(); err == nil { m.alertManager.UpdateConfig(*alertConfig) // Apply schedule settings to notification manager - if alertConfig.Schedule.Cooldown > 0 { - m.notificationMgr.SetCooldown(alertConfig.Schedule.Cooldown) - } - if alertConfig.Schedule.GroupingWindow > 0 { - m.notificationMgr.SetGroupingWindow(alertConfig.Schedule.GroupingWindow) - } else if alertConfig.Schedule.Grouping.Window > 0 { - m.notificationMgr.SetGroupingWindow(alertConfig.Schedule.Grouping.Window) + m.notificationMgr.SetCooldown(alertConfig.Schedule.Cooldown) + groupWindow := alertConfig.Schedule.Grouping.Window + if groupWindow == 0 && alertConfig.Schedule.GroupingWindow != 0 { + groupWindow = alertConfig.Schedule.GroupingWindow } + m.notificationMgr.SetGroupingWindow(groupWindow) m.notificationMgr.SetGroupingOptions( alertConfig.Schedule.Grouping.ByNode, alertConfig.Schedule.Grouping.ByGuest, diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index 9535ed8..e9f3a6b 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -390,6 +390,9 @@ func (n *NotificationManager) GetAppriseConfig() AppriseConfig { func (n *NotificationManager) SetCooldown(minutes int) { n.mu.Lock() defer n.mu.Unlock() + if minutes < 0 { + minutes = 0 + } n.cooldown = time.Duration(minutes) * time.Minute log.Info().Int("minutes", minutes).Msg("Updated notification cooldown") } @@ -398,6 +401,9 @@ func (n *NotificationManager) SetCooldown(minutes int) { func (n *NotificationManager) SetGroupingWindow(seconds int) { n.mu.Lock() defer n.mu.Unlock() + if seconds < 0 { + seconds = 0 + } n.groupWindow = time.Duration(seconds) * time.Second log.Info().Int("seconds", seconds).Msg("Updated notification grouping window") } diff --git a/internal/notifications/notifications_test.go b/internal/notifications/notifications_test.go index da78ba8..b817439 100644 --- a/internal/notifications/notifications_test.go +++ b/internal/notifications/notifications_test.go @@ -101,6 +101,46 @@ func TestNormalizeAppriseConfig(t *testing.T) { } } +func TestSetCooldownClampsNegativeValues(t *testing.T) { + nm := NewNotificationManager("") + nm.SetCooldown(-10) + + nm.mu.RLock() + if nm.cooldown != 0 { + nm.mu.RUnlock() + t.Fatalf("expected cooldown to clamp to zero, got %s", nm.cooldown) + } + nm.mu.RUnlock() + + nm.SetCooldown(5) + nm.mu.RLock() + if nm.cooldown != 5*time.Minute { + nm.mu.RUnlock() + t.Fatalf("expected cooldown of five minutes, got %s", nm.cooldown) + } + nm.mu.RUnlock() +} + +func TestSetGroupingWindowClampsNegativeValues(t *testing.T) { + nm := NewNotificationManager("") + nm.SetGroupingWindow(-60) + + nm.mu.RLock() + if nm.groupWindow != 0 { + nm.mu.RUnlock() + t.Fatalf("expected grouping window to clamp to zero, got %s", nm.groupWindow) + } + nm.mu.RUnlock() + + nm.SetGroupingWindow(120) + nm.mu.RLock() + if nm.groupWindow != 120*time.Second { + nm.mu.RUnlock() + t.Fatalf("expected grouping window of 120 seconds, got %s", nm.groupWindow) + } + nm.mu.RUnlock() +} + func TestSendGroupedAppriseInvokesExecutor(t *testing.T) { nm := NewNotificationManager("") nm.SetGroupingWindow(0)