Replace stored alerts with each payload instead of merging

The websocket store wrote every incoming active alert with
setActiveAlerts(id, alert). Solid's path setter shallow-merges into an
existing object, so a field the server stops sending kept its old value
in the browser. After a backend restart, threshold alerts are restored
without metricStatus until their next evaluation, but an open tab kept
the pre-restart live reading and the node drawer and node table went on
saying "78°C now" for a reading the server no longer reports. An
unacknowledge from another session left ackTime and ackUser behind the
same way, as did a dropped nodeDisplayName or metadata.

Active and recently resolved alerts are now written with reconcile, so
omitted keys are removed on the snapshot, keyed-delta and REST recovery
commits alike. reconcile diffs nested values in place, so an unchanged
alert's live status no longer re-notifies its readers on every frame,
which the shallow merge did by swapping in a fresh object each time.
The pending acknowledgement hold is unchanged: it still skips payloads
that contradict an unconfirmed local acknowledge or unacknowledge.

updateAlert now stores a copy of each local update. Because payloads
reconcile the stored alert in place, a snooze's optimistic record, which
shares nested objects with the record the caller keeps for rollback,
would otherwise have that rollback record rewritten by the next payload.
This commit is contained in:
rcourtman
2026-10-06 23:26:46 +01:00
parent 8424fd6505
commit 1c101b024d
5 changed files with 395 additions and 6 deletions
@@ -787,6 +787,18 @@ that fast path entirely: active-alert and resolved-alert stores keep their
own commit path, resource `alerts` facets are not in the fast-path
allow-list, and a row whose patch touches alert-relevant structure always
takes the full canonical merge.
That commit path replaces each stored alert with the server's payload for its
id instead of merging into it. A field the server omits is removed from the
store, whether the payload came from a socket snapshot, a keyed delta, or REST
recovery: a threshold alert restored after a restart carries no `metricStatus`
until its next evaluation, so the previous live reading must not keep reading
as "now"; an unacknowledge from another session clears `ackTime` and
`ackUser`; a dropped `nodeDisplayName` or `metadata` disappears. Resolved
alerts follow the same rule. The pending acknowledgement hold still skips a
payload that contradicts an unconfirmed local acknowledge or unacknowledge,
and the confirming payload then replaces the optimistic copy. Because a
replacement reconciles the stored alert in place, local optimistic updates are
stored as copies, so it never rewrites a record a caller keeps for rollback.
Operational evidence and lifecycle identity are typed through
`internal/operationaltrust`. Evidence envelopes distinguish completeness,
confidence, permissions, freshness, correlation, and bounded provider detail.
@@ -673,7 +673,13 @@ the store with reference-stable untouched items so the per-tick deep walk of
the whole projection is gone. Active alerts travel the same keyed transport
(`activeAlertsDelta`, replacing the ~37KB whole-array re-ship whenever any
alert changed) while their application stays immediate per the alerts
subsystem boundary. These projection baselines are socket-owned: an oversized
subsystem boundary. Each committed alert is reconciled in place against its
copy in the keyed active-alert store, so a reader of an unchanged alert there,
or of an unchanged nested value such as its live `metricStatus` or `metadata`,
is not notified although every commit carries freshly parsed objects; omitted
fields are deleted rather than shallow-merged. The `state.activeAlerts` array
is still replaced on every alert commit. These projection baselines are
socket-owned: an oversized
frame invalidates resources, connected infrastructure, and active alerts
together, while REST recovery hydrates display state only. A later keyed delta
without a socket baseline requests the throttled recovery path instead of
@@ -0,0 +1,40 @@
{
"version": 1,
"base_sha": "8424fd65057af75e1ea3c1fc9fe29528d7c0d68e",
"verified_at": "2026-10-06T21:24:22Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/stores/websocket.ts"
],
"content_sha256": {
"frontend-modern/src/stores/websocket.ts": "1be5c0f23d3dbaf85a32f7b8b3906c2cb25b20bb02412c31c200ffbad9c6c2d5"
},
"routes": [
"/proxmox/overview",
"/alerts/overview"
],
"viewports": [
{
"width": 1280,
"height": 900
},
{
"width": 375,
"height": 812
}
],
"states": [
"Private mock stack (backend + Vite from this tree) with a local-only overlay pinning node standalone2's evaluated CPU temperature and letting mock mode restore persisted alerts across restarts",
"standalone2 temperature alert latched below trigger (breach 95, live 78): node drawer Needs attention reads 'Temperature 78\u00b0C now, back under the 80\u00b0C alert level' and the node table temperature cell title carries the same summary",
"Backend restarted with the reading missing: /api/alerts/active returns the restored alert without metricStatus; the open tab reconnects and the drawer and cell title fall back to 'Node temperature at 95.0\u00b0C' within seconds",
"Reading returns: the next keyed delta adds metricStatus and the drawer reads '78\u00b0C now' again",
"Control on the unchanged store, same run: after the restart the open tab kept 'Temperature 78\u00b0C now' in the drawer and cell title for the full two-minute window",
"Alerts overview card snoozed ('Snoozed until ...', Resume shown), resumed, and acknowledged from the UI with no page errors"
],
"interactions": [
"Expanded the standalone2 row details in the Proxmox nodes table (desktop) and after Show all nodes (narrow)",
"Restarted the private backend while the tab stayed open, then changed the pinned reading back to 78",
"Clicked Snooze and the first preset, then Resume, then Acknowledge on the alert card",
"Acknowledged and unacknowledged through the API from another session with the tab open (card left and returned within 2s)"
]
}
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRoot } from 'solid-js';
import { createComputed, createRoot } from 'solid-js';
// Mirrors MAX_INBOUND_WEBSOCKET_MESSAGE_BYTES in the store under test.
const MAX_INBOUND_WEBSOCKET_MESSAGE_BYTES = 32 * 1024 * 1024;
@@ -1291,6 +1291,325 @@ describe('websocket store unified resource contract', () => {
}
});
describe('active alert payloads replace the stored alert', () => {
// A threshold alert held below its trigger, as the socket sends it: the
// breach stays in value/message and the reading evaluated now rides in
// metricStatus.
const heldTemperatureAlert = (): Record<string, unknown> => ({
id: 'pve2-temperature',
type: 'temperature',
level: 'warning',
resourceId: 'node:pve2',
resourceName: 'pve2',
node: 'pve2',
nodeDisplayName: 'PVE Two',
instance: 'pve2',
message: 'Node temperature at 95°C',
value: 95,
threshold: 80,
startTime: '2026-10-06T10:00:00Z',
acknowledged: false,
metadata: { resourceType: 'Node', unit: '°C', probe: 'cpu.package' },
metricStatus: {
phase: 'latched',
value: 78,
observedAt: '2026-10-06T10:05:00Z',
trigger: 80,
recovery: 75,
unit: '°C',
},
});
// The same alert as the backend restores it after a restart: no live
// status until the next evaluation.
const restoredTemperatureAlert = (): Record<string, unknown> => {
const alert = heldTemperatureAlert();
delete alert.metricStatus;
return alert;
};
const emitActiveAlerts = (lastUpdate: number, activeAlerts: unknown[]) =>
emitMessage({ type: 'rawData', data: { lastUpdate, activeAlerts } });
const emitInitialAlerts = (activeAlerts: unknown[]) =>
emitMessage({
type: 'initialState',
data: { resources: [], lastUpdate: 100, activeAlerts, recentlyResolved: [] },
});
it('drops a live status, display name and metadata that a snapshot omits', async () => {
const { store, dispose } = await createStoreHarness();
try {
await waitForOpenTick();
emitInitialAlerts([heldTemperatureAlert()]);
expect(store.activeAlerts['pve2-temperature']?.metricStatus?.value).toBe(78);
emitActiveAlerts(200, [restoredTemperatureAlert()]);
const restored = store.activeAlerts['pve2-temperature'];
expect(restored).not.toHaveProperty('metricStatus');
expect(restored?.value).toBe(95);
expect(store.state.activeAlerts[0]).not.toHaveProperty('metricStatus');
const renamed = restoredTemperatureAlert();
delete renamed.nodeDisplayName;
renamed.metadata = { resourceType: 'Node', unit: '°C' };
emitActiveAlerts(300, [renamed]);
expect(store.activeAlerts['pve2-temperature']).not.toHaveProperty('nodeDisplayName');
expect(store.activeAlerts['pve2-temperature']?.metadata).toEqual({
resourceType: 'Node',
unit: '°C',
});
delete renamed.metadata;
emitActiveAlerts(400, [renamed]);
expect(store.activeAlerts['pve2-temperature']).not.toHaveProperty('metadata');
} finally {
dispose();
}
});
it('drops a live status that a keyed delta deletes', async () => {
const { store, dispose } = await createStoreHarness();
try {
await waitForOpenTick();
emitInitialAlerts([heldTemperatureAlert()]);
emitMessage({
type: 'rawData',
data: {
lastUpdate: 200,
activeAlertsDelta: {
upserts: [{ id: 'pve2-temperature', metricStatus: null, nodeDisplayName: null }],
},
},
});
const stored = store.activeAlerts['pve2-temperature'];
expect(stored).not.toHaveProperty('metricStatus');
expect(stored).not.toHaveProperty('nodeDisplayName');
expect(stored?.message).toBe('Node temperature at 95°C');
} finally {
dispose();
}
});
it('drops a live status that REST recovery omits', async () => {
const { store, dispose } = await createStoreHarness();
try {
await waitForOpenTick();
emitInitialAlerts([heldTemperatureAlert()]);
apiFetchJSONMock.mockResolvedValueOnce([restoredTemperatureAlert()]);
await expect(store.refreshActiveAlerts()).resolves.toBe(true);
expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/alerts/active');
expect(store.activeAlerts['pve2-temperature']).not.toHaveProperty('metricStatus');
} finally {
dispose();
}
});
it('clears acknowledgement fields when the server reports an unacknowledge', async () => {
const { store, dispose } = await createStoreHarness();
try {
await waitForOpenTick();
emitInitialAlerts([
{
...restoredTemperatureAlert(),
acknowledged: true,
ackTime: '2026-10-06T10:06:00Z',
ackUser: 'admin',
},
]);
expect(store.activeAlerts['pve2-temperature']?.ackUser).toBe('admin');
// Unacknowledged from another session: the payload omits both fields.
emitActiveAlerts(200, [restoredTemperatureAlert()]);
const stored = store.activeAlerts['pve2-temperature'];
expect(stored?.acknowledged).toBe(false);
expect(stored).not.toHaveProperty('ackTime');
expect(stored).not.toHaveProperty('ackUser');
} finally {
dispose();
}
});
it('holds a local acknowledge and unacknowledge until the server confirms each', async () => {
const { store, dispose } = await createStoreHarness();
try {
await waitForOpenTick();
const id = 'pve2-temperature';
const acknowledged = {
...restoredTemperatureAlert(),
acknowledged: true,
ackTime: '2026-10-06T10:06:01Z',
ackUser: 'admin',
};
emitInitialAlerts([restoredTemperatureAlert()]);
store.updateAlert(id, {
acknowledged: true,
ackTime: '2026-10-06T10:06:00Z',
ackUser: undefined,
});
emitActiveAlerts(200, [restoredTemperatureAlert()]);
expect(store.activeAlerts[id]?.acknowledged).toBe(true);
emitActiveAlerts(300, [acknowledged]);
expect(store.activeAlerts[id]).toMatchObject({
acknowledged: true,
ackTime: '2026-10-06T10:06:01Z',
ackUser: 'admin',
});
store.updateAlert(id, { acknowledged: false, ackTime: undefined, ackUser: undefined });
emitActiveAlerts(400, [acknowledged]);
expect(store.activeAlerts[id]?.acknowledged).toBe(false);
expect(store.activeAlerts[id]).not.toHaveProperty('ackUser');
emitActiveAlerts(500, [restoredTemperatureAlert()]);
expect(store.activeAlerts[id]?.acknowledged).toBe(false);
expect(store.activeAlerts[id]).not.toHaveProperty('ackTime');
expect(store.activeAlerts[id]).not.toHaveProperty('ackUser');
} finally {
dispose();
}
});
it('keeps the record a local update replaced intact for rollback', async () => {
const { store, dispose } = await createStoreHarness();
try {
await waitForOpenTick();
const id = 'pve2-temperature';
const record = {
id: 'alert:pve2-temperature',
canonicalSpecId: 'temperature',
subjectResourceId: 'node:pve2',
state: 'open',
severity: 'warning',
firstObservedAt: '2026-10-06T10:00:00Z',
lastObservedAt: '2026-10-06T10:05:00Z',
stateChangedAt: '2026-10-06T10:00:00Z',
acknowledgement: { at: '2026-10-06T10:01:00Z', by: 'admin' },
evidenceIds: ['evidence-1'],
causeKey: 'pve2-temperature',
relatedResourceIds: [],
};
emitInitialAlerts([{ ...restoredTemperatureAlert(), operationalRecord: record }]);
// As a snooze does: the optimistic record reuses nested values of the
// record it keeps for rollback.
const previous = store.activeAlerts[id]?.operationalRecord;
if (!previous) throw new Error('expected an operational record');
store.updateAlert(id, {
operationalRecord: {
...previous,
state: 'suppressed',
suppression: {
at: '2026-10-06T10:06:00Z',
by: 'current-user',
reason: 'user_snooze',
expiresAt: '2026-10-06T11:06:00Z',
},
},
});
emitActiveAlerts(200, [
{
...restoredTemperatureAlert(),
operationalRecord: {
...record,
acknowledgement: { at: '2026-10-06T10:07:00Z', by: 'operator' },
evidenceIds: ['evidence-1', 'evidence-2'],
},
},
]);
expect(store.activeAlerts[id]?.operationalRecord?.acknowledgement?.by).toBe('operator');
expect(previous.acknowledgement).toEqual({ at: '2026-10-06T10:01:00Z', by: 'admin' });
expect([...previous.evidenceIds]).toEqual(['evidence-1']);
} finally {
dispose();
}
});
it('leaves unchanged alerts and unchanged nested fields quiet', async () => {
const { store, dispose } = await createStoreHarness();
let disposeTracking = () => {};
try {
await waitForOpenTick();
const cpuAlert = (): Record<string, unknown> => ({
...heldTemperatureAlert(),
id: 'pve1-cpu',
type: 'cpu',
resourceId: 'node:pve1',
metricStatus: {
phase: 'breaching',
value: 93,
observedAt: '2026-10-06T10:05:00Z',
trigger: 90,
recovery: 85,
},
});
emitInitialAlerts([heldTemperatureAlert(), cpuAlert()]);
let temperatureRuns = 0;
let cpuRuns = 0;
createRoot((d) => {
disposeTracking = d;
createComputed(() => {
void store.activeAlerts['pve2-temperature']?.metricStatus?.value;
temperatureRuns += 1;
});
createComputed(() => {
void store.activeAlerts['pve1-cpu']?.metricStatus?.value;
cpuRuns += 1;
});
});
const cpuBefore = store.activeAlerts['pve1-cpu'];
const cpuStatusBefore = cpuBefore?.metricStatus;
const cooler = heldTemperatureAlert();
cooler.metricStatus = { ...(cooler.metricStatus as object), value: 77 };
emitActiveAlerts(200, [cooler, cpuAlert()]);
expect(store.activeAlerts['pve2-temperature']?.metricStatus?.value).toBe(77);
expect(temperatureRuns).toBe(2);
expect(cpuRuns).toBe(1);
expect(store.activeAlerts['pve1-cpu']).toBe(cpuBefore);
expect(store.activeAlerts['pve1-cpu']?.metricStatus).toBe(cpuStatusBefore);
} finally {
disposeTracking();
dispose();
}
});
it('drops fields a recently resolved alert payload omits', async () => {
const { store, dispose } = await createStoreHarness();
try {
await waitForOpenTick();
const resolved = {
...restoredTemperatureAlert(),
resolvedTime: '2026-10-06T10:10:00Z',
};
emitMessage({
type: 'initialState',
data: {
resources: [],
lastUpdate: 100,
activeAlerts: [],
recentlyResolved: [{ ...resolved, nodeDisplayName: 'PVE Two' }],
},
});
expect(store.recentlyResolved['pve2-temperature']?.nodeDisplayName).toBe('PVE Two');
const renamed: Record<string, unknown> = { ...resolved };
delete renamed.nodeDisplayName;
emitMessage({ type: 'rawData', data: { lastUpdate: 200, recentlyResolved: [renamed] } });
expect(store.recentlyResolved['pve2-temperature']).not.toHaveProperty('nodeDisplayName');
} finally {
dispose();
}
});
});
it('drops scroll-queued deltas when a full snapshot supersedes their baseline', async () => {
const { store, dispose } = await createStoreHarness();
try {
+16 -4
View File
@@ -422,7 +422,12 @@ export function createWebSocketStore(url: string) {
clearPendingAck(id);
}
setActiveAlerts(id, alert);
// Each payload is the server's whole alert. A plain path set would
// shallow-merge it, so a field the server now omits (metricStatus on an
// alert restored after a restart, ackTime/ackUser after an unacknowledge)
// would keep its old value. reconcile removes omitted keys and diffs
// nested values in place, so unchanged fields stay quiet.
setActiveAlerts(id, reconcile(alert));
});
setState('activeAlerts', Object.values(alertsMap));
@@ -1380,9 +1385,10 @@ export function createWebSocketStore(url: string) {
}
});
// Add new resolved alerts
// Add new resolved alerts, replacing rather than merging (see
// applyActiveAlerts)
Object.entries(newResolvedAlerts).forEach(([id, alert]) => {
setRecentlyResolved(id, alert);
setRecentlyResolved(id, reconcile(alert));
});
setState('recentlyResolved', Object.values(newResolvedAlerts));
@@ -1973,7 +1979,13 @@ export function createWebSocketStore(url: string) {
}, 15000);
pendingAckTimeouts.set(alertIdentifier, pendingTimeout);
}
setActiveAlerts(alertIdentifier, { ...existingAlert, ...updates });
// Incoming payloads reconcile stored alerts in place, so store a copy
// of the update: an optimistic record can share nested objects with
// the record its caller keeps for rollback.
setActiveAlerts(alertIdentifier, {
...existingAlert,
...structuredClone(unwrap(updates)),
});
}
},
};