diff --git a/internal/config/persistence.go b/internal/config/persistence.go index 9d6068033..31e4195a1 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -565,7 +565,15 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) { }, Overrides: make(map[string]alerts.ThresholdConfig), Schedule: alerts.ScheduleConfig{ + Cooldown: 5, + MaxAlertsHour: 10, NotifyOnResolve: true, + Grouping: alerts.GroupingConfig{ + Enabled: true, + Window: 30, + ByNode: true, + ByGuest: false, + }, }, }, nil } @@ -579,9 +587,62 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) { // For empty config files ({}), enable alerts by default // This handles the case where the file exists but is empty - if string(data) == "{}" { + if bytes.Equal(bytes.TrimSpace(data), []byte("{}")) { config.Enabled = true } + + defaultSchedule := alerts.ScheduleConfig{ + Cooldown: 5, + MaxAlertsHour: 10, + NotifyOnResolve: true, + Grouping: alerts.GroupingConfig{ + Enabled: true, + Window: 30, + ByNode: true, + ByGuest: false, + }, + } + var scheduleProbe struct { + Schedule *struct { + Cooldown *int `json:"cooldown"` + MaxAlertsHour *int `json:"maxAlertsHour"` + NotifyOnResolve *bool `json:"notifyOnResolve"` + Grouping *struct { + Enabled *bool `json:"enabled"` + Window *int `json:"window"` + ByNode *bool `json:"byNode"` + ByGuest *bool `json:"byGuest"` + } `json:"grouping"` + } `json:"schedule"` + } + if err := json.Unmarshal(data, &scheduleProbe); err == nil { + if scheduleProbe.Schedule == nil || scheduleProbe.Schedule.Cooldown == nil { + config.Schedule.Cooldown = defaultSchedule.Cooldown + } + if scheduleProbe.Schedule == nil || scheduleProbe.Schedule.MaxAlertsHour == nil { + config.Schedule.MaxAlertsHour = defaultSchedule.MaxAlertsHour + } + if scheduleProbe.Schedule == nil || scheduleProbe.Schedule.NotifyOnResolve == nil { + config.Schedule.NotifyOnResolve = defaultSchedule.NotifyOnResolve + } + if scheduleProbe.Schedule == nil || scheduleProbe.Schedule.Grouping == nil { + config.Schedule.Grouping = defaultSchedule.Grouping + } else { + if scheduleProbe.Schedule.Grouping.Enabled == nil { + config.Schedule.Grouping.Enabled = defaultSchedule.Grouping.Enabled + } + if scheduleProbe.Schedule.Grouping.Window == nil { + config.Schedule.Grouping.Window = defaultSchedule.Grouping.Window + } + if scheduleProbe.Schedule.Grouping.ByNode == nil { + config.Schedule.Grouping.ByNode = defaultSchedule.Grouping.ByNode + } + if scheduleProbe.Schedule.Grouping.ByGuest == nil { + config.Schedule.Grouping.ByGuest = defaultSchedule.Grouping.ByGuest + } + } + } + // Storage: Allow Trigger=0 to disable storage alerting if config.StorageDefault.Trigger < 0 { config.StorageDefault.Trigger = 85 @@ -727,18 +788,6 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) { } config.BackupDefaults.IgnoreVMIDs = normalized } - // Default NotifyOnResolve to true if not explicitly set in saved config. - // Older configs (pre-5.1.12) don't have this field, so Go unmarshals it - // as false — but the intended default is true (recovery notifications ON). - var scheduleProbe struct { - Schedule struct { - NotifyOnResolve *bool `json:"notifyOnResolve"` - } `json:"schedule"` - } - if err := json.Unmarshal(data, &scheduleProbe); err == nil && scheduleProbe.Schedule.NotifyOnResolve == nil { - config.Schedule.NotifyOnResolve = true - } - config.MetricTimeThresholds = alerts.NormalizeMetricTimeThresholds(config.MetricTimeThresholds) config.DockerIgnoredContainerPrefixes = alerts.NormalizeDockerIgnoredPrefixes(config.DockerIgnoredContainerPrefixes) diff --git a/internal/config/persistence_alerts_normalization_test.go b/internal/config/persistence_alerts_normalization_test.go index f85ec99dd..47ed8af8f 100644 --- a/internal/config/persistence_alerts_normalization_test.go +++ b/internal/config/persistence_alerts_normalization_test.go @@ -26,6 +26,54 @@ func TestLoadAlertConfig_Normalization(t *testing.T) { input: map[string]interface{}{}, verify: func(t *testing.T, cfg *alerts.AlertConfig) { assert.True(t, cfg.Enabled) + assert.Equal(t, 5, cfg.Schedule.Cooldown) + assert.Equal(t, 10, cfg.Schedule.MaxAlertsHour) + assert.True(t, cfg.Schedule.NotifyOnResolve) + assert.True(t, cfg.Schedule.Grouping.Enabled) + assert.Equal(t, 30, cfg.Schedule.Grouping.Window) + assert.True(t, cfg.Schedule.Grouping.ByNode) + assert.False(t, cfg.Schedule.Grouping.ByGuest) + }, + }, + { + name: "Legacy schedule missing cooldown and grouping defaults", + input: map[string]interface{}{ + "schedule": map[string]interface{}{ + "notifyOnResolve": false, + }, + }, + verify: func(t *testing.T, cfg *alerts.AlertConfig) { + assert.Equal(t, 5, cfg.Schedule.Cooldown) + assert.Equal(t, 10, cfg.Schedule.MaxAlertsHour) + assert.False(t, cfg.Schedule.NotifyOnResolve) + assert.True(t, cfg.Schedule.Grouping.Enabled) + assert.Equal(t, 30, cfg.Schedule.Grouping.Window) + assert.True(t, cfg.Schedule.Grouping.ByNode) + assert.False(t, cfg.Schedule.Grouping.ByGuest) + }, + }, + { + name: "Explicit zero cooldown preserved", + input: map[string]interface{}{ + "schedule": map[string]interface{}{ + "cooldown": 0, + "notifyOnResolve": true, + "grouping": map[string]interface{}{ + "enabled": false, + "window": 0, + "byNode": false, + "byGuest": true, + }, + }, + }, + verify: func(t *testing.T, cfg *alerts.AlertConfig) { + assert.Equal(t, 0, cfg.Schedule.Cooldown) + assert.Equal(t, 10, cfg.Schedule.MaxAlertsHour) + assert.True(t, cfg.Schedule.NotifyOnResolve) + assert.False(t, cfg.Schedule.Grouping.Enabled) + assert.Equal(t, 0, cfg.Schedule.Grouping.Window) + assert.False(t, cfg.Schedule.Grouping.ByNode) + assert.True(t, cfg.Schedule.Grouping.ByGuest) }, }, {