feat(alerts): migrate persisted alert identities

This commit is contained in:
rcourtman
2026-08-27 12:01:10 +01:00
parent ff27c3881f
commit 73499d7161
31 changed files with 751 additions and 75 deletions
+10 -4
View File
@@ -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.
@@ -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": [],
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+22 -15
View File
@@ -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"
]
}
@@ -174,23 +174,36 @@ export function AlertHistoryFrequencyCard(props: AlertHistoryFrequencyCardProps)
<div class="relative mt-3 h-10">
<div class="absolute inset-x-0 top-0 h-px bg-surface-hover"></div>
<For each={props.state.axisTicks()}>
{(tick) => (
<div
class="pointer-events-none absolute top-0 flex h-full flex-col items-center"
style={{ left: `${tick.position * 100}%` }}
>
<div class="h-3 w-px bg-slate-300"></div>
{(tick, index) => {
const lastIndex = props.state.axisTicks().length - 1;
const mobileMidpoint = Math.floor(lastIndex / 2);
const visibleOnMobile =
index() === 0 || index() === mobileMidpoint || index() === lastIndex;
return (
<div
class="mt-1 whitespace-nowrap text-[10px] text-muted transform"
data-testid="alert-frequency-axis-tick"
class="pointer-events-none absolute top-0 h-full flex-col items-center sm:flex"
classList={{
'-translate-x-1/2': tick.align === 'center',
'-translate-x-full': tick.align === 'end',
hidden: !visibleOnMobile,
flex: visibleOnMobile,
}}
style={{ left: `${tick.position * 100}%` }}
>
{tick.label}
<div class="h-3 w-px bg-slate-300"></div>
<div
data-testid="alert-frequency-axis-label"
class="mt-1 whitespace-nowrap text-[10px] text-muted transform"
classList={{
'-translate-x-1/2': tick.align === 'center',
'-translate-x-full': tick.align === 'end',
}}
>
{tick.label}
</div>
</div>
</div>
)}
);
}}
</For>
</div>
</Show>
@@ -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 = {
@@ -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 });
@@ -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,
@@ -37,6 +37,9 @@ export function useAlertsConfigurationSnapshotState(
props: UseAlertsConfigurationSnapshotStateProps,
) {
const defaultSnapshot = createDefaultAlertsConfigurationSnapshot();
const [identitySchemaVersion, setIdentitySchemaVersion] = createSignal(
defaultSnapshot.identitySchemaVersion,
);
const [scheduleQuietHours, setScheduleQuietHours] = createSignal<QuietHoursConfig>(
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 },
@@ -44,6 +44,19 @@ function createResource(overrides: Partial<Resource> = {}): 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',
+1
View File
@@ -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;
+1
View File
@@ -397,6 +397,7 @@ export interface ResourceStorageMeta {
type?: string;
content?: string;
contentTypes?: string[];
aliasIds?: string[];
shared?: boolean;
isCeph?: boolean;
isZfs?: boolean;
+31 -1
View File
@@ -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
}
+276
View File
@@ -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
}
@@ -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)
}
}
+4
View File
@@ -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"`
+8
View File
@@ -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 != "" {
@@ -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),
+26 -7
View File
@@ -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")
}
}
+1
View File
@@ -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,
@@ -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)
@@ -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"])
}
}
+1
View File
@@ -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)
+6
View File
@@ -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")
+1
View File
@@ -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"`
@@ -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();
@@ -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);