diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index fd045bd6c..dd6267a70 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -2517,3 +2517,10 @@ alert ID in the event log. The primary remains the one delivery owner. not established causality. `incident_synthesis_test.go` pins classification, contradiction downgrade, bounded evidence, duplicate-delivery suppression, and partial-recovery behavior. + +### Alert hydration is not resource admission + +REST active-alert recovery may complete before a resource snapshot. Neither +active incidents nor alert hydration completion establishes an empty resource +estate or replaces platform navigation admission. Incident access must remain +available during socket reconnect; this does not assert notification delivery. diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 0d5a05fd7..2aed47751 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -3449,3 +3449,11 @@ they do not enable a platform, grant an entitlement, select an organization, change hosted mode, or bypass a server capability check. An unconfigured platform still resolves to infrastructure settings, and paid destinations continue to render and authorize from the existing runtime entitlement state. + +### Resource admission remains tenant scoped during reconnect + +The authenticated shell retains its platform admission facet until resource +snapshot evidence arrives; alert-only recovery is not resource evidence. +Resource snapshot receipt survives a transport reconnect but resets when the +WebSocket organisation URL changes. The no-op pre-auth store reports no resource +snapshot. Outgoing tenant admission must not authorise incoming tenant tabs. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index b76e8e6a3..f4f268985 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -3021,3 +3021,10 @@ A `patrol_digest` run performs the same bounded reads as capped action-audit page, and cost events for the window) once per weekly occurrence, under the existing schedule run mutex. It adds no polling, no per-resource fan-out, and no new persistence. + +### Reconnect admission uses constant-time snapshot evidence + +Runtime resource resolution uses a store signal recording resource snapshot +receipt, or an already populated resource array, rather than alert payload +presence. Reconnect must not add an estate fetch or scan to recover navigation. +The signal survives transport loss and resets on organisation URL changes. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 025cf3024..8a5276ed5 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -5018,3 +5018,11 @@ runtime inventory or action capability from the presence of container rows. The Docker drawer uses this canonical marker to warn and suppress update controls, while absent or unknown values retain compatibility without changing resource identity. + +### Resource snapshot receipt is independent of alert hydration + +The WebSocket store exposes resourceSnapshotReceived separately from +initialDataReceived. Only a state frame with a resources field sets this signal; +an explicit empty resources array is authoritative, while an alert-only frame +is not. Preserve the signal across transport reconnect and clear it with +organisation URL state reset. The global no-op store reports false. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 2497ac4b0..39aaeb883 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,35 +1,46 @@ { "version": 1, - "base_sha": "3c5d4265439d92495f35f2460c3f666330a239e5", - "verified_at": "2026-09-05T00:33:47Z", + "base_sha": "193ead50fd9559b92ee242f34b8e9718fd7a3e3e", + "verified_at": "2026-09-05T03:43:42Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx" + "frontend-modern/src/stores/websocket-global.ts", + "frontend-modern/src/stores/websocket.ts", + "frontend-modern/src/useAppRuntimeState.ts" ], "content_sha256": { - "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "0abd4bcd970e5836e8903d4140b638e7637a74b14dd1f2f6b53b338ae1e31fb3" + "frontend-modern/src/stores/websocket-global.ts": "abacee8810729f0dbded90f0b8be99ad09936134160b631b74967e0751f2919f", + "frontend-modern/src/stores/websocket.ts": "253b45b6c9694ad39c881cf8b592398962ae5fa6849a1590812fbb634649fb9b", + "frontend-modern/src/useAppRuntimeState.ts": "e879a23baf48caf99359c63cf4c0c637156d27a28ab823f62c19e14052fd1643" }, "routes": [ - "/proxmox/backups/coverage (isolated production component/router/CSS fixture; not full application)" + "/proxmox", + "/docker", + "/alerts" ], "viewports": [ { "width": 1440, "height": 900 }, + { + "width": 1100, + "height": 900 + }, { "width": 390, "height": 900 } ], "states": [ - "40 synthetic workloads with mid-table restore evidence expanded", - "Three replacement HTTP polling snapshots render updated workload names with focused toggle and expanded evidence retained", - "Native scroll remains 532px desktop and 503px narrow; route unchanged and no page errors" + "Synthetic populated Proxmox and Docker inventory in isolated real Go backend with production embedded frontend", + "Connected socket closed with 1013; reconnect attempts blocked while HTTP health remains available", + "Platform destinations retained during interruption and automatic recovery without reload; desktop navigation intentionally hidden below xl", + "Desktop active incidents retain enabled acknowledgement control; no acknowledgement persistence or notification receipt claim" ], "interactions": [ - "Focus expansion toggle and press Enter, then retain focus across three replacement snapshots", - "Run pulse-heavy-run -- node scripts/check-backup-browser-polling.mjs", - "Run pulse-heavy-run -- env PULSE_BROWSER_WIDTH=390 node scripts/check-backup-browser-polling.mjs; keyboard interaction only, not touch qualification" + "pulse-heavy-run -- env PULSE_E2E_USE_LOCAL_BACKEND=1 PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1 PULSE_MOCK_MODE=true PULSE_E2E_NAVIGATION_RECOVERY=1 PULSE_E2E_LOCAL_BACKEND_PORT=18765 npm --prefix tests/integration test -- tests/96-navigation-socket-recovery.spec.ts --project=chromium", + "Three Chromium checks passed; fresh contexts at 100% zoom; desktop and 390px reconnect screenshots inspected. Mobile menu interaction not exercised.", + "Open Alerts during interruption at 1440px; unblock socket and verify recovery without reload" ] } diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index 75fb42a40..55c5f1512 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -866,6 +866,20 @@ describe('App architecture', () => { expect(appLayoutSource).toContain("label: 'Actions'"); }); + it('keeps alert hydration separate from tenant resource admission', () => { + const start = appRuntimeStateSource.indexOf('const runtimeStateResolved'); + const predicate = appRuntimeStateSource.slice( + start, appRuntimeStateSource.indexOf('const state =', start), + ); + expect(start).toBeGreaterThan(-1); + expect(predicate).toContain('resourceSnapshotReceived()'); + expect(predicate).not.toContain('initialDataReceived()'); + expect(predicate).not.toContain('hasRuntimeStatePayload'); + const storeSource = readFileSync(join(process.cwd(), 'src/stores/websocket.ts'), 'utf8'); + const reset = storeSource.slice(storeSource.indexOf('setResourceSnapshotReceived(false)')); + expect(reset).toContain("setActiveAlertsHydrationStatus('pending')"); + }); + it('drops platform admission when the tenant changes', () => { const switchStart = appRuntimeStateSource.indexOf('const handleOrgSwitch'); expect(switchStart).toBeGreaterThan(-1); diff --git a/frontend-modern/src/__tests__/useAppRuntimeState.test.ts b/frontend-modern/src/__tests__/useAppRuntimeState.test.ts index 827e273eb..7b65aee58 100644 --- a/frontend-modern/src/__tests__/useAppRuntimeState.test.ts +++ b/frontend-modern/src/__tests__/useAppRuntimeState.test.ts @@ -155,6 +155,7 @@ describe('useAppRuntimeState', () => { connected: () => websocketConnected, reconnecting: () => websocketReconnecting, initialDataReceived: () => websocketInitialDataReceived, + resourceSnapshotReceived: () => websocketInitialDataReceived, reconnect: vi.fn(), switchUrl: vi.fn(), }), @@ -695,6 +696,18 @@ describe('useAppRuntimeState', () => { dispose(); }); + it('does not mistake alert-only reconnect recovery for an empty resource snapshot', async () => { + websocketState = makeWebSocketState({ activeAlerts: [{ id: 'recovered-alert' } as State['activeAlerts'][number]] }); + websocketConnected = false; + websocketReconnecting = true; + websocketInitialDataReceived = false; + const { hookState, dispose } = mountHook(); + await waitFor(() => expect(hookState.enhancedStore()).not.toBeNull()); + expect(hookState.state().activeAlerts).toHaveLength(1); + expect(hookState.runtimeStateResolved()).toBe(false); + dispose(); + }); + it('distinguishes an evidence-free first load from an authenticated empty estate', async () => { // The distinction still matters: an estate with nothing in it must resolve // to "no platform pages", not sit unresolved forever. It is now answered by diff --git a/frontend-modern/src/stores/__tests__/websocket-unified.test.ts b/frontend-modern/src/stores/__tests__/websocket-unified.test.ts index 6ef2ae801..61073809c 100644 --- a/frontend-modern/src/stores/__tests__/websocket-unified.test.ts +++ b/frontend-modern/src/stores/__tests__/websocket-unified.test.ts @@ -126,6 +126,25 @@ describe('websocket store unified resource contract', () => { vi.unstubAllGlobals(); }); + it('tracks resource admission separately from status frames and retains it through reconnect only', async () => { + const { store, dispose } = await createStoreHarness(); + try { + await waitForOpenTick(); + emitMessage({ type: 'rawData', data: { lastUpdate: 1, activeAlerts: {} } }); + expect(store.initialDataReceived()).toBe(true); + expect(store.resourceSnapshotReceived()).toBe(false); + emitMessage({ type: 'rawData', data: { resources: [] } }); + expect(store.resourceSnapshotReceived()).toBe(true); + mockWsInstance?.onclose?.({ code: 1013, reason: 'interruption' } as CloseEvent); + expect(store.initialDataReceived()).toBe(false); + expect(store.resourceSnapshotReceived()).toBe(true); + store.switchUrl('ws://localhost/ws?org=another'); + expect(store.resourceSnapshotReceived()).toBe(false); + } finally { + dispose(); + } + }); + it('initializes with empty resources array only', async () => { const { store, dispose } = await createStoreHarness(); try { diff --git a/frontend-modern/src/stores/websocket-global.ts b/frontend-modern/src/stores/websocket-global.ts index a0f23fd70..08400ca3b 100644 --- a/frontend-modern/src/stores/websocket-global.ts +++ b/frontend-modern/src/stores/websocket-global.ts @@ -63,6 +63,7 @@ const createNoopWebSocketStore = (): ReturnType => connected, reconnecting, initialDataReceived, + resourceSnapshotReceived: () => false, activeAlertsHydrationStatus, updateProgress, resourceChange, diff --git a/frontend-modern/src/stores/websocket.ts b/frontend-modern/src/stores/websocket.ts index 34914ed2a..e3f1f34b1 100644 --- a/frontend-modern/src/stores/websocket.ts +++ b/frontend-modern/src/stores/websocket.ts @@ -275,6 +275,9 @@ export function createWebSocketStore(url: string) { const [connected, setConnected] = createSignal(false); const [reconnecting, setReconnecting] = createSignal(false); const [initialDataReceived, setInitialDataReceived] = createSignal(false); + // Display admission survives transport reconnects, but not an organisation + // change. Alerts/status alone cannot establish an empty resource estate. + const [resourceSnapshotReceived, setResourceSnapshotReceived] = createSignal(false); const createInitialState = (): State => ({ // Canonical v6 state comes from unified resources. connectedInfrastructure: [], @@ -1210,6 +1213,7 @@ export function createWebSocketStore(url: string) { let changedResourceIds: ReadonlySet | undefined; let changedResourceKeys: ResourceChangedKeys | undefined; if (message.data.resources !== undefined) { + setResourceSnapshotReceived(true); // A full snapshot supersedes every pending deferral, and queued // deltas reference the baseline this snapshot replaces. deferredResourceIds.clear(); @@ -1850,6 +1854,7 @@ export function createWebSocketStore(url: string) { connected, reconnecting, initialDataReceived, + resourceSnapshotReceived, activeAlertsHydrationStatus, updateProgress, resourceChange, @@ -1890,6 +1895,7 @@ export function createWebSocketStore(url: string) { setConnected(false); setReconnecting(false); setInitialDataReceived(false); + setResourceSnapshotReceived(false); setActiveAlertsHydrationStatus('pending'); setUpdateProgress(null); setResourceChange({ diff --git a/frontend-modern/src/useAppRuntimeState.ts b/frontend-modern/src/useAppRuntimeState.ts index 6b38b11b2..a23209686 100644 --- a/frontend-modern/src/useAppRuntimeState.ts +++ b/frontend-modern/src/useAppRuntimeState.ts @@ -200,10 +200,9 @@ export const useAppRuntimeState = () => { const [backendHealthy, setBackendHealthy] = createSignal(false); const runtimeStateResolved = (): boolean => { const store = wsStore(); - // Deliberately not "some payload arrived": a bootstrap that carried no - // resources would still read as resolved and navigation would classify an - // empty estate, hiding every platform tab. - return Boolean(store?.initialDataReceived()) || hasRuntimeStatePayload(store?.state); + // Alert REST recovery can populate state before the first resource frame. + // It must not replace valid admission with an invented empty estate. + return Boolean(store?.resourceSnapshotReceived()) || (store?.state.resources.length ?? 0) > 0; }; const state = (): State => { const store = wsStore(); diff --git a/tests/integration/tests/96-navigation-socket-recovery.spec.ts b/tests/integration/tests/96-navigation-socket-recovery.spec.ts new file mode 100644 index 000000000..e73cd6d93 --- /dev/null +++ b/tests/integration/tests/96-navigation-socket-recovery.spec.ts @@ -0,0 +1,78 @@ +import { expect, test, type Page, type WebSocketRoute } from '@playwright/test'; +import { ensureAuthenticated } from './helpers'; + +// Opt-in: uses synthetic inventory in an isolated real backend. Never interrupts +// a shared runtime or substitutes the application's websocket store. +const enabled = process.env.PULSE_E2E_NAVIGATION_RECOVERY === '1' && + process.env.PULSE_E2E_USE_LOCAL_BACKEND === '1'; +const healthy = 'Backend and live data stream are connected.'; + +async function navigation(page: Page) { + return page.locator('[aria-label="Primary navigation"]').evaluateAll(nodes => + nodes.map(node => ({ + visible: node.getBoundingClientRect().height > 0, + tabs: Array.from(node.querySelectorAll('.tab')).map(tab => ({ + label: tab.getAttribute('aria-label')?.replace(/^\d+ /, '').split(':')[0], + // Counts may change independently while the socket recovers. + text: (tab as HTMLElement).innerText.replace(/\d+/g, '').replace(/\s+/g, ' ').trim(), + icons: tab.querySelectorAll('svg').length, + visible: tab.getBoundingClientRect().height > 0, + })), + })), + ); +} + +for (const width of [1440, 1100, 390]) { + test(`populated navigation survives socket loss at ${width}px`, async ({ page, browser }, testInfo) => { + test.skip(!enabled, 'Requires isolated mock backend and explicit qualification opt-in'); + test.setTimeout(120_000); + await page.setViewportSize({ width, height: 900 }); + let blocked = false; + const sockets: WebSocketRoute[] = []; + await page.routeWebSocket('**/ws*', async socket => { + if (blocked) { + await socket.close({ code: 1013, reason: 'qualification interruption' }); + return; + } + socket.connectToServer(); + sockets.push(socket); + }); + await ensureAuthenticated(page); + await page.goto('/proxmox'); + await expect(page.locator('[data-proxmox-host-row]').first()).toBeVisible({ timeout: 60_000 }); + await expect(page.locator('tr[data-guest-id]').first()).toBeVisible(); + await expect(page.getByRole('status', { name: healthy })).toBeVisible(); + // Verify both platform inventories before interrupting the live connection. + await page.goto('/docker'); + await expect(page.locator('tr[data-docker-host-row]').first()).toBeVisible(); + await expect(page.locator('tr[data-docker-container-row]').first()).toBeVisible(); + await expect(page.getByRole('status', { name: healthy })).toBeVisible(); + const before = await navigation(page); + expect(before).toHaveLength(1); + expect(before[0].visible).toBe(width >= 1280); + expect(before[0].tabs.map(tab => tab.label)).toEqual(expect.arrayContaining(['Proxmox', 'Docker'])); + const capture = async (stage: string) => { + await testInfo.attach(stage, { body: await page.screenshot({ path: testInfo.outputPath(`${stage}.png`) }), contentType: 'image/png' }); + }; + await capture('healthy'); + const documentIdentity = await page.evaluate(() => performance.timeOrigin); + blocked = true; + for (const socket of sockets) await socket.close({ code: 1013, reason: 'qualification interruption' }); + await expect(page.getByRole('status', { name: 'Backend is healthy. Live updates are reconnecting.' })).toBeVisible({ timeout: 30_000 }); + expect((await page.request.get('/api/health')).ok()).toBeTruthy(); + await capture('reconnecting'); + expect.soft(await navigation(page)).toEqual(before); + await expect(page.locator('tr[data-docker-container-row]').first()).toBeVisible(); + if (width >= 1280) { + await page.locator('[aria-label="Primary navigation"]').getByRole('link', { name: /Alerts/ }).click(); + await expect(page.getByRole('button', { name: 'Acknowledge', exact: true }).first()).toBeEnabled(); + await capture('active-incidents-during-reconnect'); + } + blocked = false; + await expect(page.getByRole('status', { name: healthy })).toBeVisible({ timeout: 45_000 }); + expect.soft(await navigation(page)).toEqual(before); + expect(await page.evaluate(() => performance.timeOrigin)).toBe(documentIdentity); + await capture('recovered'); + await testInfo.attach('environment', { body: JSON.stringify({ browser: browser.version(), width, height: 900, zoom: 1, before }), contentType: 'application/json' }); + }); +} diff --git a/tests/qualification/navigation-reconnect/README.md b/tests/qualification/navigation-reconnect/README.md new file mode 100644 index 000000000..f34b9badc --- /dev/null +++ b/tests/qualification/navigation-reconnect/README.md @@ -0,0 +1,70 @@ +# Navigation during cold-stream reconnect (#1899) + +## Reproduction and scope — 5 September 2026 + +Source-built v6.4.1 (`db7e26deac2a77dd5eff1dfb5bf2f1546683d5d6`) and +integrated main (`193ead50fd9559b92ee242f34b8e9718fd7a3e3e`) both lost all six +platform destinations after a real connected browser websocket was closed with +1013 during startup. HTTP health remained available, Docker inventory remained +visible and System controls survived. Socket recovery alone did not restore the +destinations in either baseline run. This reproduces a path consistent with +https://github.com/rcourtman/Pulse/issues/1899, not the reporter's LXC installation. + +Chromium 141.0.7390.37, fresh contexts, 100% zoom, 1440×900 and 1100×900; +real isolated Go backend, embedded production frontend, synthetic Proxmox and +Docker inventory. Below xl the desktop navigation is intentionally hidden even +before interruption; its destinations disappeared from the DOM in the failing +baseline. This is not a new responsive-navigation requirement. + +Alert REST recovery can populate `activeAlerts` before the first resource frame. +The previous runtime-resolution predicate treated that as authoritative resource +state, replacing the admission facet with an empty estate. The repair tracks +receipt of a resource snapshot independently: preserve this knowledge during +transport reconnect, clear it on organisation URL changes, and continue treating +an explicit empty resource snapshot as authoritative. No navigation redesign. + +## Repeat locally + +Install locked dependencies in the repository root, `frontend-modern` and +`tests/integration`, then run from the repository root: + +```sh +pulse-heavy-run -- env \ + PULSE_E2E_USE_LOCAL_BACKEND=1 PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1 \ + PULSE_MOCK_MODE=true PULSE_E2E_NAVIGATION_RECOVERY=1 \ + PULSE_E2E_LOCAL_BACKEND_PORT=18765 \ + npm --prefix tests/integration test -- \ + tests/96-navigation-socket-recovery.spec.ts --project=chromium +``` + +Install the pinned Playwright browser first if absent. The opt-in runner builds, +starts and stops an isolated backend; it does not use a shared application. +Screenshots and browser/viewport metadata are attached to the Playwright report. +Counts are excluded from navigation equality because alerts can change normally. + +The repaired main run passes both widths, including desktop navigation into +active incidents and an enabled acknowledgement control while disconnected; +recovery requires no page reload. This is access/control availability evidence, +not acknowledgement persistence or notification delivery qualification. +102 focused runtime, websocket and architecture tests pass. Frontend typecheck +remains blocked by two errors in untouched `TrueNASAlertsTable.test.tsx` +(lines 83 and 119: `healthy` is not a `ResourceStatus`). + +## Remaining boundaries + +No published artifact, candidate branch, reporter browser, reverse proxy, +admission-HTTP-failure injection, mobile menu interaction or long outage soak +was qualified here. A release-line backport needs reproduction and repair +verification on that exact candidate, followed by its restarted soak. Test the +admission-failure path separately rather than assuming this repairs it. This +opt-in spec does not become an automatically executed release gate by existing. + +## Bounded preflight repair + +Matching alert, cloud-paid, performance and unified-resource contracts now +record the snapshot/admission boundary, with an additional architecture guard. +Fresh repair validation: 87 focused tests pass; three real-backend Chromium +checks pass at 1440, 1100 and 390×900. Desktop and narrow reconnect screenshots +were inspected. At 390px the Docker inventory and bottom navigation remain +visible; this does not exercise the More menu or mobile platform switching. +The matching browser receipt records the exact runtime source hashes.