mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 03:33:53 +00:00
fix(websocket): apply resource deltas to the raw server baseline
The rc.9 delta stream (962d29780) diffs resources against the server's
per-client snapshot, but the client applied those patches to its
canonically merged store. On estates where the canonical coalesce folds
agent and runtime-platform hosts into one resource the two domains
disagree: a merge patch for a coalesced-away ID materialised as a
typeless stub resource, and a server-side removal of the merged
carrier's ID deleted both facets with nothing ever re-adding the
survivor. Resources then vanished progressively — platform tabs
included, since navigation visibility derives from state.resources.
Reported on the #1601 rc.9 retest for both admin and read-only users;
the 403s in that report are an unrelated non-admin update poll.
Keep a pristine copy of the server's resource array, apply deltas to
that raw baseline (the exact objects the server diffed against), then
derive the canonical view through the same merge the full-snapshot path
already uses. Delta application now converges to the same state a full
snapshot would produce, by construction. The raw baseline stays
isolated from the store: entries are structuredClone'd before the
canonical merge because the merge output shares nested references and
reconcile mutates adopted objects in place. A delta arriving before any
full snapshot (for example after an oversized initial payload was
dropped) now requests a full snapshot instead of patching nothing.
The flipped status assertion in websocket-unified.test.ts is
deliberate: the old delta path deleted keys that the canonical merge
preserves, so it disagreed with the full-snapshot path; the assertion
now pins delta/full parity.
Refs #1601
Contract-Neutral: client-side websocket delta application fix; wire protocol and payload shapes unchanged
This commit is contained in:
@@ -1,42 +1,42 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "8fd43b307bf3faabc9c362d1ebccf1830be9ca0d",
|
||||
"verified_at": "2026-08-07T15:30:51Z",
|
||||
"base_sha": "0928071b9a1342c824cc75dd3ef6047c86c67b64",
|
||||
"verified_at": "2026-08-07T14:05:00Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/api/resourceActions.ts",
|
||||
"frontend-modern/src/features/actions/ActionReviewDialog.tsx",
|
||||
"frontend-modern/src/types/actionAudit.ts",
|
||||
"frontend-modern/src/utils/actionAuditPresentation.ts"
|
||||
"frontend-modern/src/stores/websocket.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/api/resourceActions.ts": "0c4bac0a4f779f6afeee39f45ad46d51baf0b1b5a47ebdc3f424148804f1c329",
|
||||
"frontend-modern/src/features/actions/ActionReviewDialog.tsx": "992b1203fb775cf2e1eb86ffc04f1ce5ca5e072f26dedfbb0f9338459022c2eb",
|
||||
"frontend-modern/src/types/actionAudit.ts": "e44b0cf97b070c63da667fa035a2a5f427ccc58936b7a4296e520eec1f9a1ed0",
|
||||
"frontend-modern/src/utils/actionAuditPresentation.ts": "fd1ae3e44b9881d7324845520cfdeaf958dcb30bf62a59f6ec69d342ca13290a"
|
||||
"frontend-modern/src/stores/websocket.ts": "0625744f76128b42b3802ffb3880a5169013e11343f4b74332d171be05d77ae2"
|
||||
},
|
||||
"routes": [
|
||||
"/actions?action=action-browser-1"
|
||||
"/proxmox/overview",
|
||||
"/docker/overview",
|
||||
"/standalone/machines",
|
||||
"/alerts/overview"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 1000
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
"width": 375,
|
||||
"height": 812
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Pending action whose executor reports an exact command-agent readiness refusal",
|
||||
"Drifted pending action with a refreshable replacement plan",
|
||||
"Fresh replacement action ready for a new operator approval"
|
||||
"Live dev estate with 263 resources across 48 types including agent-merged Proxmox nodes (Agent chip on Disaster Recovery A) and docker hosts",
|
||||
"WebSocket delta stream active: initialState full snapshot followed by continuous resourceDelta-only rawData messages",
|
||||
"Machines page with two standalone agent hosts",
|
||||
"Docker overview with two hosts and eight containers",
|
||||
"Mobile viewport after full reload re-hydrating from a fresh initialState plus deltas"
|
||||
],
|
||||
"interactions": [
|
||||
"Confirmed the exact executor-owned refusal and remediation are visible on desktop, Reject remains available, and Approve is absent",
|
||||
"Confirmed the mobile dialog stays within the 390px viewport with no document overflow and keeps Close, Refresh plan, and Reject reachable",
|
||||
"Refreshed the drifted plan through the real dialog and API client, confirmed the success notice, removed Refresh plan, and exposed Approve only for the fresh replacement",
|
||||
"Reset the viewport override and finalized the browser session"
|
||||
"Logged in and watched the delta stream for over five minutes across dozens of resourceDelta messages; resource count stayed exactly 263 after every delta application with zero console errors",
|
||||
"Navigated Proxmox, Docker, Machines, and Alerts surfaces mid-stream and confirmed all six platform navigation tabs stayed present the whole time",
|
||||
"Confirmed the Machines table kept both agent hosts and the Docker overview kept both hosts and all eight containers across delta cycles",
|
||||
"Reloaded at 375x812 and confirmed the mobile platform rail and Proxmox overview re-hydrated fully from initialState plus deltas",
|
||||
"Confirmed live metric values (CPU and memory cells) continued updating from delta payloads while resource membership stayed stable"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -313,13 +313,121 @@ describe('websocket store unified resource contract', () => {
|
||||
agent: { osName: 'Debian', agentVersion: '6.2.0' },
|
||||
disks: [{ name: 'sda', usage: 20 }],
|
||||
});
|
||||
expect(agent).not.toHaveProperty('status');
|
||||
// Deltas must land exactly where a full snapshot would: the canonical
|
||||
// merge keeps the previous status when the server payload omits it, so
|
||||
// the delta path has to agree with the full-snapshot path here.
|
||||
expect(agent?.status).toBe('online');
|
||||
expect(store.state.lastUpdate).toBe(200);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps canonically merged hosts consistent with full snapshots across deltas (#1601)', async () => {
|
||||
const { store, dispose } = await createStoreHarness();
|
||||
try {
|
||||
await waitForOpenTick();
|
||||
|
||||
// Two server resources that the client coalesces into ONE host: an
|
||||
// agent host and a docker host sharing a hostname. The server's
|
||||
// per-client delta baseline keeps both IDs, so deltas reference IDs
|
||||
// the merged client view no longer holds.
|
||||
emitMessage({
|
||||
type: 'initialState',
|
||||
data: {
|
||||
connectedInfrastructure: [],
|
||||
resources: [
|
||||
{
|
||||
id: 'agent-host-1',
|
||||
type: 'agent',
|
||||
name: 'docker-01',
|
||||
status: 'online',
|
||||
lastSeen: 100,
|
||||
sources: ['agent'],
|
||||
},
|
||||
{
|
||||
id: 'docker-host-1',
|
||||
type: 'agent',
|
||||
name: 'docker-01',
|
||||
status: 'online',
|
||||
lastSeen: 100,
|
||||
sources: ['docker'],
|
||||
},
|
||||
],
|
||||
lastUpdate: 100,
|
||||
activeAlerts: [],
|
||||
recentlyResolved: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.state.resources).toHaveLength(1);
|
||||
expect(store.state.resources[0]?.id).toBe('agent-host-1');
|
||||
|
||||
// A merge patch for the coalesced-away docker ID must not surface a
|
||||
// typeless stub resource: the delta applies to the raw server baseline
|
||||
// and the canonical merge runs again on the result.
|
||||
emitMessage({
|
||||
type: 'rawData',
|
||||
data: {
|
||||
lastUpdate: 200,
|
||||
resourceDelta: {
|
||||
upserts: [{ id: 'docker-host-1', lastSeen: 200 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.state.resources).toHaveLength(1);
|
||||
expect(store.state.resources[0]?.id).toBe('agent-host-1');
|
||||
expect(store.state.resources.every((resource) => Boolean(resource.type))).toBe(true);
|
||||
|
||||
// Removing the agent side server-side must leave the surviving docker
|
||||
// host visible. Before the raw-baseline fix this removed the single
|
||||
// merged client resource outright and the docker host never came back.
|
||||
emitMessage({
|
||||
type: 'rawData',
|
||||
data: {
|
||||
lastUpdate: 300,
|
||||
resourceDelta: {
|
||||
removed: ['agent-host-1'],
|
||||
order: ['docker-host-1'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.state.resources).toHaveLength(1);
|
||||
expect(store.state.resources[0]?.id).toBe('docker-host-1');
|
||||
expect(store.state.resources[0]?.type).toBe('agent');
|
||||
expect(store.state.resources[0]?.lastSeen).toBe(200);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('requests a full snapshot instead of applying a delta without a baseline', async () => {
|
||||
const { store, dispose } = await createStoreHarness();
|
||||
try {
|
||||
await waitForOpenTick();
|
||||
|
||||
emitMessage({
|
||||
type: 'rawData',
|
||||
data: {
|
||||
lastUpdate: 100,
|
||||
resourceDelta: {
|
||||
upserts: [{ id: 'agent-1', cpu: { current: 42 } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.state.resources).toHaveLength(0);
|
||||
const sentTypes = (mockWsInstance?.send.mock.calls ?? []).map(
|
||||
(call) => JSON.parse(call[0] as string).type,
|
||||
);
|
||||
expect(sentTypes).toContain('requestData');
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves connected infrastructure when raw updates omit that projection', async () => {
|
||||
const { store, dispose } = await createStoreHarness();
|
||||
try {
|
||||
|
||||
@@ -302,6 +302,15 @@ export function createWebSocketStore(url: string) {
|
||||
}
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
// Pristine copy of the server's resource array. Resource deltas are diffed
|
||||
// against the server's per-client snapshot, so they must be applied to this
|
||||
// raw payload — never to the canonically merged `state.resources`, whose
|
||||
// coalesced host IDs and enriched fields no longer match the server
|
||||
// baseline. Kept isolated from the store: entries handed to the canonical
|
||||
// merge are cloned first, because the merge output shares nested references
|
||||
// and Solid's reconcile mutates adopted objects in place.
|
||||
let rawServerResources: Resource[] | null = null;
|
||||
let lastDeltaRecoveryRequestAt = 0;
|
||||
let reconnectTimeout = 0;
|
||||
let reconnectDelayTimeout = 0;
|
||||
let lastServerActivityAt = Date.now();
|
||||
@@ -428,6 +437,16 @@ export function createWebSocketStore(url: string) {
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const requestFullStateRecovery = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastDeltaRecoveryRequestAt < 30000) return;
|
||||
lastDeltaRecoveryRequestAt = now;
|
||||
logger.warn('Received resource delta without a full snapshot baseline; requesting full state');
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'requestData' }));
|
||||
}
|
||||
};
|
||||
|
||||
const setupWebSocket = () => {
|
||||
if (!ws || isDisposed) return;
|
||||
|
||||
@@ -534,17 +553,36 @@ export function createWebSocketStore(url: string) {
|
||||
// Handle unified resources
|
||||
let nextResources: Resource[] | undefined;
|
||||
if (message.data.resources !== undefined) {
|
||||
nextResources = Array.isArray(message.data.resources)
|
||||
? mergeCanonicalResourceSnapshot(message.data.resources, state.resources)
|
||||
: [];
|
||||
if (Array.isArray(message.data.resources)) {
|
||||
rawServerResources = structuredClone(message.data.resources) as Resource[];
|
||||
nextResources = mergeCanonicalResourceSnapshot(
|
||||
message.data.resources,
|
||||
state.resources,
|
||||
);
|
||||
} else {
|
||||
rawServerResources = [];
|
||||
nextResources = [];
|
||||
}
|
||||
} else if (
|
||||
'resourceDelta' in message.data &&
|
||||
message.data.resourceDelta !== undefined
|
||||
) {
|
||||
nextResources = applyResourceStateDelta(
|
||||
state.resources,
|
||||
message.data.resourceDelta,
|
||||
);
|
||||
if (rawServerResources) {
|
||||
rawServerResources = applyResourceStateDelta(
|
||||
rawServerResources,
|
||||
message.data.resourceDelta,
|
||||
);
|
||||
nextResources = mergeCanonicalResourceSnapshot(
|
||||
structuredClone(rawServerResources) as Resource[],
|
||||
state.resources,
|
||||
);
|
||||
} else {
|
||||
// A delta landed before any full snapshot (e.g. the initial
|
||||
// payload was dropped as oversized). There is no baseline to
|
||||
// patch, so ask the server for a full snapshot instead of
|
||||
// applying the delta to nothing and rendering stubs.
|
||||
requestFullStateRecovery();
|
||||
}
|
||||
}
|
||||
if (nextResources !== undefined) {
|
||||
logger.debug('[WebSocket] Updating resources', {
|
||||
@@ -888,6 +926,8 @@ export function createWebSocketStore(url: string) {
|
||||
setRecentlyResolved(reconcile({}));
|
||||
});
|
||||
|
||||
rawServerResources = null;
|
||||
lastDeltaRecoveryRequestAt = 0;
|
||||
clearReconnectTimeout();
|
||||
clearReconnectDelayTimeout();
|
||||
reconnectAttempt = 0;
|
||||
|
||||
Reference in New Issue
Block a user