diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 29009d16b..be74635de 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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" ] } diff --git a/frontend-modern/src/stores/__tests__/websocket-unified.test.ts b/frontend-modern/src/stores/__tests__/websocket-unified.test.ts index 26fc6c28f..8eb5811e2 100644 --- a/frontend-modern/src/stores/__tests__/websocket-unified.test.ts +++ b/frontend-modern/src/stores/__tests__/websocket-unified.test.ts @@ -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 { diff --git a/frontend-modern/src/stores/websocket.ts b/frontend-modern/src/stores/websocket.ts index c1d635fdc..02f44f9d6 100644 --- a/frontend-modern/src/stores/websocket.ts +++ b/frontend-modern/src/stores/websocket.ts @@ -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;