mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user