diff --git a/internal/config/persistence.go b/internal/config/persistence.go index f1eb2e250..e7eb5c7b9 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -88,7 +88,17 @@ type alertSchedulePresence struct { } type alertScheduleFieldPresence struct { - NotifyOnResolve *bool `json:"notifyOnResolve"` + Cooldown *int `json:"cooldown"` + MaxAlertsHour *int `json:"maxAlertsHour"` + NotifyOnResolve *bool `json:"notifyOnResolve"` + Grouping *alertScheduleGroupingPresence `json:"grouping"` +} + +type alertScheduleGroupingPresence struct { + Enabled *bool `json:"enabled"` + Window *int `json:"window"` + ByNode *bool `json:"byNode"` + ByGuest *bool `json:"byGuest"` } type resolvedConfigPersistencePaths struct { @@ -927,7 +937,15 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) { Disk: &alerts.HysteresisThreshold{Trigger: 90, Clear: 85}, }, Schedule: alerts.ScheduleConfig{ + Cooldown: 5, + MaxAlertsHour: 10, NotifyOnResolve: true, + Grouping: alerts.GroupingConfig{ + Enabled: true, + Window: 30, + ByNode: true, + ByGuest: false, + }, }, StorageDefault: alerts.HysteresisThreshold{Trigger: 85, Clear: 80}, TimeThresholds: map[string]int{ @@ -973,15 +991,48 @@ func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) { config.Enabled = true } - // Load-specific: NotifyOnResolve defaults to true (send recovery notifications). - // Since bool zero-value is false, we check raw JSON to distinguish - // "missing field" (old configs) from "explicitly set to false". - if !config.Schedule.NotifyOnResolve { - var presence alertSchedulePresence - // Ignore error here as we've already unmarshaled successfully above. - if json.Unmarshal(data, &presence) == nil { - if presence.Schedule == nil || presence.Schedule.NotifyOnResolve == nil { - config.Schedule.NotifyOnResolve = true + // Load-specific: back-fill legacy alert schedule defaults for configs saved + // before these fields existed (pre-5.1.12). Because Go unmarshals a missing + // field to its zero value, we probe the raw JSON to distinguish "field + // absent" (apply default) from "explicitly set to 0/false" (preserve it). + defaultSchedule := alerts.ScheduleConfig{ + Cooldown: 5, + MaxAlertsHour: 10, + NotifyOnResolve: true, + Grouping: alerts.GroupingConfig{ + Enabled: true, + Window: 30, + ByNode: true, + ByGuest: false, + }, + } + var presence alertSchedulePresence + // Ignore error here as we've already unmarshaled successfully above. + if json.Unmarshal(data, &presence) == nil { + sched := presence.Schedule + if sched == nil || sched.Cooldown == nil { + config.Schedule.Cooldown = defaultSchedule.Cooldown + } + if sched == nil || sched.MaxAlertsHour == nil { + config.Schedule.MaxAlertsHour = defaultSchedule.MaxAlertsHour + } + if sched == nil || sched.NotifyOnResolve == nil { + config.Schedule.NotifyOnResolve = defaultSchedule.NotifyOnResolve + } + if sched == nil || sched.Grouping == nil { + config.Schedule.Grouping = defaultSchedule.Grouping + } else { + if sched.Grouping.Enabled == nil { + config.Schedule.Grouping.Enabled = defaultSchedule.Grouping.Enabled + } + if sched.Grouping.Window == nil { + config.Schedule.Grouping.Window = defaultSchedule.Grouping.Window + } + if sched.Grouping.ByNode == nil { + config.Schedule.Grouping.ByNode = defaultSchedule.Grouping.ByNode + } + if sched.Grouping.ByGuest == nil { + config.Schedule.Grouping.ByGuest = defaultSchedule.Grouping.ByGuest } } } diff --git a/internal/config/persistence_alerts_normalization_test.go b/internal/config/persistence_alerts_normalization_test.go index f2cf4923b..39064f3bc 100644 --- a/internal/config/persistence_alerts_normalization_test.go +++ b/internal/config/persistence_alerts_normalization_test.go @@ -26,6 +26,13 @@ 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) }, }, { @@ -114,6 +121,47 @@ func TestLoadAlertConfig_Normalization(t *testing.T) { assert.False(t, cfg.Schedule.NotifyOnResolve) }, }, + { + 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 and grouping 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) + }, + }, { name: "NodeDefaults Temperature nil", input: map[string]interface{}{