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,