diff --git a/frontend-modern/src/components/Settings/DiagnosticsPanel.tsx b/frontend-modern/src/components/Settings/DiagnosticsPanel.tsx index 1aec79904..f83cb9cb7 100644 --- a/frontend-modern/src/components/Settings/DiagnosticsPanel.tsx +++ b/frontend-modern/src/components/Settings/DiagnosticsPanel.tsx @@ -345,6 +345,18 @@ export const DiagnosticsPanel: Component = () => { data.aiChat.url = '[REDACTED]'; } + // Redact alert override keys: they encode instance names that may + // themselves be hostnames or IPs (some users name instances after + // their Proxmox URL). Thresholds and disabled flags stay visible so + // the override shape is still triageable. + const alertsAny = data.alerts as Record | undefined; + if (alertsAny && Array.isArray(alertsAny.overrides)) { + alertsAny.overrides = alertsAny.overrides.map((o: Record, i: number) => ({ + ...o, + key: `override-${i + 1}`, + })); + } + // Redact IPs in error messages data.errors = errors.map(redactString); diff --git a/internal/api/diagnostics.go b/internal/api/diagnostics.go index fc1cbb13d..55ffa20fd 100644 --- a/internal/api/diagnostics.go +++ b/internal/api/diagnostics.go @@ -376,12 +376,25 @@ type DockerAgentAttention struct { // AlertsDiagnostic summarises alert configuration migration state. type AlertsDiagnostic struct { - LegacyThresholdsDetected bool `json:"legacyThresholdsDetected"` - LegacyThresholdSources []string `json:"legacyThresholdSources,omitempty"` - LegacyScheduleSettings []string `json:"legacyScheduleSettings,omitempty"` - MissingCooldown bool `json:"missingCooldown"` - MissingGroupingWindow bool `json:"missingGroupingWindow"` - Notes []string `json:"notes,omitempty"` + LegacyThresholdsDetected bool `json:"legacyThresholdsDetected"` + LegacyThresholdSources []string `json:"legacyThresholdSources,omitempty"` + LegacyScheduleSettings []string `json:"legacyScheduleSettings,omitempty"` + MissingCooldown bool `json:"missingCooldown"` + MissingGroupingWindow bool `json:"missingGroupingWindow"` + Notes []string `json:"notes,omitempty"` + Overrides []AlertsOverrideDiagnostic `json:"overrides,omitempty"` +} + +// AlertsOverrideDiagnostic captures one threshold override entry so support +// triage can verify that the persisted key matches the runtime resource ID +// (e.g. that a Ceph pool override under `pve5-ceph-pool-data_replication` is +// actually keyed the same way the alert manager looks it up). +type AlertsOverrideDiagnostic struct { + Key string `json:"key"` + Disabled bool `json:"disabled,omitempty"` + DisableConnectivity bool `json:"disableConnectivity,omitempty"` + Thresholds map[string]float64 `json:"thresholds,omitempty"` + LegacyThresholds bool `json:"legacyThresholds,omitempty"` } // AIChatDiagnostic reports on the AI chat service status. @@ -1049,13 +1062,22 @@ func buildAlertsDiagnostic(m *monitoring.Monitor) *AlertsDiagnostic { legacySources = append(legacySources, "node-defaults") } + overrideKeys := make([]string, 0, len(config.Overrides)) + for key := range config.Overrides { + overrideKeys = append(overrideKeys, key) + } + sort.Strings(overrideKeys) + overrideIndex := 0 - for _, override := range config.Overrides { + for _, key := range overrideKeys { + override := config.Overrides[key] overrideIndex++ - if hasLegacyThresholds(override) { + isLegacy := hasLegacyThresholds(override) + if isLegacy { diag.LegacyThresholdsDetected = true legacySources = append(legacySources, fmt.Sprintf("override-%d", overrideIndex)) } + diag.Overrides = append(diag.Overrides, summarizeOverride(key, override, isLegacy)) } for idx, rule := range config.CustomRules { @@ -1156,6 +1178,38 @@ func hasLegacyThresholds(th alerts.ThresholdConfig) bool { th.NetworkOutLegacy != nil } +func summarizeOverride(key string, th alerts.ThresholdConfig, legacy bool) AlertsOverrideDiagnostic { + out := AlertsOverrideDiagnostic{ + Key: key, + Disabled: th.Disabled, + DisableConnectivity: th.DisableConnectivity, + LegacyThresholds: legacy, + } + + thresholds := map[string]float64{} + addThreshold := func(name string, t *alerts.HysteresisThreshold) { + if t == nil { + return + } + thresholds[name] = t.Trigger + } + addThreshold("cpu", th.CPU) + addThreshold("memory", th.Memory) + addThreshold("disk", th.Disk) + addThreshold("diskRead", th.DiskRead) + addThreshold("diskWrite", th.DiskWrite) + addThreshold("networkIn", th.NetworkIn) + addThreshold("networkOut", th.NetworkOut) + addThreshold("usage", th.Usage) + addThreshold("temperature", th.Temperature) + addThreshold("diskTemperature", th.DiskTemperature) + + if len(thresholds) > 0 { + out.Thresholds = thresholds + } + return out +} + func preferredDockerHostName(host models.DockerHost) string { if name := strings.TrimSpace(host.DisplayName); name != "" { return name diff --git a/internal/api/diagnostics_additional_test.go b/internal/api/diagnostics_additional_test.go index 2fcf2471d..6ce637511 100644 --- a/internal/api/diagnostics_additional_test.go +++ b/internal/api/diagnostics_additional_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker" @@ -415,6 +416,70 @@ func TestBuildAlertsDiagnostic_LegacySettings(t *testing.T) { } } +// Triage cases like #1341 hinge on whether the persisted override key +// matches the runtime storage ID. Surface override keys + thresholds in the +// diagnostics export so we don't have to ask users to cat `data/alerts.json`. +func TestBuildAlertsDiagnostic_OverridesEmittedForTriage(t *testing.T) { + cfg := &config.Config{DataPath: t.TempDir()} + monitor := newMonitorForDiagnostics(t, cfg) + + manager := monitor.GetAlertManager() + alertCfg := manager.GetConfig() + alertCfg.Schedule.Cooldown = 600 + alertCfg.Schedule.Grouping.Window = 30 + + usage := alerts.HysteresisThreshold{Trigger: 50, Clear: 45} + cpu := alerts.HysteresisThreshold{Trigger: 75, Clear: 70} + alertCfg.Overrides = map[string]alerts.ThresholdConfig{ + "pve5-ceph-pool-data_replication": {Usage: &usage}, + "pve5-101": {CPU: &cpu, Disabled: true}, + } + manager.UpdateConfig(alertCfg) + + diag := buildAlertsDiagnostic(monitor) + if diag == nil { + t.Fatalf("expected diagnostics") + } + if len(diag.Overrides) != 2 { + t.Fatalf("override count = %d, want 2", len(diag.Overrides)) + } + + byKey := map[string]AlertsOverrideDiagnostic{} + for _, o := range diag.Overrides { + byKey[o.Key] = o + } + + ceph, ok := byKey["pve5-ceph-pool-data_replication"] + if !ok { + t.Fatalf("expected ceph pool override key in diagnostics, got keys %v", keysOf(byKey)) + } + if got := ceph.Thresholds["usage"]; got != 50 { + t.Fatalf("ceph usage threshold = %.1f, want 50", got) + } + if ceph.Disabled { + t.Fatalf("ceph override should not be disabled") + } + + guest, ok := byKey["pve5-101"] + if !ok { + t.Fatalf("expected guest override key in diagnostics, got keys %v", keysOf(byKey)) + } + if got := guest.Thresholds["cpu"]; got != 75 { + t.Fatalf("guest cpu threshold = %.1f, want 75", got) + } + if !guest.Disabled { + t.Fatalf("guest override Disabled flag lost in summary") + } +} + +func keysOf(m map[string]AlertsOverrideDiagnostic) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + func TestBuildDiscoveryDiagnostic_ConfigOnly(t *testing.T) { cfg := &config.Config{ DiscoveryEnabled: true,