diff --git a/internal/alerts/config/normalize.go b/internal/alerts/config/normalize.go index 6448aee99..50074b0fe 100644 --- a/internal/alerts/config/normalize.go +++ b/internal/alerts/config/normalize.go @@ -282,6 +282,61 @@ func NormalizeAgentDefaults(config *AlertConfig) { } } EnsureValidHysteresis(config.AgentDefaults.DiskTemperature, "agent.diskTemperature") + + NormalizeDiskFillByType(config) +} + +// diskFillByTypeDefaults returns the canonical per-type fill-% defaults. +// Keys are lowercase hardware type strings. +func diskFillByTypeDefaults() map[string]HysteresisThreshold { + return map[string]HysteresisThreshold{ + "nvme": {Trigger: 92, Clear: 87}, + "sata": {Trigger: 90, Clear: 85}, + "hdd": {Trigger: 85, Clear: 80}, + } +} + +// NormalizeDiskFillByType ensures AlertConfig.DiskFillByType is seeded with +// lowercase nvme/sata/hdd defaults when nil, lowercases any existing keys, +// and resets non-positive trigger or clear values to the default for that +// key. Operator-customized positive values are preserved. +func NormalizeDiskFillByType(config *AlertConfig) { + defaults := diskFillByTypeDefaults() + if config.DiskFillByType == nil { + copyMap := make(map[string]HysteresisThreshold, len(defaults)) + for k, v := range defaults { + copyMap[k] = v + } + config.DiskFillByType = copyMap + return + } + + // Lowercase any non-lowercase keys, moving values into the canonical position. + for key, value := range config.DiskFillByType { + lower := strings.ToLower(strings.TrimSpace(key)) + if lower == key { + continue + } + delete(config.DiskFillByType, key) + if lower == "" { + continue + } + if _, exists := config.DiskFillByType[lower]; !exists { + config.DiskFillByType[lower] = value + } + } + + // Ensure all canonical keys are present and have positive trigger/clear values. + for key, defaultVal := range defaults { + current, ok := config.DiskFillByType[key] + if !ok { + config.DiskFillByType[key] = defaultVal + continue + } + if current.Trigger <= 0 || current.Clear <= 0 { + config.DiskFillByType[key] = defaultVal + } + } } func NormalizeGeneralSettings(config *AlertConfig) { diff --git a/internal/alerts/default_config.go b/internal/alerts/default_config.go index 4bb3ea6d4..cbae0e30f 100644 --- a/internal/alerts/default_config.go +++ b/internal/alerts/default_config.go @@ -76,7 +76,12 @@ func defaultAlertConfig() AlertConfig { CPU: &HysteresisThreshold{Trigger: 80, Clear: 75}, Memory: &HysteresisThreshold{Trigger: 85, Clear: 80}, }, - StorageDefault: HysteresisThreshold{Trigger: 85, Clear: 80}, + StorageDefault: HysteresisThreshold{Trigger: 85, Clear: 80}, + DiskFillByType: map[string]HysteresisThreshold{ + "nvme": {Trigger: 92, Clear: 87}, + "sata": {Trigger: 90, Clear: 85}, + "hdd": {Trigger: 85, Clear: 80}, + }, MinimumDelta: 2.0, // 2% minimum change SuppressionWindow: 5, // 5 minutes HysteresisMargin: 5.0, // 5% default margin diff --git a/internal/alerts/default_normalization_test.go b/internal/alerts/default_normalization_test.go index 20d9dea77..2da85e4c0 100644 --- a/internal/alerts/default_normalization_test.go +++ b/internal/alerts/default_normalization_test.go @@ -1,6 +1,10 @@ package alerts -import "testing" +import ( + "testing" + + alertconfig "github.com/rcourtman/pulse-go-rewrite/internal/alerts/config" +) func TestNormalizeSnapshotDefaults(t *testing.T) { tests := []struct { @@ -189,3 +193,111 @@ func TestBackupIgnoreVMID(t *testing.T) { }) } } + +func TestNormalizeDiskFillByType(t *testing.T) { + t.Run("nil map seeds nvme/sata/hdd defaults", func(t *testing.T) { + cfg := &AlertConfig{} + alertconfig.NormalizeDiskFillByType(cfg) + + if cfg.DiskFillByType == nil { + t.Fatal("expected DiskFillByType to be seeded, got nil") + } + nvme, ok := cfg.DiskFillByType["nvme"] + if !ok { + t.Fatal("nvme key not seeded") + } + if nvme.Trigger != 92 || nvme.Clear != 87 { + t.Fatalf("nvme = %+v, want {Trigger:92 Clear:87}", nvme) + } + sata, ok := cfg.DiskFillByType["sata"] + if !ok { + t.Fatal("sata key not seeded") + } + if sata.Trigger != 90 || sata.Clear != 85 { + t.Fatalf("sata = %+v, want {Trigger:90 Clear:85}", sata) + } + hdd, ok := cfg.DiskFillByType["hdd"] + if !ok { + t.Fatal("hdd key not seeded") + } + if hdd.Trigger != 85 || hdd.Clear != 80 { + t.Fatalf("hdd = %+v, want {Trigger:85 Clear:80}", hdd) + } + }) + + t.Run("operator customized values survive", func(t *testing.T) { + cfg := &AlertConfig{ + DiskFillByType: map[string]HysteresisThreshold{ + "nvme": {Trigger: 95, Clear: 90}, + }, + } + alertconfig.NormalizeDiskFillByType(cfg) + + nvme := cfg.DiskFillByType["nvme"] + if nvme.Trigger != 95 || nvme.Clear != 90 { + t.Fatalf("nvme = %+v, want operator value {Trigger:95 Clear:90}", nvme) + } + // Missing keys still seeded. + if sata, ok := cfg.DiskFillByType["sata"]; !ok || sata.Trigger != 90 || sata.Clear != 85 { + t.Fatalf("sata = %+v ok=%v, want default {Trigger:90 Clear:85}", sata, ok) + } + if hdd, ok := cfg.DiskFillByType["hdd"]; !ok || hdd.Trigger != 85 || hdd.Clear != 80 { + t.Fatalf("hdd = %+v ok=%v, want default {Trigger:85 Clear:80}", hdd, ok) + } + }) + + t.Run("negative trigger resets to default", func(t *testing.T) { + cfg := &AlertConfig{ + DiskFillByType: map[string]HysteresisThreshold{ + "nvme": {Trigger: -1, Clear: 10}, + "sata": {Trigger: 88, Clear: -5}, + }, + } + alertconfig.NormalizeDiskFillByType(cfg) + + nvme := cfg.DiskFillByType["nvme"] + if nvme.Trigger != 92 || nvme.Clear != 87 { + t.Fatalf("nvme = %+v, want default reset {Trigger:92 Clear:87}", nvme) + } + sata := cfg.DiskFillByType["sata"] + if sata.Trigger != 90 || sata.Clear != 85 { + t.Fatalf("sata = %+v, want default reset {Trigger:90 Clear:85}", sata) + } + }) + + t.Run("mixed case keys lowercase to canonical", func(t *testing.T) { + cfg := &AlertConfig{ + DiskFillByType: map[string]HysteresisThreshold{ + "NVMe": {Trigger: 93, Clear: 88}, + }, + } + alertconfig.NormalizeDiskFillByType(cfg) + + if _, exists := cfg.DiskFillByType["NVMe"]; exists { + t.Fatalf("expected mixed-case key NVMe to be removed, map=%+v", cfg.DiskFillByType) + } + nvme, ok := cfg.DiskFillByType["nvme"] + if !ok { + t.Fatalf("expected lowercase nvme key, map=%+v", cfg.DiskFillByType) + } + if nvme.Trigger != 93 || nvme.Clear != 88 { + t.Fatalf("nvme = %+v, want preserved operator value {Trigger:93 Clear:88}", nvme) + } + }) +} + +func TestDefaultAlertConfigSeedsDiskFillByType(t *testing.T) { + cfg := defaultAlertConfig() + if cfg.DiskFillByType == nil { + t.Fatal("expected defaultAlertConfig to seed DiskFillByType, got nil") + } + if nvme, ok := cfg.DiskFillByType["nvme"]; !ok || nvme.Trigger != 92 || nvme.Clear != 87 { + t.Fatalf("nvme = %+v ok=%v, want {Trigger:92 Clear:87}", nvme, ok) + } + if sata, ok := cfg.DiskFillByType["sata"]; !ok || sata.Trigger != 90 || sata.Clear != 85 { + t.Fatalf("sata = %+v ok=%v, want {Trigger:90 Clear:85}", sata, ok) + } + if hdd, ok := cfg.DiskFillByType["hdd"]; !ok || hdd.Trigger != 85 || hdd.Clear != 80 { + t.Fatalf("hdd = %+v ok=%v, want {Trigger:85 Clear:80}", hdd, ok) + } +}