Default legacy alert schedule settings on load

Back-port v5 fix af486b6f5 to v6. LoadAlertConfig now back-fills
Schedule.Cooldown=5, MaxAlertsHour=10, NotifyOnResolve=true and the
Grouping defaults for legacy alerts.json files saved before these
fields existed, probing raw JSON so an explicit 0/false is preserved.
Without this, a v5 user migrating to v6 loads with zeroed cooldown and
rate limit, silently losing notification-spam protection.
This commit is contained in:
rcourtman 2026-06-04 08:32:06 +01:00
parent 5a21f0c288
commit 545a6f22a0
2 changed files with 109 additions and 10 deletions

View file

@ -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
}
}
}

View file

@ -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{}{