Make per-guest backup/snapshot toggles inherit global thresholds

The per-guest Backup/Snapshot toggle persisted a full copy of the current
global defaults just to flip enabled, freezing the threshold values into
the override. Later global edits (a 32-day backup warning) then silently
never applied to toggled guests, which kept firing at the frozen factory
7-day warning. Reported twice in discussion #1126.

- Guest overrides now resolve against the globals at evaluation time:
  zero-valued fields inherit the global value, explicit values still win,
  and hand-written sparse overrides stop decoding as accidental zeros.
- The toggles write enabled-only overrides instead of freezing a copy.
- Normalization rewrites stored overrides whose thresholds exactly match
  the current globals into sparse form, which is behavior-preserving at
  migration time and un-freezes existing installs.
- The Backups/Snapshots global editors reconcile warning/critical pairs
  by adjusting the untouched field, so typing a 32-day warning no longer
  silently snaps back to the 14-day critical default.

Refs #1126
This commit is contained in:
rcourtman 2026-08-12 06:38:25 +01:00
parent 1243cde497
commit 359a372cdf
15 changed files with 598 additions and 53 deletions

View file

@ -816,6 +816,28 @@ evaluation, backup rollup age evaluation, backup inventory readiness, PVE
template subject matching, namespace disambiguation, and snapshot/backup active
alert cleanup; future backup or snapshot alert behavior should extend that
owner rather than expanding the central Manager file.
Per-guest backup and snapshot overrides are sparse, not frozen copies. A
zero-valued threshold field in an override (`warningDays`, `criticalDays`,
`freshHours`, `staleHours`, and the snapshot size pair) inherits the current
global default at evaluation time through the merge helpers in
`internal/alerts/backup_snapshot.go`; explicit non-zero values win, and
`alertOrphaned` plus `ignoreVMIDs` always resolve from the globals because they
are instance-wide filters. `NormalizeRecoveryOverrides` in
`internal/alerts/config/normalize.go` (called from `UpdateConfig` in
`internal/alerts/config_runtime.go` and from the shared persistence
normalization in `internal/config/persistence.go`) rewrites stored overrides
whose threshold tuple exactly matches the current globals into sparse
enabled-only form. Those full copies were artifacts of the legacy per-guest
toggle, and the equality condition makes the rewrite behavior-preserving at
the moment it runs while letting the guest track later global edits (#1126).
Overrides whose thresholds differ from the current globals are deliberate
per-guest values and must never be rewritten.
`TestGuestBackupOverrideInheritsGlobalThresholds`,
`TestFrozenBackupOverrideMigratesToSparse`,
`TestDeliberateBackupOverridePreserved`, and
`TestMergeSnapshotOverrideInheritsZeroFields` in
`internal/alerts/alerts_test.go` pin the merge, migration, and preservation
behaviors.
Proxmox disk health alert evaluation now lives in
`internal/alerts/disk_health.go`. That file owns Proxmox disk canonical
identity, disk health assessment alerts, known-firmware health suppression, and
@ -1107,6 +1129,21 @@ override toggles now route through
while powered-off/connectivity state transitions plus alert-removal side
effects now route through
`frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsAvailabilityMutations.ts`.
The backup/snapshot override toggles in that mutations owner write sparse
enabled-only overrides (zero-valued threshold fields) rather than copying the
current global defaults into the override, because zero-valued fields inherit
the globals at evaluation time and a copied value freezes the guest against
later global edits (#1126). Toggling a guest that already carries explicit
override thresholds must preserve those values while flipping enabled.
The Backups and Snapshots global-defaults editors in
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxBackupsSection.tsx`
and
`frontend-modern/src/components/Alerts/ThresholdsTableProxmoxSnapshotsSection.tsx`
reconcile warning/critical pairs through `reconcileWarningCriticalEdit` in
`frontend-modern/src/features/alerts/thresholds/helpers.ts`: a single-field
edit that creates a conflict adjusts the field the user did not touch, so a
32-day warning edit raises critical alongside it instead of being silently
clamped back down by the sanitizers before save.
That same thresholds host-data boundary now treats top-level TrueNAS appliances
as canonical `agent` resources with `platformType: 'truenas'`. System-disk
group headers must use agent-owned header metadata instead of guest/node-

View file

@ -319,6 +319,13 @@ tabs. The thresholds platform IA is platform-shaped: Proxmox, Docker,
Kubernetes, TrueNAS, vSphere, PBS, PMG, and Systems. Frontend primitives own
the chip, reset, "+ Filter", and route-backed shell pattern; alerts must not
replace that with page-local search/tab chrome or legacy neutral buckets.
Threshold edit semantics under `frontend-modern/src/features/alerts/thresholds/`
also stay alerts-owned: the override mutation hooks write sparse enabled-only
backup/snapshot overrides that inherit global thresholds at evaluation time,
and the warning/critical pair reconciliation in the thresholds helpers adjusts
the untouched field on a conflicting single-field edit. Primitives must not
absorb those persistence or validation rules into shared form/table chrome
(#1126).
PlatformTableToolbar may accept compact consumer-owned context through its
shared leading-control rail when the context is actionable for the inventory
immediately below it. Consumers must use that extension point for local

View file

@ -1,22 +1,22 @@
{
"version": 1,
"base_sha": "cea33b8ee29255035d7d248704b80428761085c6",
"verified_at": "2026-08-11T19:40:09Z",
"base_sha": "1243cde4971a70007ac5f867593da76f19698e22",
"verified_at": "2026-08-12T05:34:19Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/diskListModel.ts",
"frontend-modern/src/components/Workloads/stackedDiskBarModel.ts",
"frontend-modern/src/features/standalone/agentMachineTableModel.ts",
"frontend-modern/src/utils/format.ts"
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxBackupsSection.tsx",
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxSnapshotsSection.tsx",
"frontend-modern/src/features/alerts/thresholds/helpers.ts",
"frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/diskListModel.ts": "82dd17c7d9ba7c5ee103db375d11748df245ecf843ab56505f9e4fa6d21f0b81",
"frontend-modern/src/components/Workloads/stackedDiskBarModel.ts": "a72196fdc5c75bdd40ad5c45f1934e06d05ebdd4a89f68f6e932a12f97383b78",
"frontend-modern/src/features/standalone/agentMachineTableModel.ts": "fe5a0b14cc9324ac9d6197295a6f201fe59c6d22fd66437b22aeb4b567b316cf",
"frontend-modern/src/utils/format.ts": "8c09d25938796573232752d5e326055712c784dc906731f110188804a09b2114"
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxBackupsSection.tsx": "cebf62372258700510ff8f9a912bbf51df2c7fafbbc263de12e52353aaf0d335",
"frontend-modern/src/components/Alerts/ThresholdsTableProxmoxSnapshotsSection.tsx": "705c40d5b57090e551d56eeef5a9e04fa007a43f5d6a78c34033e5d6472a5ffc",
"frontend-modern/src/features/alerts/thresholds/helpers.ts": "b3da861fb86315e87beeade0376871bf4cf9dbeb14bd53d13055584a98866abb",
"frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsOverrideMutations.ts": "1e917cd27216674aa58c038b9715f7621668bcc157a97e1b4f0e6ac1b606e0f8"
},
"routes": [
"/proxmox"
"/alerts/thresholds/proxmox"
],
"viewports": [
{
@ -24,20 +24,24 @@
"height": 800
},
{
"width": 768,
"height": 900
"width": 375,
"height": 812
}
],
"states": [
"mock-mode LXC edge-proxy-01 with a measured rootfs plus a config-only mount (/srv/archive, usage -1, total 20 GiB) in the shared workloads table",
"workloads row disk cell: single measured bar (37% target), no fabricated 0% mini-slot for the config-only mount, at both viewports",
"row disk tooltip: Disk Breakdown lists / at 37% (21.9 GB/59.0 GB) and /srv/archive as — with ?/20.0 GB",
"guest drawer Filesystems block: rootfs card 21.9 GB/59.0 GB 37% ROOTFS and /srv/archive card ?/20.0 GB — MP0 at both viewports"
"Backups section global defaults row at factory 7/14 warning/critical days",
"Backups global defaults row after warning-days edit to 32 (critical auto-raised to 32, warning preserved)",
"Backups global defaults row after critical-days edit to 45 (consistent 32/45 pair untouched)",
"Backups global defaults row after critical-days edit to 10 below warning (warning lowered to 10/10)",
"Narrow 375px card layout of the same Backups global defaults row with the warning-days edit applied",
"All edits discarded via reload before saving; live dev alert config unchanged"
],
"interactions": [
"searched the Proxmox workloads table for edge-proxy-01 at 1280x800",
"hovered the row disk cell to open the Disk Breakdown tooltip and read both entries",
"clicked the row to expand the guest drawer and read the Filesystems block",
"resized to 768x900, reloaded /proxmox, re-filtered to edge-proxy-01, re-opened the drawer and re-read the disk cell and Filesystems block"
"Logged in as admin and opened Alerts > Thresholds > Proxmox",
"Edited Backups global Warning Days from 7 to 32 and confirmed the value stayed 32 with Critical Days raised to 32 instead of the old silent 14 clamp",
"Edited Critical Days to 45 and confirmed the 32/45 pair was left untouched",
"Edited Critical Days to 10 and confirmed Warning Days lowered with it to 10/10",
"Repeated the Warning Days 32 edit at 375px mobile viewport and confirmed identical 32/32 behavior in the card layout",
"Reloaded without saving after each pass so no threshold change persisted to the dev backend"
]
}

View file

@ -10,7 +10,10 @@ import {
DEFAULT_BACKUP_FRESH_HOURS,
DEFAULT_BACKUP_STALE_HOURS,
} from '@/features/alerts/thresholds/constants';
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
import {
formatMetricValue,
reconcileWarningCriticalEdit,
} from '@/features/alerts/thresholds/helpers';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
export function ThresholdsTableProxmoxBackupsSection(props: ThresholdsTableSectionProps) {
@ -56,6 +59,22 @@ export function ThresholdsTableProxmoxBackupsSection(props: ThresholdsTableSecti
typeof value === 'function'
? value(currentRecord)
: { ...currentRecord, ...value };
const days = reconcileWarningCriticalEdit(
{
warning: currentRecord['warning days'],
critical: currentRecord['critical days'],
},
{
warning:
typeof nextRecord['warning days'] === 'number'
? nextRecord['warning days']
: currentRecord['warning days'],
critical:
typeof nextRecord['critical days'] === 'number'
? nextRecord['critical days']
: currentRecord['critical days'],
},
);
return {
...prev,
freshHours:
@ -66,14 +85,8 @@ export function ThresholdsTableProxmoxBackupsSection(props: ThresholdsTableSecti
typeof nextRecord['stale hours'] === 'number'
? nextRecord['stale hours']
: prev.staleHours,
warningDays:
typeof nextRecord['warning days'] === 'number'
? nextRecord['warning days']
: prev.warningDays,
criticalDays:
typeof nextRecord['critical days'] === 'number'
? nextRecord['critical days']
: prev.criticalDays,
warningDays: days.warning,
criticalDays: days.critical,
};
});
}}

View file

@ -3,7 +3,10 @@ import Camera from 'lucide-solid/icons/camera';
import { ResourceTable } from './ResourceTable';
import { CollapsibleSection } from './Thresholds/sections/CollapsibleSection';
import { formatMetricValue } from '@/features/alerts/thresholds/helpers';
import {
formatMetricValue,
reconcileWarningCriticalEdit,
} from '@/features/alerts/thresholds/helpers';
import type { ThresholdsTableSectionProps } from '@/features/alerts/thresholds/thresholdsTableSectionProps';
export function ThresholdsTableProxmoxSnapshotsSection(props: ThresholdsTableSectionProps) {
@ -49,24 +52,44 @@ export function ThresholdsTableProxmoxSnapshotsSection(props: ThresholdsTableSec
typeof value === 'function'
? value(currentRecord)
: { ...currentRecord, ...value };
const days = reconcileWarningCriticalEdit(
{
warning: currentRecord['warning days'],
critical: currentRecord['critical days'],
},
{
warning:
typeof nextRecord['warning days'] === 'number'
? nextRecord['warning days']
: currentRecord['warning days'],
critical:
typeof nextRecord['critical days'] === 'number'
? nextRecord['critical days']
: currentRecord['critical days'],
},
);
const sizes = reconcileWarningCriticalEdit(
{
warning: currentRecord.warningSizeGiB,
critical: currentRecord.criticalSizeGiB,
},
{
warning:
typeof nextRecord.warningSizeGiB === 'number'
? nextRecord.warningSizeGiB
: currentRecord.warningSizeGiB,
critical:
typeof nextRecord.criticalSizeGiB === 'number'
? nextRecord.criticalSizeGiB
: currentRecord.criticalSizeGiB,
},
);
return {
...prev,
warningDays:
typeof nextRecord['warning days'] === 'number'
? nextRecord['warning days']
: prev.warningDays,
criticalDays:
typeof nextRecord['critical days'] === 'number'
? nextRecord['critical days']
: prev.criticalDays,
warningSizeGiB:
typeof nextRecord.warningSizeGiB === 'number'
? nextRecord.warningSizeGiB
: prev.warningSizeGiB,
criticalSizeGiB:
typeof nextRecord.criticalSizeGiB === 'number'
? nextRecord.criticalSizeGiB
: prev.criticalSizeGiB,
warningDays: days.warning,
criticalDays: days.critical,
warningSizeGiB: sizes.warning,
criticalSizeGiB: sizes.critical,
};
});
}}

View file

@ -3,6 +3,7 @@ import {
normalizeThresholdLabel,
normalizeDockerIgnoredInput,
formatMetricValue,
reconcileWarningCriticalEdit,
} from '@/features/alerts/thresholds/helpers';
describe('alerts thresholds helpers', () => {
@ -132,4 +133,30 @@ describe('alerts thresholds helpers', () => {
expect(formatMetricValue('unknown', 123)).toBe('123');
});
});
describe('reconcileWarningCriticalEdit', () => {
it('raises critical when the warning edit exceeds it', () => {
expect(
reconcileWarningCriticalEdit({ warning: 7, critical: 14 }, { warning: 32, critical: 14 }),
).toEqual({ warning: 32, critical: 32 });
});
it('lowers warning when the critical edit drops below it', () => {
expect(
reconcileWarningCriticalEdit({ warning: 32, critical: 45 }, { warning: 32, critical: 10 }),
).toEqual({ warning: 10, critical: 10 });
});
it('leaves consistent pairs untouched', () => {
expect(
reconcileWarningCriticalEdit({ warning: 7, critical: 14 }, { warning: 10, critical: 14 }),
).toEqual({ warning: 10, critical: 14 });
});
it('allows warning-only configurations when critical is unset', () => {
expect(
reconcileWarningCriticalEdit({ warning: 7, critical: 0 }, { warning: 32, critical: 0 }),
).toEqual({ warning: 32, critical: 0 });
});
});
});

View file

@ -20,6 +20,25 @@ export const pmgColumn = (key: keyof PMGThresholdDefaults, label: string) => ({
normalized: normalizeThresholdLabel(label),
});
// Keeps a warning/critical pair consistent after a single-field edit by
// adjusting the field the user did NOT touch. The sanitizers clamp the warning
// value down when it exceeds critical, which silently rewrote the number the
// user just typed (a 32-day backup warning collapsed back to the 14-day
// critical default, #1126). Raising the counterpart instead preserves the
// typed value and stays visible in the form before saving.
export const reconcileWarningCriticalEdit = (
prev: { warning: number; critical: number },
next: { warning: number; critical: number },
): { warning: number; critical: number } => {
const warningChanged = next.warning !== prev.warning;
const criticalChanged = next.critical !== prev.critical;
if (next.critical <= 0 || next.warning <= next.critical) return next;
if (warningChanged && !criticalChanged) return { warning: next.warning, critical: next.warning };
if (criticalChanged && !warningChanged)
return { warning: next.critical, critical: next.critical };
return next;
};
export const normalizeDockerIgnoredInput = (value: string): string[] =>
value
.split('\n')

View file

@ -169,4 +169,108 @@ describe('useThresholdsOverrideMutations', () => {
},
});
});
it('toggleBackup writes a sparse enabled-only override instead of freezing the globals', () => {
const overrideSignal = createSignal<any[]>([]);
const { props, rawOverridesConfig } = buildTableProps(overrideSignal);
const guestResource: TableResource = {
id: 'cluster-a:node-2:100',
name: 'db-01',
type: 'guest',
resourceType: 'VM',
vmid: 100,
node: 'node-2',
instance: 'cluster-a',
defaults: { cpu: 80 },
thresholds: { cpu: 80 },
};
const { result } = renderHook(() =>
useThresholdsOverrideMutations({
props,
resources: {
nodesWithOverrides: () => [],
agentsWithOverrides: () => [],
agentDisksWithOverrides: () => [],
dockerHostsWithOverrides: () => [],
guestsFlat: () => [guestResource],
dockerContainersFlat: () => [],
pbsServersWithOverrides: () => [],
pmgServersWithOverrides: () => [],
storageWithOverrides: () => [],
},
editingThresholds: () => ({}),
editingNote: () => '',
bulkEditIds: () => [],
cancelEdit: vi.fn(),
}),
);
result.toggleBackup(guestResource.id, true);
// Zero-valued thresholds inherit the globals at evaluation time. A copy of
// the global 7/14 here would freeze those values into the override (#1126).
const rawEntries = Object.values(rawOverridesConfig());
expect(rawEntries).toHaveLength(1);
expect(rawEntries[0].backup).toEqual({
enabled: true,
warningDays: 0,
criticalDays: 0,
});
});
it('toggleSnapshot preserves explicit override thresholds while flipping enabled', () => {
const overrideSignal = createSignal<any[]>([
{
id: 'cluster-a:node-2:100',
name: 'db-01',
type: 'guest',
thresholds: {},
snapshot: { enabled: false, warningDays: 5, criticalDays: 9 },
},
]);
const { props, rawOverridesConfig } = buildTableProps(overrideSignal);
const guestResource: TableResource = {
id: 'cluster-a:node-2:100',
name: 'db-01',
type: 'guest',
resourceType: 'VM',
vmid: 100,
node: 'node-2',
instance: 'cluster-a',
defaults: { cpu: 80 },
thresholds: { cpu: 80 },
};
const { result } = renderHook(() =>
useThresholdsOverrideMutations({
props,
resources: {
nodesWithOverrides: () => [],
agentsWithOverrides: () => [],
agentDisksWithOverrides: () => [],
dockerHostsWithOverrides: () => [],
guestsFlat: () => [guestResource],
dockerContainersFlat: () => [],
pbsServersWithOverrides: () => [],
pmgServersWithOverrides: () => [],
storageWithOverrides: () => [],
},
editingThresholds: () => ({}),
editingNote: () => '',
bulkEditIds: () => [],
cancelEdit: vi.fn(),
}),
);
result.toggleSnapshot(guestResource.id);
const rawEntries = Object.values(rawOverridesConfig());
expect(rawEntries).toHaveLength(1);
expect(rawEntries[0].snapshot).toEqual({
enabled: true,
warningDays: 5,
criticalDays: 9,
});
});
});

View file

@ -322,7 +322,15 @@ export function useThresholdsOverrideMutations({
const { storageId } = getOverridePersistenceIdentity(resource);
const existingOverride = findOverrideForResource(props.overrides(), resource);
const previousRaw = findRawOverrideConfigForResource(props.rawOverridesConfig(), resource);
const baseConfig = existingOverride?.backup || props.backupDefaults();
// Zero-valued thresholds inherit the global defaults at evaluation time,
// so a toggle-created override stores only the enabled flag. Copying the
// globals here would freeze their current values into the override and
// silently detach the guest from later global edits (#1126).
const baseConfig = existingOverride?.backup ?? {
enabled: props.backupDefaults().enabled,
warningDays: 0,
criticalDays: 0,
};
const newBackup = {
...baseConfig,
enabled: forceState !== undefined ? forceState : !baseConfig.enabled,
@ -361,7 +369,13 @@ export function useThresholdsOverrideMutations({
const { storageId } = getOverridePersistenceIdentity(resource);
const existingOverride = findOverrideForResource(props.overrides(), resource);
const previousRaw = findRawOverrideConfigForResource(props.rawOverridesConfig(), resource);
const baseConfig = existingOverride?.snapshot || props.snapshotDefaults();
// Sparse for the same reason as toggleBackup: zero thresholds inherit the
// globals at evaluation time instead of freezing a copy (#1126).
const baseConfig = existingOverride?.snapshot ?? {
enabled: props.snapshotDefaults().enabled,
warningDays: 0,
criticalDays: 0,
};
const newSnapshot = {
...baseConfig,
enabled: forceState !== undefined ? forceState : !baseConfig.enabled,

View file

@ -19954,3 +19954,211 @@ func TestDockerContainerOverrideLegacyIDKeyStillHonoured(t *testing.T) {
t.Fatalf("expected legacy ID-keyed override to still disable container alerts")
}
}
func backupTestFixtures(now time.Time, ageDays int) (
[]recovery.ProtectionRollup,
map[string]GuestLookup,
map[string][]GuestLookup,
string,
) {
rollups := []recovery.ProtectionRollup{
{
RollupID: "res:vm:proxmox:inst:node:100",
SubjectRef: &recovery.ExternalRef{
Type: "proxmox-vm",
Namespace: "inst",
Name: "app-server",
ID: "inst:node:100",
Class: "node",
},
LastSuccessAt: ptrTime(now.Add(-time.Duration(ageDays) * 24 * time.Hour)),
LastOutcome: recovery.OutcomeSuccess,
Providers: []recovery.Provider{recovery.ProviderProxmoxPVE},
},
}
key := BuildGuestKey("inst", "node", 100)
guestsByKey := map[string]GuestLookup{
key: {
ResourceID: "inst:node:100",
Name: "app-server",
Instance: "inst",
Node: "node",
Type: "qemu",
VMID: 100,
},
}
guestsByVMID := map[string][]GuestLookup{"100": {guestsByKey[key]}}
return rollups, guestsByKey, guestsByVMID, key
}
// A sparse enabled-only override must inherit the global thresholds instead of
// zeroing them out (#1126).
func TestGuestBackupOverrideInheritsGlobalThresholds(t *testing.T) {
m := newTestManager(t)
m.ClearActiveAlerts()
cfg := m.GetConfig()
cfg.Enabled = true
cfg.BackupDefaults = BackupAlertConfig{
Enabled: true,
WarningDays: 32,
CriticalDays: 45,
}
cfg.Overrides = map[string]ThresholdConfig{
"inst:node:100": {Backup: &BackupAlertConfig{Enabled: true}},
}
m.UpdateConfig(cfg)
now := time.Now()
rollups, guestsByKey, guestsByVMID, key := backupTestFixtures(now, 10)
m.CheckBackups(rollups, guestsByKey, guestsByVMID)
m.mu.RLock()
_, exists := m.activeAlerts["backup-age-"+sanitizeAlertKey(key)]
m.mu.RUnlock()
if exists {
t.Fatalf("10-day-old backup alerted despite inherited 32-day warning threshold")
}
rollups, guestsByKey, guestsByVMID, key = backupTestFixtures(now, 33)
m.CheckBackups(rollups, guestsByKey, guestsByVMID)
m.mu.RLock()
alert, exists := testLookupActiveAlert(t, m, "backup-age-"+sanitizeAlertKey(key))
m.mu.RUnlock()
if !exists {
t.Fatalf("expected warning alert at inherited 32-day threshold")
}
if alert.Level != AlertLevelWarning {
t.Fatalf("expected warning alert, got %s", alert.Level)
}
}
// findBackupOverride returns the single backup override in the config,
// regardless of the storage key normalizeOverrides settled on.
func findBackupOverride(t *testing.T, m *Manager) BackupAlertConfig {
t.Helper()
for _, override := range m.GetConfig().Overrides {
if override.Backup != nil {
return *override.Backup
}
}
t.Fatalf("no backup override found in config")
return BackupAlertConfig{}
}
// An override that is an exact threshold copy of the current globals is a
// frozen artifact of the legacy toggle. It must be rewritten to sparse so a
// later global threshold change applies to the guest (#1126).
func TestFrozenBackupOverrideMigratesToSparse(t *testing.T) {
m := newTestManager(t)
m.ClearActiveAlerts()
cfg := m.GetConfig()
cfg.Enabled = true
cfg.BackupDefaults = BackupAlertConfig{
Enabled: true,
WarningDays: 7,
CriticalDays: 14,
FreshHours: 24,
StaleHours: 72,
}
cfg.Overrides = map[string]ThresholdConfig{
"inst:node:100": {
Backup: &BackupAlertConfig{
Enabled: true,
WarningDays: 7,
CriticalDays: 14,
FreshHours: 24,
StaleHours: 72,
},
},
}
m.UpdateConfig(cfg)
migrated := findBackupOverride(t, m)
if !migrated.Enabled {
t.Fatalf("expected enabled sparse backup override, got %+v", migrated)
}
if migrated.WarningDays != 0 || migrated.CriticalDays != 0 {
t.Fatalf("frozen copy not migrated to sparse: %+v", migrated)
}
// Raising the global warning threshold must now take effect for the guest.
cfg = m.GetConfig()
cfg.BackupDefaults.WarningDays = 32
cfg.BackupDefaults.CriticalDays = 45
m.UpdateConfig(cfg)
rollups, guestsByKey, guestsByVMID, key := backupTestFixtures(time.Now(), 10)
m.CheckBackups(rollups, guestsByKey, guestsByVMID)
m.mu.RLock()
_, exists := m.activeAlerts["backup-age-"+sanitizeAlertKey(key)]
m.mu.RUnlock()
if exists {
t.Fatalf("10-day-old backup alerted at stale frozen 7-day threshold after global raised to 32")
}
}
// Overrides whose thresholds differ from the globals are deliberate and must
// survive migration and win at evaluation.
func TestDeliberateBackupOverridePreserved(t *testing.T) {
m := newTestManager(t)
m.ClearActiveAlerts()
cfg := m.GetConfig()
cfg.Enabled = true
cfg.BackupDefaults = BackupAlertConfig{
Enabled: true,
WarningDays: 32,
CriticalDays: 45,
}
cfg.Overrides = map[string]ThresholdConfig{
"inst:node:100": {
Backup: &BackupAlertConfig{
Enabled: true,
WarningDays: 7,
CriticalDays: 10,
},
},
}
m.UpdateConfig(cfg)
kept := findBackupOverride(t, m)
if kept.WarningDays != 7 || kept.CriticalDays != 10 {
t.Fatalf("deliberate override rewritten: %+v", kept)
}
rollups, guestsByKey, guestsByVMID, key := backupTestFixtures(time.Now(), 11)
m.CheckBackups(rollups, guestsByKey, guestsByVMID)
m.mu.RLock()
alert, exists := testLookupActiveAlert(t, m, "backup-age-"+sanitizeAlertKey(key))
m.mu.RUnlock()
if !exists {
t.Fatalf("expected critical alert at deliberate 10-day override threshold")
}
if alert.Level != AlertLevelCritical {
t.Fatalf("expected critical alert, got %s", alert.Level)
}
}
func TestMergeSnapshotOverrideInheritsZeroFields(t *testing.T) {
defaults := SnapshotAlertConfig{
Enabled: true,
WarningDays: 20,
CriticalDays: 30,
WarningSizeGiB: 50,
CriticalSizeGiB: 100,
}
merged := mergeSnapshotOverride(defaults, SnapshotAlertConfig{Enabled: true})
if merged.WarningDays != 20 || merged.CriticalDays != 30 {
t.Fatalf("sparse snapshot override did not inherit day thresholds: %+v", merged)
}
if merged.WarningSizeGiB != 50 || merged.CriticalSizeGiB != 100 {
t.Fatalf("sparse snapshot override did not inherit size thresholds: %+v", merged)
}
explicit := mergeSnapshotOverride(defaults, SnapshotAlertConfig{Enabled: true, WarningDays: 5})
if explicit.WarningDays != 5 || explicit.CriticalDays != 30 {
t.Fatalf("explicit snapshot override field lost: %+v", explicit)
}
}

View file

@ -38,10 +38,53 @@ func backupIgnoreVMID(vmID string, ignoreList []string) bool {
return false
}
// mergeSnapshotOverride resolves a per-guest snapshot override against the
// global defaults. Overrides written by the per-guest toggle carry only the
// enabled flag, so zero-valued thresholds inherit the global value; explicit
// non-zero values win (#1126).
func mergeSnapshotOverride(defaults SnapshotAlertConfig, override SnapshotAlertConfig) SnapshotAlertConfig {
merged := override
if merged.WarningDays <= 0 {
merged.WarningDays = defaults.WarningDays
}
if merged.CriticalDays <= 0 {
merged.CriticalDays = defaults.CriticalDays
}
if merged.WarningSizeGiB <= 0 {
merged.WarningSizeGiB = defaults.WarningSizeGiB
}
if merged.CriticalSizeGiB <= 0 {
merged.CriticalSizeGiB = defaults.CriticalSizeGiB
}
return merged
}
// mergeBackupOverride is the backup counterpart of mergeSnapshotOverride.
// AlertOrphaned and IgnoreVMIDs are instance-wide filters, so the globals
// always win for those regardless of what a stale override carries.
func mergeBackupOverride(defaults BackupAlertConfig, override BackupAlertConfig) BackupAlertConfig {
merged := override
if merged.WarningDays <= 0 {
merged.WarningDays = defaults.WarningDays
}
if merged.CriticalDays <= 0 {
merged.CriticalDays = defaults.CriticalDays
}
if merged.FreshHours <= 0 {
merged.FreshHours = defaults.FreshHours
}
if merged.StaleHours <= 0 {
merged.StaleHours = defaults.StaleHours
}
merged.AlertOrphaned = defaults.AlertOrphaned
merged.IgnoreVMIDs = defaults.IgnoreVMIDs
return merged
}
func (m *Manager) resolvedSnapshotAlertConfigNoLock(thresholds ThresholdConfig) SnapshotAlertConfig {
cfg := m.config.SnapshotDefaults
if thresholds.Snapshot != nil {
cfg = *thresholds.Snapshot
cfg = mergeSnapshotOverride(cfg, *thresholds.Snapshot)
}
return cfg
}
@ -49,7 +92,7 @@ func (m *Manager) resolvedSnapshotAlertConfigNoLock(thresholds ThresholdConfig)
func (m *Manager) resolvedBackupAlertConfigNoLock(thresholds ThresholdConfig) BackupAlertConfig {
cfg := m.config.BackupDefaults
if thresholds.Backup != nil {
cfg = *thresholds.Backup
cfg = mergeBackupOverride(cfg, *thresholds.Backup)
}
if cfg.AlertOrphaned == nil {
alertOrphaned := true
@ -355,7 +398,7 @@ func (m *Manager) CheckSnapshotsForInstance(instanceName string, snapshots []mod
currentSnapshotCfg := snapshotCfg
if gh.Snapshot != nil {
currentSnapshotCfg = *gh.Snapshot
currentSnapshotCfg = mergeSnapshotOverride(snapshotCfg, *gh.Snapshot)
}
if !currentSnapshotCfg.Enabled {
@ -799,7 +842,7 @@ func (m *Manager) CheckBackupsWithInventory(
continue
}
if gh.Backup != nil {
currentBackupCfg = *gh.Backup
currentBackupCfg = mergeBackupOverride(backupCfg, *gh.Backup)
}
}

View file

@ -211,6 +211,42 @@ func NormalizeSnapshotDefaults(config *AlertConfig) {
}
}
// NormalizeRecoveryOverrides rewrites per-guest backup/snapshot overrides whose
// threshold values are exact copies of the current global defaults into sparse
// overrides that carry only the enabled flag. The legacy per-guest toggle
// persisted a full copy of the globals just to flip enabled, freezing the
// threshold values so later global edits silently stopped applying to those
// guests (#1126). Zero-valued fields inherit the global default at evaluation
// time, so the rewrite is behavior-preserving at the moment it runs and lets
// the override track future global changes. Overrides whose thresholds differ
// from the current globals are deliberate per-guest values and are left alone.
func NormalizeRecoveryOverrides(config *AlertConfig) {
for id, override := range config.Overrides {
changed := false
if b := override.Backup; b != nil {
if b.WarningDays == config.BackupDefaults.WarningDays &&
b.CriticalDays == config.BackupDefaults.CriticalDays &&
b.FreshHours == config.BackupDefaults.FreshHours &&
b.StaleHours == config.BackupDefaults.StaleHours {
override.Backup = &BackupAlertConfig{Enabled: b.Enabled}
changed = true
}
}
if s := override.Snapshot; s != nil {
if s.WarningDays == config.SnapshotDefaults.WarningDays &&
s.CriticalDays == config.SnapshotDefaults.CriticalDays &&
s.WarningSizeGiB == config.SnapshotDefaults.WarningSizeGiB &&
s.CriticalSizeGiB == config.SnapshotDefaults.CriticalSizeGiB {
override.Snapshot = &SnapshotAlertConfig{Enabled: s.Enabled}
changed = true
}
}
if changed {
config.Overrides[id] = override
}
}
}
func NormalizeBackupDefaults(config *AlertConfig) {
if config.BackupDefaults.WarningDays < 0 {
config.BackupDefaults.WarningDays = 0

View file

@ -97,4 +97,11 @@ func ensureValidHysteresis(threshold *HysteresisThreshold, metricName string) {
func normalizeSnapshotDefaults(config *AlertConfig) { alertconfig.NormalizeSnapshotDefaults(config) }
func normalizeBackupDefaults(config *AlertConfig) { alertconfig.NormalizeBackupDefaults(config) }
// NormalizeRecoveryOverrides converts per-guest backup/snapshot overrides that
// are exact threshold copies of the current global defaults into sparse
// enabled-only overrides so they track future global edits (#1126).
func NormalizeRecoveryOverrides(config *AlertConfig) {
alertconfig.NormalizeRecoveryOverrides(config)
}
func validateQuietHoursTimezone(config *AlertConfig) { alertconfig.ValidateQuietHoursTimezone(config) }

View file

@ -31,6 +31,7 @@ func (m *Manager) UpdateConfig(config AlertConfig) {
alertconfig.NormalizePBSDefaults(&config)
alertconfig.NormalizeSnapshotDefaults(&config)
alertconfig.NormalizeBackupDefaults(&config)
alertconfig.NormalizeRecoveryOverrides(&config)
alertconfig.NormalizeNodeDefaults(&config)
alertconfig.NormalizeAgentDefaults(&config)
alertconfig.NormalizeKubernetesDefaults(&config)

View file

@ -912,6 +912,8 @@ func normalizeAlertDefaults(config *alerts.AlertConfig) {
}
config.DockerIgnoredContainerPrefixes = alerts.NormalizeDockerIgnoredPrefixes(config.DockerIgnoredContainerPrefixes)
alerts.NormalizeRecoveryOverrides(config)
}
// SaveAlertConfig saves alert configuration to file