From cd278bf5e24e95557d6265b25a7fe729bc7cb33e Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 28 May 2026 10:56:21 +0100 Subject: [PATCH] Surface alert override identities in diagnostics Forward-port of the release/5.1 fix (9ac7df976). buildAlertsDiagnostic previously emitted only cooldown/grouping flags, so triaging support cases like #1341 where a user suspects an override key mismatch required asking them to paste alerts config from inside their container. Add an Overrides slice that names each persisted key with its thresholds and disabled flags. Sanitize mode in the frontend redacts the keys to override-N while keeping thresholds visible. --- .../__tests__/diagnosticsModel.test.ts | 19 ++++++ .../components/Settings/diagnosticsModel.ts | 18 +++++ internal/api/diagnostics.go | 58 ++++++++++++++++- internal/api/diagnostics_additional_test.go | 65 +++++++++++++++++++ 4 files changed, 157 insertions(+), 3 deletions(-) diff --git a/frontend-modern/src/components/Settings/__tests__/diagnosticsModel.test.ts b/frontend-modern/src/components/Settings/__tests__/diagnosticsModel.test.ts index fecffc5fb..2fa1b62c2 100644 --- a/frontend-modern/src/components/Settings/__tests__/diagnosticsModel.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/diagnosticsModel.test.ts @@ -99,6 +99,21 @@ const createDiagnosticsData = (): DiagnosticsData => platforms: [{ key: 'truenas', catalog_selected: 2, credentials_opened: 1 }], notes: ['Some probed addresses did not match a supported API-backed platform.'], }, + alerts: { + missingCooldown: false, + missingGroupingWindow: false, + overrides: [ + { + key: 'pve5-ceph-pool-data_replication', + thresholds: { usage: 50 }, + }, + { + key: 'pve5-101', + disabled: true, + thresholds: { cpu: 75 }, + }, + ], + }, errors: ['probe failed for 10.0.0.10 after timeout'], }) as DiagnosticsData; @@ -140,6 +155,10 @@ describe('diagnosticsModel', () => { }), ); expect(sanitized.errors).toEqual(['probe failed for [REDACTED_IP] after timeout']); + expect(sanitized.alerts?.overrides).toEqual([ + expect.objectContaining({ key: 'override-1', thresholds: { usage: 50 } }), + expect.objectContaining({ key: 'override-2', disabled: true, thresholds: { cpu: 75 } }), + ]); expect(sanitized).not.toHaveProperty('commercialFunnel'); expect(sanitized).not.toHaveProperty('infrastructureOnboarding'); }); diff --git a/frontend-modern/src/components/Settings/diagnosticsModel.ts b/frontend-modern/src/components/Settings/diagnosticsModel.ts index 89602c3f5..d53095359 100644 --- a/frontend-modern/src/components/Settings/diagnosticsModel.ts +++ b/frontend-modern/src/components/Settings/diagnosticsModel.ts @@ -81,10 +81,18 @@ export interface DockerAgentDiagnostic { notes?: string[]; } +export interface AlertsOverrideDiagnostic { + key: string; + disabled?: boolean; + disableConnectivity?: boolean; + thresholds?: Record; +} + export interface AlertsDiagnostic { missingCooldown: boolean; missingGroupingWindow: boolean; notes?: string[]; + overrides?: AlertsOverrideDiagnostic[]; } export interface MetricsStoreDiagnostic { @@ -237,6 +245,16 @@ export function sanitizeDiagnosticsData(raw: DiagnosticsData): DiagnosticsData { data.aiChat.url = '[REDACTED]'; } + if (data.alerts && Array.isArray(data.alerts.overrides)) { + data.alerts = { + ...data.alerts, + overrides: data.alerts.overrides.map((override, index) => ({ + ...override, + key: `override-${index + 1}`, + })), + }; + } + if (Array.isArray(data.errors)) { data.errors = data.errors.map(redactString); } diff --git a/internal/api/diagnostics.go b/internal/api/diagnostics.go index 49ba82e0c..a80e3190c 100644 --- a/internal/api/diagnostics.go +++ b/internal/api/diagnostics.go @@ -16,6 +16,7 @@ import ( "time" "github.com/prometheus/client_golang/prometheus" + alertconfig "github.com/rcourtman/pulse-go-rewrite/internal/alerts/config" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" @@ -680,9 +681,20 @@ func (a DockerAgentAttention) NormalizeCollections() DockerAgentAttention { // AlertsDiagnostic summarises alert configuration migration state. type AlertsDiagnostic struct { - MissingCooldown bool `json:"missingCooldown"` - MissingGroupingWindow bool `json:"missingGroupingWindow"` - Notes []string `json:"notes"` + MissingCooldown bool `json:"missingCooldown"` + MissingGroupingWindow bool `json:"missingGroupingWindow"` + Notes []string `json:"notes"` + Overrides []AlertsOverrideDiagnostic `json:"overrides,omitempty"` +} + +// AlertsOverrideDiagnostic captures one threshold override entry so support +// triage can verify the persisted key matches the runtime resource ID +// without asking users to cat their alerts config from inside the container. +type AlertsOverrideDiagnostic struct { + Key string `json:"key"` + Disabled bool `json:"disabled,omitempty"` + DisableConnectivity bool `json:"disableConnectivity,omitempty"` + Thresholds map[string]float64 `json:"thresholds,omitempty"` } func (d AlertsDiagnostic) NormalizeCollections() AlertsDiagnostic { @@ -1321,9 +1333,49 @@ func buildAlertsDiagnostic(m *monitoring.Monitor) *AlertsDiagnostic { appendNote("Alert grouping window is disabled. Configure a grouping window to bundle related alerts.") } + overrideKeys := make([]string, 0, len(config.Overrides)) + for key := range config.Overrides { + overrideKeys = append(overrideKeys, key) + } + sort.Strings(overrideKeys) + for _, key := range overrideKeys { + diag.Overrides = append(diag.Overrides, summarizeAlertOverride(key, config.Overrides[key])) + } + return diag } +func summarizeAlertOverride(key string, th alertconfig.ThresholdConfig) AlertsOverrideDiagnostic { + out := AlertsOverrideDiagnostic{ + Key: key, + Disabled: th.Disabled, + DisableConnectivity: th.DisableConnectivity, + } + + thresholds := map[string]float64{} + addThreshold := func(name string, t *alertconfig.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 fingerprintPublicKey(pub string) (string, error) { pub = strings.TrimSpace(pub) if pub == "" { diff --git a/internal/api/diagnostics_additional_test.go b/internal/api/diagnostics_additional_test.go index 807d1c3f6..48edd725b 100644 --- a/internal/api/diagnostics_additional_test.go +++ b/internal/api/diagnostics_additional_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + alertconfig "github.com/rcourtman/pulse-go-rewrite/internal/alerts/config" "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" @@ -472,6 +473,70 @@ func TestBuildAlertsDiagnostic_LegacySettings(t *testing.T) { } } +// Triage cases like #1341 hinge on whether a persisted override key matches +// the runtime resource ID. Surface override keys + thresholds in the +// diagnostics export so we don't have to ask users to cat the alerts config. +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 := alertconfig.HysteresisThreshold{Trigger: 50, Clear: 45} + cpu := alertconfig.HysteresisThreshold{Trigger: 75, Clear: 70} + alertCfg.Overrides = map[string]alertconfig.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", keysOfOverrideDiag(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", keysOfOverrideDiag(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 keysOfOverrideDiag(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,