fix(web): preserve platform admission through cold reconnect

Keep resource snapshot receipt separate from alert hydration and tenant-scoped across reconnect. Include matching subsystem contracts, architecture coverage and fresh browser receipt.

Validation: 87 focused tests and three real-backend Chromium checks pass at 1440, 1100 and 390px. Exact release-candidate qualification remains outstanding.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-05 04:43:43 +01:00
parent 193ead50fd
commit 672a497076
13 changed files with 256 additions and 15 deletions
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+22 -11
View File
@@ -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"
]
}
@@ -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);
@@ -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
@@ -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 {
@@ -63,6 +63,7 @@ const createNoopWebSocketStore = (): ReturnType<typeof createWebSocketStore> =>
connected,
reconnecting,
initialDataReceived,
resourceSnapshotReceived: () => false,
activeAlertsHydrationStatus,
updateProgress,
resourceChange,
+6
View File
@@ -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<string> | 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({
+3 -4
View File
@@ -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();
@@ -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' });
});
}
@@ -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.