Surface alert override identities in diagnostics

Refs #1341

buildAlertsDiagnostic previously emitted only boolean flags (legacy
thresholds, missing cooldown/grouping window). Override keys and their
trigger values were absent, so triaging a support case where the user
suspects an override mismatch required asking them to paste
data/alerts.json 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, so a public export still shows the alert
shape without leaking instance names that may be hostnames.
This commit is contained in:
rcourtman 2026-05-28 10:45:10 +01:00
parent a70f1d9676
commit 9ac7df976f
3 changed files with 139 additions and 8 deletions

View file

@ -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<string, unknown> | undefined;
if (alertsAny && Array.isArray(alertsAny.overrides)) {
alertsAny.overrides = alertsAny.overrides.map((o: Record<string, unknown>, i: number) => ({
...o,
key: `override-${i + 1}`,
}));
}
// Redact IPs in error messages
data.errors = errors.map(redactString);

View file

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

View file

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