diff --git a/docs/ALERT_ENGINE_EVOLUTION.md b/docs/ALERT_ENGINE_EVOLUTION.md
index db42207ca..f89319982 100644
--- a/docs/ALERT_ENGINE_EVOLUTION.md
+++ b/docs/ALERT_ENGINE_EVOLUTION.md
@@ -152,7 +152,13 @@ Post-plan hardening (2026-08-27, same-day follow-through):
included — records its fired event. The JSON snapshot history file is
retired. Parity with the JSON model is pinned by
`history_projection_parity_test.go`.
-- **The identity/config migration is registered, not rushed:** governed
- coverage gap `alert-identity-config-migration` records why the alias
- layers persist until a versioned migration rewrites persisted
- override keys and ack records to canonical identity.
+- **The identity/config migration is complete.** Alert configuration now
+ carries an additive identity schema version and the monitor runs a
+ non-mutating, fail-closed migration plan against the live resource registry
+ before persisting it. Proven guest, guest-disk, storage, Docker, and
+ provider-declared succession keys move to their single write identity;
+ ambiguous, conflicting, unknown, and temporarily absent rows are retained.
+ Active-alert snapshots (including acknowledgement fields) are rewritten with
+ canonical state IDs after restore. The planner is idempotent across rollback
+ and re-upgrade, rejects future schema versions, and the frontend round-trips
+ the marker on ordinary configuration saves.
diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json
index f475e26a8..6b65c27e5 100644
--- a/docs/release-control/v6/internal/status.json
+++ b/docs/release-control/v6/internal/status.json
@@ -9763,39 +9763,6 @@
"kind": "file"
}
]
- },
- {
- "id": "alert-identity-config-migration",
- "summary": "Persisted alert state (threshold override keys, ack records, active-alert IDs) still carries legacy identity forms bridged at read time by alias layers (thresholdOverrideForResourceNoLock registry translation, lookupGuestOverride's three-key fallback, storageThresholdLookupIDs, testAlertEquivalentIDs' ~8 equivalent forms). The Phase 3 policy fold contains the bridging behind one resolution surface, but the alias code must be maintained until an explicit, versioned config migration rewrites persisted keys to canonical identity. Deliberately deferred from the 2026-08 evolution work: rewriting user override keys requires the live resource registry for translation and carries the #1738 failure mode in reverse (silently orphaning overrides), so it needs its own migration design, dry-run tooling, and test round rather than landing mid-RC.",
- "owner": "project-owner",
- "status": "triaged",
- "recorded_at": "2026-08-27",
- "lane_ids": [
- "L6",
- "L13"
- ],
- "subsystem_ids": [
- "alerts"
- ],
- "proposed_resolution": "lane-expansion",
- "coverage_impact": 3,
- "evidence": [
- {
- "repo": "pulse",
- "path": "docs/ALERT_ENGINE_EVOLUTION.md",
- "kind": "file"
- },
- {
- "repo": "pulse",
- "path": "internal/alerts/alert_policy.go",
- "kind": "file"
- },
- {
- "repo": "pulse",
- "path": "internal/alerts/guest_override_identity.go",
- "kind": "file"
- }
- ]
}
],
"candidate_lanes": [],
diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md
index 9f3ac9893..4ceccdf18 100644
--- a/docs/release-control/v6/internal/subsystems/alerts.md
+++ b/docs/release-control/v6/internal/subsystems/alerts.md
@@ -1821,8 +1821,11 @@ active-alert persistence. Shared canonical identity helpers may infer resource,
spec, and state ids from legacy alerts, but they must do so without mutating
live in-memory alert instances unless the caller explicitly backfills that
state. Persisted active-alert snapshots must therefore clone alerts under lock,
-backfill canonical identity on the clone, and serialize that snapshot instead
-of mutating the live alert map during async saves or incident rebuilds.
+backfill canonical identity on the clone, replace the persisted public ID with
+the canonical state ID, and serialize that snapshot instead of mutating the
+live alert map during async saves or incident rebuilds. Restore must preserve
+acknowledgement fields and rewrite a successfully derived legacy snapshot
+atomically after the in-memory restore; unrecognized records remain readable.
That same ownership also governs acknowledgement and manual-clear cleanup.
Clearing an alert through the canonical alerts runtime must remove both legacy
public-id tracking and canonical-state acknowledgement records so old aliases
@@ -1872,6 +1875,36 @@ reload behavior, and
`frontend-modern/src/features/alerts/thresholds/hooks/__tests__/truenasThresholdPersistence.test.tsx`
proves the browser-side TrueNAS save/refetch contract.
+### Versioned persisted alert identity
+
+`AlertConfig.identitySchemaVersion` is the additive persisted identity marker;
+the current schema is version 1. `PlanAlertIdentityMigration` is the mandatory
+dry-run boundary: it operates on a copied config and reports source/target
+versions, removed and added keys, deferred rows, and unsupported future
+versions before any write. `ApplyAlertIdentityMigration` may install that exact
+plan only while its source version is still current. The monitoring bridge must
+persist the result before updating the live manager.
+
+Version 1 folds provider-declared canonical succession, Docker container
+recreation IDs, Proxmox guest and guest-disk aliases, and live storage aliases
+onto the single write identity already used by the editor/evaluator. A current
+row wins over retired duplicates. Multiple legacy rows claiming one target,
+ambiguous live ownership, unknown rows, and temporarily absent resources fail
+closed and stay in `alerts.json` for a later resource snapshot that can prove
+the mapping. Alias readers remain compatibility inputs for those deferred rows,
+not persistence authority. Storage alias provenance is carried on the unified
+resource `StorageMeta` so the plan does not reconstruct ownership from display
+names alone.
+
+The marker is rollback-safe: older binaries ignore the additive JSON field and
+may omit it on a later save; after re-upgrade the idempotent planner runs again.
+Newer binaries preserve an already-held marker when a stale browser payload
+omits it, while the alert editor explicitly round-trips the marker. A config
+whose marker is newer than the running binary is never rewritten. Regression
+ownership is `internal/alerts/alert_identity_migration_test.go`,
+`internal/monitoring/monitor_alert_override_migration_test.go`, and
+`frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.snapshot.test.ts`.
+
### Versioned alert-intent policy
The alerts runtime owns one versioned alert-intent document and its durable
diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 09f2f617e..2898035b5 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -4603,6 +4603,13 @@ visibility preference decides whether they are shown. A feature must not build
counts from page-wide alert totals after search or another active facet has
narrowed the rendered rows, and a time-scope option must not claim a count from
an unfetched period.
+The Alert History frequency axis is also a responsive contract. Desktop keeps
+the complete model-owned tick set; below the `sm` breakpoint the render surface
+keeps only start, midpoint, and end labels so locale-formatted timestamps do
+not overlap or create horizontal overflow. The 390px operator qualification
+must assert three visible labels, non-overlapping client rectangles, keyboard
+reachability, and a contained document before its actual-pixels receipt is
+recorded.
Because that popover combines view application, default selection, removal,
and an inline naming form, it is a labelled non-modal dialog rather than an
ARIA menu. Its trigger exposes the dialog relationship, Escape returns focus
diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md
index 95a4dee30..0dd14d1a0 100644
--- a/docs/release-control/v6/internal/subsystems/monitoring.md
+++ b/docs/release-control/v6/internal/subsystems/monitoring.md
@@ -19,6 +19,12 @@
Own polling, typed collection, runtime state assembly, and canonical monitoring
truth for live infrastructure data.
+Monitoring supplies the live unified-resource snapshot used by the alerts-owned
+versioned identity migration. The migration must be planned without mutating
+the active configuration, persist successfully before the alert manager adopts
+it, leave ambiguous identities untouched, and reject schema versions newer than
+the running binary. Monitoring owns this orchestration only; alert identity,
+override semantics, and the schema version remain alerts authority.
Agent Doctor interprets Proxmox capability profiles according to the monitored
product. Only PVE host profiles require a link to a PVE node; an explicit PBS
profile, or an auto/missing-type host that matches a configured PBS instance by
diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md
index 2997e1c12..10c5fab28 100644
--- a/docs/release-control/v6/internal/subsystems/storage-recovery.md
+++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md
@@ -35,6 +35,12 @@ Own the storage and recovery product surfaces, recovery-point persistence and
querying, and the operator-facing storage health presentation layer while
keeping adjacent commercial reporting APIs out of storage/recovery product
state.
+Unified storage metadata may carry source alias IDs so the alerts subsystem can
+rewrite legacy threshold keys to the current metrics-target identity. Those
+aliases are compatibility evidence only: they do not retarget a datastore,
+backup artifact, recovery point, protection posture, or restore operation, and
+storage/recovery consumers must continue to use the canonical resource and
+provider identities for all domain authority.
The Proxmox overview is also a large-estate read-side consumer: it owns one
canonical unified-resource snapshot for its node and guest regions, and its
shared workloads adapter must consume that snapshot without starting a second
diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md
index d2b45c0f0..1563ec772 100644
--- a/docs/release-control/v6/internal/subsystems/unified-resources.md
+++ b/docs/release-control/v6/internal/subsystems/unified-resources.md
@@ -17,6 +17,11 @@
Own canonical resource identity, type normalization, typed views, and
cross-source deduplication.
+Storage metadata may expose source-authored alias IDs as compatibility evidence
+for one-time consumers such as the alerts identity migration. Adapters and
+clones must preserve that bounded alias set without promoting it to primary
+identity: `Resource.ID`, canonical identity, and the storage metrics target
+remain authoritative, and ambiguous alias claims must fail closed.
One machine may belong to more than one typed view without becoming more than
one canonical resource. A matching Pulse host report and Docker report merge
into one `agent` resource with Agent and Docker facets, and the Hosts and
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index cc7314f00..aca1a6c77 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -1,19 +1,27 @@
{
"version": 1,
- "base_sha": "96add5d57a2b703e756eb7157b4faa2f7db37c55",
- "verified_at": "2026-08-27T10:04:09Z",
+ "base_sha": "ff27c3881f0c418de856b26175cb2fcd96a962ec",
+ "verified_at": "2026-08-27T10:55:17Z",
"result": "passed",
"changed_paths": [
- "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx"
+ "frontend-modern/src/features/alerts/AlertHistoryFrequencyCard.tsx",
+ "frontend-modern/src/features/alerts/alertsConfigurationModel.ts",
+ "frontend-modern/src/features/alerts/useAlertsConfigurationSnapshotState.ts",
+ "frontend-modern/src/types/alerts.ts",
+ "frontend-modern/src/types/resource.ts"
],
"content_sha256": {
- "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "8fb34851a8bcc32c67a4f4f156fdc32f62236a0fa8c5e2aa8b3d4dfcc8ecd47f"
+ "frontend-modern/src/features/alerts/AlertHistoryFrequencyCard.tsx": "7794ba25adfb94f5bf11a718ad774ea717dc7e8b543572ddfb461465c0593515",
+ "frontend-modern/src/features/alerts/alertsConfigurationModel.ts": "d295586ac8044fda4930ee56a9ea6b1ca2bfb27792ce594f4fb9d5fc0cbf1243",
+ "frontend-modern/src/features/alerts/useAlertsConfigurationSnapshotState.ts": "3cd18ea4cf402a5a5594e63eec7bb98bc0dbad8031c8b610ee9c10f3d2caed98",
+ "frontend-modern/src/types/alerts.ts": "5d57daaa49c83caa5ce40217b9bca1daf17867743d311d472724d6d5210349b1",
+ "frontend-modern/src/types/resource.ts": "b1c07de178e5b8a6725bb555031af8df0abf9a731dcf84e7791c70d95b399053"
},
- "routes": ["/proxmox/backups/date"],
+ "routes": ["/alerts/history"],
"viewports": [
{
- "width": 1280,
- "height": 720
+ "width": 1440,
+ "height": 1000
},
{
"width": 390,
@@ -21,16 +29,15 @@
}
],
"states": [
- "mock-mode Proxmox Backups loaded with backup-vault offsite-vault, backup-vault primary-vault, and dr-vault primary-vault rows in deterministic server and datastore order",
- "the backup-vault primary-vault row expanded into the canonical resource detail drawer",
- "the primary-vault row remained expanded after multiple two-second mock provider refreshes while row order remained unchanged",
- "the same deterministic order and expanded primary-vault row rendered in the compact phone table"
+ "live development History tab loaded with the seven-day alert-frequency chart and 114 history rows",
+ "desktop chart rendered all five time-axis labels without overlap or horizontal page overflow",
+ "phone chart rendered the start, midpoint, and Now labels without overlap or horizontal page overflow",
+ "phone history cards remained contained and readable at 390px width"
],
"interactions": [
- "expanded the backup-vault primary-vault row and confirmed the matching detail drawer appeared",
- "waited across multiple live mock refresh intervals and confirmed offsite-vault remained before primary-vault and the expanded row did not reset",
- "resized from 1280x720 to 390x844 and confirmed the row stayed expanded with compact Server, State, Store, Used, and Backups columns",
- "confirmed document and body scroll widths matched both viewports with no horizontal page overflow",
+ "opened the History tab against the running development backend and inspected the rendered alert-frequency chart",
+ "measured every visible desktop and phone axis-label bounding box and confirmed no adjacent boxes overlapped",
+ "resized from 1440x1000 to 390x844 and confirmed document scroll width continued to match the viewport",
"confirmed the browser console contained no errors during the final desktop and phone verification flow"
]
}
diff --git a/frontend-modern/src/features/alerts/AlertHistoryFrequencyCard.tsx b/frontend-modern/src/features/alerts/AlertHistoryFrequencyCard.tsx
index 97cdf057b..04d888b75 100644
--- a/frontend-modern/src/features/alerts/AlertHistoryFrequencyCard.tsx
+++ b/frontend-modern/src/features/alerts/AlertHistoryFrequencyCard.tsx
@@ -174,23 +174,36 @@ export function AlertHistoryFrequencyCard(props: AlertHistoryFrequencyCardProps)
- {(tick) => (
-
-
+ {(tick, index) => {
+ const lastIndex = props.state.axisTicks().length - 1;
+ const mobileMidpoint = Math.floor(lastIndex / 2);
+ const visibleOnMobile =
+ index() === 0 || index() === mobileMidpoint || index() === lastIndex;
+
+ return (
- {tick.label}
+
+
+ {tick.label}
+
-
- )}
+ );
+ }}
diff --git a/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.snapshot.test.ts b/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.snapshot.test.ts
index 69f87fd49..9ae659c7a 100644
--- a/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.snapshot.test.ts
+++ b/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.snapshot.test.ts
@@ -31,6 +31,10 @@ const UNDEF_TRIGGER = { trigger: undefined };
describe('readAlertsConfigurationSnapshot — absent optional sections', () => {
const snapshot = readAlertsConfigurationSnapshot(cfg({}));
+ it('defaults the persisted identity schema marker to zero', () => {
+ expect(snapshot.identitySchemaVersion).toBe(0);
+ });
+
it('keeps factory guestDefaults / nodeDefaults / agentDefaults', () => {
expect(snapshot.guestDefaults.cpu).toBe(80);
expect(snapshot.guestDefaults.memory).toBe(85);
@@ -1334,6 +1338,19 @@ describe('buildAlertsConfigurationPayload — additional branches', () => {
expect(alertConfig?.observationWindowHours).toBe(48);
});
+ it('round-trips the persisted identity schema marker', () => {
+ const snapshot = readAlertsConfigurationSnapshot(cfg({ identitySchemaVersion: 1 }));
+
+ const { alertConfig } = buildAlertsConfigurationPayload({
+ snapshot,
+ rawOverridesConfig: {},
+ alertsActivationState: null,
+ alertsActivationConfig: null,
+ });
+
+ expect(alertConfig?.identitySchemaVersion).toBe(1);
+ });
+
it('falls back to 24/72/true for null freshHours/staleHours/alertOrphaned', () => {
const snapshot = createDefaultAlertsConfigurationSnapshot();
snapshot.backupDefaults = {
diff --git a/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.test.ts b/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.test.ts
index 2dfbde75f..99e590af8 100644
--- a/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.test.ts
+++ b/frontend-modern/src/features/alerts/__tests__/alertsConfigurationModel.test.ts
@@ -16,6 +16,7 @@ describe('alertsConfigurationModel', () => {
it('creates the canonical default snapshot', () => {
const snapshot = createDefaultAlertsConfigurationSnapshot();
+ expect(snapshot.identitySchemaVersion).toBe(0);
expect(snapshot.guestDefaults.cpu).toBe(FACTORY_GUEST_DEFAULTS.cpu);
expect(snapshot.dockerDefaults.serviceWarnGapPercent).toBe(
FACTORY_DOCKER_DEFAULTS.serviceWarnGapPercent,
@@ -31,6 +32,7 @@ describe('alertsConfigurationModel', () => {
it('normalizes alert config into the runtime snapshot', () => {
const config = {
+ identitySchemaVersion: 1,
enabled: true,
guestDefaults: {
cpu: { trigger: 91, clear: 86 },
@@ -80,6 +82,7 @@ describe('alertsConfigurationModel', () => {
const snapshot = readAlertsConfigurationSnapshot(config);
+ expect(snapshot.identitySchemaVersion).toBe(1);
expect(snapshot.guestDefaults.cpu).toBe(91);
expect(snapshot.guestDisableConnectivity).toBe(true);
expect(snapshot.guestPoweredOffSeverity).toBe('critical');
@@ -108,6 +111,7 @@ describe('alertsConfigurationModel', () => {
it('builds the canonical save payload from the runtime snapshot', () => {
const snapshot = createDefaultAlertsConfigurationSnapshot();
+ snapshot.identitySchemaVersion = 1;
snapshot.guestDefaults.cpu = 92;
snapshot.dockerIgnoredPrefixes = [' web ', ''];
snapshot.guestTagWhitelist = [' prod ', ''];
@@ -149,6 +153,7 @@ describe('alertsConfigurationModel', () => {
expect(result.dockerValidationError).toBeUndefined();
expect(result.alertConfig).toBeDefined();
+ expect(result.alertConfig?.identitySchemaVersion).toBe(1);
expect(result.alertConfig?.guestDefaults.cpu).toEqual({ trigger: 92, clear: 87 });
expect(result.alertConfig?.vmwareDefaults?.cpu).toEqual({ trigger: 80, clear: 75 });
expect(result.alertConfig?.vmwareDefaults?.usage).toEqual({ trigger: 85, clear: 80 });
diff --git a/frontend-modern/src/features/alerts/alertsConfigurationModel.ts b/frontend-modern/src/features/alerts/alertsConfigurationModel.ts
index 3f141903c..052e025af 100644
--- a/frontend-modern/src/features/alerts/alertsConfigurationModel.ts
+++ b/frontend-modern/src/features/alerts/alertsConfigurationModel.ts
@@ -64,6 +64,7 @@ export {
} from '@/utils/alertThresholdDefaults';
export interface AlertsConfigurationSnapshot {
+ identitySchemaVersion: number;
scheduleQuietHours: QuietHoursConfig;
scheduleCooldown: CooldownConfig;
scheduleGrouping: GroupingConfig;
@@ -238,6 +239,7 @@ const normalizeNotificationDeliveryTarget = (
export function createDefaultAlertsConfigurationSnapshot(): AlertsConfigurationSnapshot {
return {
+ identitySchemaVersion: 0,
scheduleQuietHours: createDefaultQuietHours(),
scheduleCooldown: createDefaultCooldown(),
scheduleGrouping: createDefaultGrouping(),
@@ -329,6 +331,8 @@ export function createDefaultAlertsConfigurationSnapshot(): AlertsConfigurationS
export function readAlertsConfigurationSnapshot(config: AlertConfig): AlertsConfigurationSnapshot {
const snapshot = createDefaultAlertsConfigurationSnapshot();
+ snapshot.identitySchemaVersion = Math.max(0, Math.trunc(config.identitySchemaVersion ?? 0));
+
if (config.guestDefaults) {
snapshot.guestDefaults = {
cpu: getTriggerValue(config.guestDefaults.cpu) ?? FACTORY_GUEST_DEFAULTS.cpu,
@@ -721,6 +725,7 @@ export function buildAlertsConfigurationPayload({
return {
alertConfig: {
+ identitySchemaVersion: snapshot.identitySchemaVersion || undefined,
enabled: alertsActivationConfig?.enabled ?? true,
activationState: alertsActivationState ?? undefined,
activationTime: alertsActivationConfig?.activationTime ?? undefined,
diff --git a/frontend-modern/src/features/alerts/useAlertsConfigurationSnapshotState.ts b/frontend-modern/src/features/alerts/useAlertsConfigurationSnapshotState.ts
index 44c4e6bb5..7a2adebdc 100644
--- a/frontend-modern/src/features/alerts/useAlertsConfigurationSnapshotState.ts
+++ b/frontend-modern/src/features/alerts/useAlertsConfigurationSnapshotState.ts
@@ -37,6 +37,9 @@ export function useAlertsConfigurationSnapshotState(
props: UseAlertsConfigurationSnapshotStateProps,
) {
const defaultSnapshot = createDefaultAlertsConfigurationSnapshot();
+ const [identitySchemaVersion, setIdentitySchemaVersion] = createSignal(
+ defaultSnapshot.identitySchemaVersion,
+ );
const [scheduleQuietHours, setScheduleQuietHours] = createSignal(
defaultSnapshot.scheduleQuietHours,
);
@@ -163,6 +166,7 @@ export function useAlertsConfigurationSnapshotState(
);
const applyConfigurationSnapshot = (snapshot: AlertsConfigurationSnapshot) => {
+ setIdentitySchemaVersion(snapshot.identitySchemaVersion);
setScheduleQuietHours({
...snapshot.scheduleQuietHours,
days: { ...snapshot.scheduleQuietHours.days },
@@ -225,6 +229,7 @@ export function useAlertsConfigurationSnapshotState(
};
const captureConfigurationSnapshot = (): AlertsConfigurationSnapshot => ({
+ identitySchemaVersion: identitySchemaVersion(),
scheduleQuietHours: {
...scheduleQuietHours(),
days: { ...scheduleQuietHours().days },
diff --git a/frontend-modern/src/types/__tests__/resource.test.ts b/frontend-modern/src/types/__tests__/resource.test.ts
index 0774bccc3..65cf2d5e4 100644
--- a/frontend-modern/src/types/__tests__/resource.test.ts
+++ b/frontend-modern/src/types/__tests__/resource.test.ts
@@ -44,6 +44,19 @@ function createResource(overrides: Partial = {}): Resource {
}
describe('Resource Type Guards', () => {
+ it('preserves storage alias IDs as compatibility metadata', () => {
+ const resource = createResource({
+ type: 'storage',
+ storage: {
+ aliasIds: ['legacy-storage-id'],
+ shared: false,
+ },
+ });
+
+ expect(resource.storage?.aliasIds).toEqual(['legacy-storage-id']);
+ expect(resource.id).toBe('test-1');
+ });
+
it('retains three-state UDP availability evidence on canonical resources', () => {
const resource = createResource({
type: 'network-endpoint',
diff --git a/frontend-modern/src/types/alerts.ts b/frontend-modern/src/types/alerts.ts
index 519da5dd3..c6ebd4219 100644
--- a/frontend-modern/src/types/alerts.ts
+++ b/frontend-modern/src/types/alerts.ts
@@ -118,6 +118,7 @@ export interface BackupAlertConfig {
export type ActivationState = 'pending_review' | 'active' | 'snoozed';
export interface AlertConfig {
+ identitySchemaVersion?: number;
enabled: boolean;
activationState?: ActivationState;
observationWindowHours?: number;
diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts
index 73a6c24f0..b8d0df99b 100644
--- a/frontend-modern/src/types/resource.ts
+++ b/frontend-modern/src/types/resource.ts
@@ -397,6 +397,7 @@ export interface ResourceStorageMeta {
type?: string;
content?: string;
contentTypes?: string[];
+ aliasIds?: string[];
shared?: boolean;
isCeph?: boolean;
isZfs?: boolean;
diff --git a/internal/alerts/active_persistence.go b/internal/alerts/active_persistence.go
index 3c87290d9..183aabae2 100644
--- a/internal/alerts/active_persistence.go
+++ b/internal/alerts/active_persistence.go
@@ -57,6 +57,7 @@ func (m *Manager) SaveActiveAlerts() error {
}
clone := alert.Clone()
backfillCanonicalIdentity(clone)
+ clone.ID = exportedAlertID(clone, clone.ID)
alerts = append(alerts, clone)
}
m.mu.RUnlock()
@@ -151,10 +152,13 @@ func (m *Manager) LoadActiveAlerts() error {
now := time.Now()
restoredCount := 0
duplicateCount := 0
+ identityMigrationCount := 0
seen := make(map[string]bool)
for _, alert := range alerts {
- backfillCanonicalIdentity(alert)
+ if alert == nil {
+ continue
+ }
// Migrate legacy guest alert IDs (instance-node-VMID -> instance-VMID).
isGuestAlert := strings.Contains(alert.Type, "cpu") || strings.Contains(alert.Type, "memory") ||
@@ -184,11 +188,25 @@ func (m *Manager) LoadActiveAlerts() error {
oldResourceID := alert.ResourceID
alert.ResourceID = newResourceID
alert.ID = strings.Replace(alert.ID, oldResourceID, newResourceID, 1)
+ alert.CanonicalSpecID = ""
+ alert.CanonicalState = ""
+ alert.CanonicalKind = ""
+ if alert.Metadata != nil {
+ delete(alert.Metadata, "canonicalSpecID")
+ delete(alert.Metadata, "canonicalAlertKind")
+ }
}
}
}
}
+ legacyID := alert.ID
+ backfillCanonicalIdentity(alert)
+ alert.ID = exportedAlertID(alert, alert.ID)
+ if alert.ID != legacyID {
+ identityMigrationCount++
+ }
+
if seen[alert.ID] {
duplicateCount++
log.Warn().Str("alertID", alert.ID).Msg("skipping duplicate alert during restore")
@@ -216,6 +234,12 @@ func (m *Manager) LoadActiveAlerts() error {
}
m.setActiveAlertNoLock(alert.ID, alert)
+ if legacyID != alert.ID {
+ if m.activeAlertAlias == nil {
+ m.activeAlertAlias = make(map[string]string)
+ }
+ m.activeAlertAlias[legacyID] = activeAlertStorageKey(alert, alert.ID)
+ }
if alert.Acknowledged {
ackTime := alert.StartTime
if alert.AckTime != nil {
@@ -271,7 +295,13 @@ func (m *Manager) LoadActiveAlerts() error {
Int("restored", restoredCount).
Int("total", len(alerts)).
Int("duplicates", duplicateCount).
+ Int("identityMigrations", identityMigrationCount).
Msg("Restored active alerts from disk")
+ if identityMigrationCount > 0 {
+ // The asynchronous save waits for the load lock, then atomically replaces
+ // the legacy file with the canonical IDs just restored into memory.
+ m.saveActiveAlertsAsync("canonical-identity-migration")
+ }
return nil
}
diff --git a/internal/alerts/alert_identity_migration.go b/internal/alerts/alert_identity_migration.go
new file mode 100644
index 000000000..5314f754e
--- /dev/null
+++ b/internal/alerts/alert_identity_migration.go
@@ -0,0 +1,276 @@
+package alerts
+
+import (
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
+)
+
+// CurrentAlertIdentitySchemaVersion is the persisted alert configuration
+// identity schema understood by this release.
+const CurrentAlertIdentitySchemaVersion = 1
+
+// AlertIdentityMigrationDeferred describes an override which the dry-run
+// planner deliberately refused to move. The source row remains untouched.
+type AlertIdentityMigrationDeferred struct {
+ Key string `json:"key"`
+ Reason string `json:"reason"`
+}
+
+// AlertIdentityMigrationPlan is a non-mutating preview of the versioned
+// identity migration. ApplyAlertIdentityMigration applies the exact planned
+// override snapshot only if the source schema version is still current.
+type AlertIdentityMigrationPlan struct {
+ FromVersion int `json:"fromVersion"`
+ ToVersion int `json:"toVersion"`
+ RemovedOverrideKeys []string `json:"removedOverrideKeys,omitempty"`
+ AddedOverrideKeys []string `json:"addedOverrideKeys,omitempty"`
+ Deferred []AlertIdentityMigrationDeferred `json:"deferred,omitempty"`
+ UnsupportedVersion bool `json:"unsupportedVersion,omitempty"`
+
+ plannedOverrides map[string]ThresholdConfig
+}
+
+// Changed reports whether applying the plan would change persisted config.
+func (p AlertIdentityMigrationPlan) Changed() bool {
+ return !p.UnsupportedVersion && (p.FromVersion != p.ToVersion || len(p.RemovedOverrideKeys) > 0 || len(p.AddedOverrideKeys) > 0)
+}
+
+// PlanAlertIdentityMigration builds a dry-run migration from the live unified
+// resource snapshot. Unknown and ambiguous identities fail closed and remain
+// in the result for a later poll that can prove their ownership.
+func PlanAlertIdentityMigration(config AlertConfig, resources []unifiedresources.Resource) AlertIdentityMigrationPlan {
+ plan := AlertIdentityMigrationPlan{
+ FromVersion: config.IdentitySchemaVersion,
+ ToVersion: CurrentAlertIdentitySchemaVersion,
+ }
+ if config.IdentitySchemaVersion > CurrentAlertIdentitySchemaVersion {
+ plan.ToVersion = config.IdentitySchemaVersion
+ plan.UnsupportedVersion = true
+ return plan
+ }
+
+ original := cloneThresholdOverrides(config.Overrides)
+ working := config
+ working.Overrides = cloneThresholdOverrides(config.Overrides)
+
+ MigrateCanonicalOverrideKeys(&working, resources)
+ MigrateDockerContainerOverrideKeys(&working, resources)
+ plan.Deferred = append(plan.Deferred, migrateGuestOverrideKeys(&working, resources)...)
+ plan.Deferred = append(plan.Deferred, migrateStorageOverrideKeys(&working, resources)...)
+ working.IdentitySchemaVersion = CurrentAlertIdentitySchemaVersion
+
+ for key := range original {
+ if _, exists := working.Overrides[key]; !exists {
+ plan.RemovedOverrideKeys = append(plan.RemovedOverrideKeys, key)
+ }
+ }
+ for key := range working.Overrides {
+ if _, exists := original[key]; !exists {
+ plan.AddedOverrideKeys = append(plan.AddedOverrideKeys, key)
+ }
+ }
+ sort.Strings(plan.RemovedOverrideKeys)
+ sort.Strings(plan.AddedOverrideKeys)
+ sort.Slice(plan.Deferred, func(i, j int) bool {
+ if plan.Deferred[i].Key == plan.Deferred[j].Key {
+ return plan.Deferred[i].Reason < plan.Deferred[j].Reason
+ }
+ return plan.Deferred[i].Key < plan.Deferred[j].Key
+ })
+ plan.plannedOverrides = cloneThresholdOverrides(working.Overrides)
+ return plan
+}
+
+// ApplyAlertIdentityMigration applies a previously generated plan. A stale
+// plan is rejected if another writer changed the schema version in between.
+func ApplyAlertIdentityMigration(config *AlertConfig, plan AlertIdentityMigrationPlan) bool {
+ if config == nil || !plan.Changed() || plan.UnsupportedVersion || config.IdentitySchemaVersion != plan.FromVersion {
+ return false
+ }
+ config.Overrides = cloneThresholdOverrides(plan.plannedOverrides)
+ config.IdentitySchemaVersion = plan.ToVersion
+ return true
+}
+
+func cloneThresholdOverrides(source map[string]ThresholdConfig) map[string]ThresholdConfig {
+ if source == nil {
+ return nil
+ }
+ cloned := make(map[string]ThresholdConfig, len(source))
+ for key, override := range source {
+ cloned[key] = override
+ }
+ return cloned
+}
+
+type overrideAliasClaims struct {
+ targets map[string]string
+ ambiguous map[string]struct{}
+}
+
+func newOverrideAliasClaims() *overrideAliasClaims {
+ return &overrideAliasClaims{
+ targets: make(map[string]string),
+ ambiguous: make(map[string]struct{}),
+ }
+}
+
+func (c *overrideAliasClaims) add(alias, target string) {
+ alias = strings.TrimSpace(alias)
+ target = strings.TrimSpace(target)
+ if alias == "" || target == "" || alias == target {
+ return
+ }
+ if existing, exists := c.targets[alias]; exists && existing != target {
+ delete(c.targets, alias)
+ c.ambiguous[alias] = struct{}{}
+ return
+ }
+ if _, conflict := c.ambiguous[alias]; conflict {
+ return
+ }
+ c.targets[alias] = target
+}
+
+func migrateGuestOverrideKeys(config *AlertConfig, resources []unifiedresources.Resource) []AlertIdentityMigrationDeferred {
+ claims := newOverrideAliasClaims()
+ for _, resource := range resources {
+ typeKey := unifiedresources.CanonicalResourceType(resource.Type)
+ if typeKey != unifiedresources.ResourceTypeVM && typeKey != unifiedresources.ResourceTypeSystemContainer {
+ continue
+ }
+ if resource.Proxmox == nil {
+ continue
+ }
+ ident, ok := guestOverrideIdentityFromParts(resource.Proxmox.Instance, resource.Proxmox.NodeName, resource.Proxmox.VMID)
+ if !ok {
+ continue
+ }
+ vmid := strconv.Itoa(ident.vmid)
+ target := guestOverridePrimaryKey(nil, strings.Join([]string{ident.instance, ident.node, vmid}, ":"))
+ aliases := []string{
+ resource.ID,
+ strings.Join([]string{ident.instance, ident.node, vmid}, ":"),
+ legacyGuestStableOverrideKey(ident.instance, ident.vmid),
+ }
+ if ident.instance != ident.node {
+ aliases = append(aliases, legacyClusterGuestOverrideKey(ident.instance, ident.node, ident.vmid))
+ }
+ aliases = append(aliases, resource.SupersededCanonicalIDs...)
+ for _, alias := range aliases {
+ claims.add(alias, target)
+ }
+ }
+ return applyOverrideAliasClaims(config, claims, true)
+}
+
+func migrateStorageOverrideKeys(config *AlertConfig, resources []unifiedresources.Resource) []AlertIdentityMigrationDeferred {
+ claims := newOverrideAliasClaims()
+ for _, resource := range resources {
+ if unifiedresources.CanonicalResourceType(resource.Type) != unifiedresources.ResourceTypeStorage {
+ continue
+ }
+ target := ""
+ if resource.MetricsTarget != nil && strings.EqualFold(resource.MetricsTarget.ResourceType, "storage") {
+ target = strings.TrimSpace(resource.MetricsTarget.ResourceID)
+ }
+ if target == "" && resource.Proxmox != nil {
+ target = strings.TrimSpace(resource.Proxmox.SourceID)
+ }
+ if target == "" {
+ target = strings.TrimSpace(resource.ID)
+ }
+ aliases := append([]string{resource.ID}, resource.SupersededCanonicalIDs...)
+ if resource.Storage != nil {
+ aliases = append(aliases, resource.Storage.AliasIDs...)
+ if resource.Storage.Shared && resource.Proxmox != nil {
+ for _, node := range resource.Storage.Nodes {
+ prefix := strings.TrimSpace(node)
+ instance := strings.TrimSpace(resource.Proxmox.Instance)
+ if instance != "" && prefix != "" && !strings.HasPrefix(strings.ToLower(prefix), strings.ToLower(instance)+"-") {
+ prefix = instance + "-" + prefix
+ }
+ if prefix != "" && strings.TrimSpace(resource.Name) != "" {
+ aliases = append(aliases, prefix+"-"+strings.TrimSpace(resource.Name))
+ }
+ }
+ }
+ if strings.EqualFold(resource.Storage.Platform, "pbs") {
+ if slash := strings.LastIndex(target, "/"); slash > 0 {
+ aliases = append(aliases, target[:slash]+"-"+target[slash+1:])
+ }
+ }
+ }
+ for _, alias := range aliases {
+ claims.add(alias, target)
+ if cephAlias, ok := cephPoolStorageSourceAliasID(alias); ok {
+ claims.add(cephAlias, target)
+ }
+ }
+ }
+ return applyOverrideAliasClaims(config, claims, false)
+}
+
+func applyOverrideAliasClaims(config *AlertConfig, claims *overrideAliasClaims, includeGuestDisks bool) []AlertIdentityMigrationDeferred {
+ if config == nil || len(config.Overrides) == 0 {
+ return nil
+ }
+ original := cloneThresholdOverrides(config.Overrides)
+ moves := make(map[string][]string)
+ deferred := make([]AlertIdentityMigrationDeferred, 0)
+ for key := range original {
+ alias := key
+ suffix := ""
+ if includeGuestDisks && strings.HasPrefix(key, "guest-disk:") {
+ if split := strings.Index(strings.TrimPrefix(key, "guest-disk:"), "/disk:"); split >= 0 {
+ baseAndSuffix := strings.TrimPrefix(key, "guest-disk:")
+ alias = baseAndSuffix[:split]
+ suffix = baseAndSuffix[split:]
+ }
+ }
+ if _, conflict := claims.ambiguous[alias]; conflict {
+ deferred = append(deferred, AlertIdentityMigrationDeferred{Key: key, Reason: "ambiguous live identity"})
+ continue
+ }
+ target, claimed := claims.targets[alias]
+ if !claimed {
+ continue
+ }
+ if suffix != "" {
+ target = "guest-disk:" + target + suffix
+ }
+ if target != key {
+ moves[target] = append(moves[target], key)
+ }
+ }
+
+ changed := false
+ result := cloneThresholdOverrides(original)
+ for target, sources := range moves {
+ sort.Strings(sources)
+ if _, targetExists := original[target]; targetExists {
+ for _, source := range sources {
+ delete(result, source)
+ changed = true
+ }
+ continue
+ }
+ if len(sources) != 1 {
+ for _, source := range sources {
+ deferred = append(deferred, AlertIdentityMigrationDeferred{Key: source, Reason: "multiple legacy rows claim one canonical identity"})
+ }
+ continue
+ }
+ source := sources[0]
+ result[target] = original[source]
+ delete(result, source)
+ changed = true
+ }
+ if changed {
+ config.Overrides = result
+ }
+ return deferred
+}
diff --git a/internal/alerts/alert_identity_migration_test.go b/internal/alerts/alert_identity_migration_test.go
new file mode 100644
index 000000000..e477fadef
--- /dev/null
+++ b/internal/alerts/alert_identity_migration_test.go
@@ -0,0 +1,195 @@
+package alerts
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
+)
+
+func TestPlanAlertIdentityMigrationDryRunAndApply(t *testing.T) {
+ guestOverride := ThresholdConfig{CPU: &HysteresisThreshold{Trigger: 91, Clear: 86}}
+ diskOverride := ThresholdConfig{Disk: &HysteresisThreshold{Trigger: 93, Clear: 88}}
+ storageOverride := ThresholdConfig{Usage: &HysteresisThreshold{Trigger: 94, Clear: 89}}
+ unknownOverride := ThresholdConfig{Disabled: true}
+ config := AlertConfig{Overrides: map[string]ThresholdConfig{
+ "cluster-a-node-1-100": guestOverride,
+ "guest-disk:cluster-a-node-2-101/disk:root": diskOverride,
+ "agent:cluster-a-ceph-pool-data": storageOverride,
+ "temporarily-offline-resource": unknownOverride,
+ }}
+ original := cloneThresholdOverrides(config.Overrides)
+ resources := []unifiedresources.Resource{
+ {
+ ID: "vm-aaaaaaaaaaaaaaaa",
+ Type: unifiedresources.ResourceTypeVM,
+ Proxmox: &unifiedresources.ProxmoxData{
+ Instance: "cluster-a",
+ NodeName: "node-1",
+ VMID: 100,
+ },
+ },
+ {
+ ID: "vm-bbbbbbbbbbbbbbbb",
+ Type: unifiedresources.ResourceTypeVM,
+ Proxmox: &unifiedresources.ProxmoxData{
+ Instance: "cluster-a",
+ NodeName: "node-2",
+ VMID: 101,
+ },
+ },
+ {
+ ID: "storage-cccccccccccccccc",
+ Type: unifiedresources.ResourceTypeStorage,
+ MetricsTarget: &unifiedresources.MetricsTarget{
+ ResourceType: "storage",
+ ResourceID: "cluster-a-ceph-pool-data",
+ },
+ Storage: &unifiedresources.StorageMeta{
+ AliasIDs: []string{"agent:cluster-a-ceph-pool-data"},
+ },
+ },
+ }
+
+ plan := PlanAlertIdentityMigration(config, resources)
+ if !plan.Changed() || plan.FromVersion != 0 || plan.ToVersion != CurrentAlertIdentitySchemaVersion {
+ t.Fatalf("unexpected migration plan: %+v", plan)
+ }
+ if !reflect.DeepEqual(config.Overrides, original) || config.IdentitySchemaVersion != 0 {
+ t.Fatalf("dry-run mutated source config: %+v", config)
+ }
+
+ if !ApplyAlertIdentityMigration(&config, plan) {
+ t.Fatal("expected migration plan to apply")
+ }
+ if config.IdentitySchemaVersion != CurrentAlertIdentitySchemaVersion {
+ t.Fatalf("identity schema version = %d", config.IdentitySchemaVersion)
+ }
+ for _, removed := range []string{
+ "cluster-a-node-1-100",
+ "guest-disk:cluster-a-node-2-101/disk:root",
+ "agent:cluster-a-ceph-pool-data",
+ } {
+ if _, exists := config.Overrides[removed]; exists {
+ t.Fatalf("legacy override %q remained after migration", removed)
+ }
+ }
+ if got := config.Overrides["guest:cluster-a:100"]; got.CPU == nil || got.CPU.Trigger != 91 {
+ t.Fatalf("guest override was not migrated: %+v", got)
+ }
+ if got := config.Overrides["guest-disk:guest:cluster-a:101/disk:root"]; got.Disk == nil || got.Disk.Trigger != 93 {
+ t.Fatalf("guest disk override was not migrated: %+v", got)
+ }
+ if got := config.Overrides["cluster-a-ceph-pool-data"]; got.Usage == nil || got.Usage.Trigger != 94 {
+ t.Fatalf("storage override was not migrated: %+v", got)
+ }
+ if got, exists := config.Overrides["temporarily-offline-resource"]; !exists || !got.Disabled {
+ t.Fatalf("unknown override was not preserved: %+v", config.Overrides)
+ }
+
+ second := PlanAlertIdentityMigration(config, resources)
+ if second.Changed() {
+ t.Fatalf("migration was not idempotent: %+v", second)
+ }
+}
+
+func TestPlanAlertIdentityMigrationFailsClosedOnConflictingLegacyRows(t *testing.T) {
+ config := AlertConfig{Overrides: map[string]ThresholdConfig{
+ "cluster-a-100": {CPU: &HysteresisThreshold{Trigger: 90, Clear: 85}},
+ "cluster-a-node-1-100": {CPU: &HysteresisThreshold{Trigger: 95, Clear: 90}},
+ }}
+ resources := []unifiedresources.Resource{{
+ ID: "vm-aaaaaaaaaaaaaaaa",
+ Type: unifiedresources.ResourceTypeVM,
+ Proxmox: &unifiedresources.ProxmoxData{
+ Instance: "cluster-a",
+ NodeName: "node-1",
+ VMID: 100,
+ },
+ }}
+
+ plan := PlanAlertIdentityMigration(config, resources)
+ if len(plan.Deferred) != 2 {
+ t.Fatalf("deferred rows = %+v, want both conflicting aliases", plan.Deferred)
+ }
+ if !ApplyAlertIdentityMigration(&config, plan) {
+ t.Fatal("schema-only portion of plan should still apply")
+ }
+ if len(config.Overrides) != 2 {
+ t.Fatalf("conflicting rows were changed: %+v", config.Overrides)
+ }
+}
+
+func TestPlanAlertIdentityMigrationRejectsNewerSchema(t *testing.T) {
+ config := AlertConfig{
+ IdentitySchemaVersion: CurrentAlertIdentitySchemaVersion + 1,
+ Overrides: map[string]ThresholdConfig{"legacy": {Disabled: true}},
+ }
+ plan := PlanAlertIdentityMigration(config, nil)
+ if !plan.UnsupportedVersion || plan.Changed() {
+ t.Fatalf("newer schema plan = %+v", plan)
+ }
+ if ApplyAlertIdentityMigration(&config, plan) {
+ t.Fatal("newer schema must not be modified")
+ }
+}
+
+func TestLoadActiveAlertsRewritesCanonicalPersistedIDAndPreservesAck(t *testing.T) {
+ m := newTestManager(t)
+ now := time.Now().UTC()
+ legacy := []*Alert{{
+ ID: "node-offline-pve-a",
+ Type: "connectivity",
+ Level: AlertLevelWarning,
+ ResourceName: "pve-a",
+ Node: "pve-a",
+ Message: "node offline",
+ StartTime: now.Add(-time.Minute),
+ LastSeen: now,
+ Acknowledged: true,
+ AckTime: &now,
+ AckUser: "operator",
+ }}
+ data, err := json.Marshal(legacy)
+ if err != nil {
+ t.Fatal(err)
+ }
+ alertsDir := m.getAlertsDir()
+ if err := os.MkdirAll(alertsDir, alertsDirPerm); err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(alertsDir, "active-alerts.json")
+ if err := os.WriteFile(path, data, alertsFilePerm); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := m.LoadActiveAlerts(); err != nil {
+ t.Fatalf("LoadActiveAlerts() error = %v", err)
+ }
+ wantID := "pve-a::pve-a-connectivity"
+ deadline := time.Now().Add(2 * time.Second)
+ for {
+ persistedData, readErr := os.ReadFile(path)
+ if readErr != nil {
+ t.Fatal(readErr)
+ }
+ var persisted []*Alert
+ if err := json.Unmarshal(persistedData, &persisted); err != nil {
+ t.Fatal(err)
+ }
+ if len(persisted) == 1 && persisted[0].ID == wantID {
+ if !persisted[0].Acknowledged || persisted[0].AckUser != "operator" || persisted[0].AckTime == nil {
+ t.Fatalf("ack fields changed during rewrite: %+v", persisted[0])
+ }
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("active alert file was not canonically rewritten: %+v", persisted)
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+}
diff --git a/internal/alerts/config/types.go b/internal/alerts/config/types.go
index aaf0f8c57..b70c4aa95 100644
--- a/internal/alerts/config/types.go
+++ b/internal/alerts/config/types.go
@@ -230,6 +230,10 @@ type GuestLookup struct {
// AlertConfig represents the complete alert configuration
type AlertConfig struct {
+ // IdentitySchemaVersion records which persisted alert-identity migration
+ // has been applied. It is additive so older Pulse releases safely ignore it;
+ // after a rollback, a newer release can rerun the idempotent migration.
+ IdentitySchemaVersion int `json:"identitySchemaVersion,omitempty"`
Enabled bool `json:"enabled"`
ActivationState ActivationState `json:"activationState,omitempty"`
ObservationWindowHours int `json:"observationWindowHours,omitempty"`
diff --git a/internal/alerts/config_runtime.go b/internal/alerts/config_runtime.go
index 7908e7458..1dc8ea952 100644
--- a/internal/alerts/config_runtime.go
+++ b/internal/alerts/config_runtime.go
@@ -14,6 +14,14 @@ func (m *Manager) UpdateConfig(config AlertConfig) {
m.mu.Lock()
defer m.mu.Unlock()
+ // Clients and rollback-era config writers may not know about the additive
+ // identity schema marker. Never lower a version already held by this
+ // process; an older binary can still omit it on disk and the migration will
+ // safely rerun after a later upgrade.
+ if config.IdentitySchemaVersion < m.config.IdentitySchemaVersion {
+ config.IdentitySchemaVersion = m.config.IdentitySchemaVersion
+ }
+
// Preserve activation state/time when clients update the config without including it.
// This avoids unintentionally resetting alerts to pending review when saving thresholds.
if config.ActivationState == "" && m.config.ActivationState != "" {
diff --git a/internal/monitoring/monitor_alert_override_migration_test.go b/internal/monitoring/monitor_alert_override_migration_test.go
index 6fe6b2320..609262f0b 100644
--- a/internal/monitoring/monitor_alert_override_migration_test.go
+++ b/internal/monitoring/monitor_alert_override_migration_test.go
@@ -153,6 +153,9 @@ func TestSyncUnifiedResourceAlertsPersistsAndEvaluatesTrueNASOverrideSuccession(
if override := reloaded.Overrides[newID]; override.Memory == nil || override.Memory.Trigger != 95 {
t.Fatalf("reloaded override missing under canonical identity %s: %+v", newID, override)
}
+ if got := reloaded.IdentitySchemaVersion; got != alerts.CurrentAlertIdentitySchemaVersion {
+ t.Fatalf("persisted alert identity schema version = %d, want %d", got, alerts.CurrentAlertIdentitySchemaVersion)
+ }
resources = []unifiedresources.Resource{
trueNASMemoryAlertResource(newID, oldID, sharedName, 96),
diff --git a/internal/monitoring/monitor_alert_sync.go b/internal/monitoring/monitor_alert_sync.go
index ccf1940a8..98a037e41 100644
--- a/internal/monitoring/monitor_alert_sync.go
+++ b/internal/monitoring/monitor_alert_sync.go
@@ -97,17 +97,36 @@ func (m *Monitor) syncUnifiedResourceAlertsToState(resources []unifiedresources.
}
config := m.alertManager.GetConfig()
- migrated := alerts.MigrateCanonicalOverrideKeys(&config, resources)
- if alerts.MigrateDockerContainerOverrideKeys(&config, resources) {
- migrated = true
- }
- if migrated {
+ migrationPlan := alerts.PlanAlertIdentityMigration(config, resources)
+ if migrationPlan.UnsupportedVersion {
+ log.Warn().
+ Int("configVersion", migrationPlan.FromVersion).
+ Int("supportedVersion", alerts.CurrentAlertIdentitySchemaVersion).
+ Msg("alert identity config uses a newer schema; leaving persisted identities untouched")
+ } else if alerts.ApplyAlertIdentityMigration(&config, migrationPlan) {
if m.configPersist == nil {
- log.Warn().Msg("cannot persist canonical alert override migration without config persistence")
+ log.Warn().
+ Int("fromVersion", migrationPlan.FromVersion).
+ Int("toVersion", migrationPlan.ToVersion).
+ Int("removedOverrides", len(migrationPlan.RemovedOverrideKeys)).
+ Int("addedOverrides", len(migrationPlan.AddedOverrideKeys)).
+ Int("deferredOverrides", len(migrationPlan.Deferred)).
+ Msg("cannot persist planned alert identity migration without config persistence")
} else if err := m.configPersist.SaveAlertConfig(config); err != nil {
- log.Error().Err(err).Msg("failed to persist canonical alert override migration")
+ log.Error().
+ Err(err).
+ Int("fromVersion", migrationPlan.FromVersion).
+ Int("toVersion", migrationPlan.ToVersion).
+ Msg("failed to persist planned alert identity migration")
} else {
m.alertManager.UpdateConfig(config)
+ log.Info().
+ Int("fromVersion", migrationPlan.FromVersion).
+ Int("toVersion", migrationPlan.ToVersion).
+ Int("removedOverrides", len(migrationPlan.RemovedOverrideKeys)).
+ Int("addedOverrides", len(migrationPlan.AddedOverrideKeys)).
+ Int("deferredOverrides", len(migrationPlan.Deferred)).
+ Msg("persisted alert identity migration plan")
}
}
diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go
index 5be80ef47..95a761286 100644
--- a/internal/unifiedresources/adapters.go
+++ b/internal/unifiedresources/adapters.go
@@ -1985,6 +1985,7 @@ func resourceFromStorage(storage models.Storage) (Resource, ResourceIdentity) {
Type: storageType,
Content: content,
ContentTypes: parseStorageContentTypes(content),
+ AliasIDs: append([]string(nil), storage.AliasIDs...),
Shared: storage.Shared,
Enabled: storage.Enabled,
Active: storage.Active,
diff --git a/internal/unifiedresources/adapters_test.go b/internal/unifiedresources/adapters_test.go
index 054dea4b1..bcb4b61f1 100644
--- a/internal/unifiedresources/adapters_test.go
+++ b/internal/unifiedresources/adapters_test.go
@@ -1037,6 +1037,7 @@ func TestResourceFromContainerPreservesProxmoxPool(t *testing.T) {
func TestResourceFromStorageIncludesStorageMetadata(t *testing.T) {
storage := models.Storage{
ID: "storage-1",
+ AliasIDs: []string{"cluster-a-pve-1-ceph-rbd-pool"},
Name: "ceph-rbd-pool",
Node: "pve-1",
Instance: "cluster-a",
@@ -1066,6 +1067,9 @@ func TestResourceFromStorageIncludesStorageMetadata(t *testing.T) {
if got, want := resource.Storage.Pool, "ceph/rbd-a"; got != want {
t.Fatalf("storage pool = %q, want %q", got, want)
}
+ if got, want := resource.Storage.AliasIDs, storage.AliasIDs; !reflect.DeepEqual(got, want) {
+ t.Fatalf("storage alias IDs = %v, want %v", got, want)
+ }
wantContentTypes := []string{"images", "rootdir"}
if len(resource.Storage.ContentTypes) != len(wantContentTypes) {
t.Fatalf("contentTypes length = %d, want %d (%v)", len(resource.Storage.ContentTypes), len(wantContentTypes), resource.Storage.ContentTypes)
diff --git a/internal/unifiedresources/canonical_identity_test.go b/internal/unifiedresources/canonical_identity_test.go
index cb97dd6c6..072b62ec6 100644
--- a/internal/unifiedresources/canonical_identity_test.go
+++ b/internal/unifiedresources/canonical_identity_test.go
@@ -931,6 +931,7 @@ func TestStorageTopologyAndVDevLayoutAreDistinctIdentityFields(t *testing.T) {
Platform: "truenas",
Topology: "pool",
VDevLayout: "mirror+special",
+ AliasIDs: []string{"legacy-pool-id"},
}
cloned := cloneStorageMeta(meta)
@@ -943,6 +944,9 @@ func TestStorageTopologyAndVDevLayoutAreDistinctIdentityFields(t *testing.T) {
if cloned.VDevLayout != "mirror+special" {
t.Fatalf("cloned vdev layout = %q, want \"mirror+special\"", cloned.VDevLayout)
}
+ if len(cloned.AliasIDs) != 1 || cloned.AliasIDs[0] != "legacy-pool-id" {
+ t.Fatalf("cloned alias IDs = %v, want compatibility identity", cloned.AliasIDs)
+ }
// A pool with no reported vdevs still carries the discriminator, and the
// layout stays absent rather than being backfilled from it.
@@ -965,4 +969,7 @@ func TestStorageTopologyAndVDevLayoutAreDistinctIdentityFields(t *testing.T) {
if decoded["vdevLayout"] != "mirror+special" {
t.Fatalf("wire vdevLayout = %v, want \"mirror+special\"", decoded["vdevLayout"])
}
+ if aliases, ok := decoded["aliasIds"].([]any); !ok || len(aliases) != 1 || aliases[0] != "legacy-pool-id" {
+ t.Fatalf("wire aliasIds = %v, want [legacy-pool-id]", decoded["aliasIds"])
+ }
}
diff --git a/internal/unifiedresources/clone.go b/internal/unifiedresources/clone.go
index d94372cf6..8e0548419 100644
--- a/internal/unifiedresources/clone.go
+++ b/internal/unifiedresources/clone.go
@@ -171,6 +171,7 @@ func cloneStorageMeta(in *StorageMeta) *StorageMeta {
}
out := *in
out.ContentTypes = cloneStringSlice(in.ContentTypes)
+ out.AliasIDs = cloneStringSlice(in.AliasIDs)
out.Nodes = cloneStringSlice(in.Nodes)
out.ConsumerTypes = cloneStringSlice(in.ConsumerTypes)
out.TopConsumers = cloneStorageConsumerMetaSlice(in.TopConsumers)
diff --git a/internal/unifiedresources/clone_test.go b/internal/unifiedresources/clone_test.go
index 6a9b6ab56..c6d7ce567 100644
--- a/internal/unifiedresources/clone_test.go
+++ b/internal/unifiedresources/clone_test.go
@@ -497,6 +497,7 @@ func TestCloneStorageMeta_Nil(t *testing.T) {
func TestCloneStorageMeta_SliceIsolation(t *testing.T) {
original := &StorageMeta{
ContentTypes: []string{"images", "backup"},
+ AliasIDs: []string{"legacy-storage-id"},
Nodes: []string{"node1", "node2"},
ConsumerTypes: []string{"vm"},
TopConsumers: []StorageConsumerMeta{
@@ -510,6 +511,11 @@ func TestCloneStorageMeta_SliceIsolation(t *testing.T) {
t.Error("mutating cloned ContentTypes should not affect original")
}
+ cloned.AliasIDs[0] = "MUTATED"
+ if original.AliasIDs[0] == "MUTATED" {
+ t.Error("mutating cloned AliasIDs should not affect original")
+ }
+
cloned.Nodes[0] = "MUTATED"
if original.Nodes[0] == "MUTATED" {
t.Error("mutating cloned Nodes should not affect original")
diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go
index f93f5f45a..37c11caf3 100644
--- a/internal/unifiedresources/types.go
+++ b/internal/unifiedresources/types.go
@@ -405,6 +405,7 @@ type StorageMeta struct {
Type string `json:"type,omitempty"`
Content string `json:"content,omitempty"`
ContentTypes []string `json:"contentTypes,omitempty"`
+ AliasIDs []string `json:"aliasIds,omitempty"`
Shared bool `json:"shared"`
Enabled bool `json:"enabled"`
Active bool `json:"active"`
diff --git a/tests/integration/tests/93-alert-operator-qualification.spec.ts b/tests/integration/tests/93-alert-operator-qualification.spec.ts
index de9c5ca5d..94e77746e 100644
--- a/tests/integration/tests/93-alert-operator-qualification.spec.ts
+++ b/tests/integration/tests/93-alert-operator-qualification.spec.ts
@@ -234,6 +234,23 @@ test.describe('Alert operator qualification', () => {
await expect(databaseCard).toContainText('deliberately long diagnostic message');
await expect(databaseCard).toContainText('Database Node With A Deliberately Long');
+ const visibleAxisLabels = page.locator('[data-testid="alert-frequency-axis-label"]:visible');
+ await expect(visibleAxisLabels).toHaveCount(3);
+ const axisBoxes = (
+ await visibleAxisLabels.evaluateAll((labels) =>
+ labels.map((label) => {
+ const box = label.getBoundingClientRect();
+ return { left: box.left, right: box.right };
+ }),
+ )
+ ).sort((a, b) => a.left - b.left);
+ for (let index = 1; index < axisBoxes.length; index += 1) {
+ expect(
+ axisBoxes[index - 1]!.right,
+ 'Mobile alert-frequency axis labels must not overlap',
+ ).toBeLessThanOrEqual(axisBoxes[index]!.left);
+ }
+
const search = page.getByPlaceholder('Search alerts...');
await search.focus();
await expect(search).toBeFocused();
diff --git a/tests/integration/tests/94-alert-history-real-backend.spec.ts b/tests/integration/tests/94-alert-history-real-backend.spec.ts
index ea79ada1b..35c6b32da 100644
--- a/tests/integration/tests/94-alert-history-real-backend.spec.ts
+++ b/tests/integration/tests/94-alert-history-real-backend.spec.ts
@@ -109,7 +109,14 @@ test.describe('Real backend alert history qualification', () => {
message: 'Legacy history import rendered from SQLite',
type: 'cpu',
});
- expect(Date.parse(importedAlert!.lastSeen) - Date.parse(importedAlert!.startTime)).toBe(30 * 60_000);
+ expect(
+ Math.abs(
+ Date.parse(importedAlert!.lastSeen) -
+ Date.parse(importedAlert!.startTime) -
+ 30 * 60_000,
+ ),
+ 'Seed timestamps may be constructed a few milliseconds apart',
+ ).toBeLessThanOrEqual(100);
await restartManagedLocalBackend();
await ensureAuthenticated(page);