Fix platform navigation during stream reconnects

This commit is contained in:
rcourtman
2026-07-22 22:53:20 +01:00
parent 82083378a2
commit 8510243094
8 changed files with 267 additions and 37 deletions
@@ -3103,6 +3103,12 @@ query...`, and `Reading storage...` before streamed tool arguments are
source-owned (`Patrol run attached`, `Patrol mode ... attached`)
and the drawer must still exclude raw provider payloads, commands, and
Patrol-authored remediation steps.
Platform tabs in that same `frontend-modern/src/App.tsx` shell may remain
admitted after a current authenticated REST bootstrap or from retained
server-owned WebSocket state during stream recovery. That navigation
evidence must remain independent from Assistant session, handoff, and drawer
state, and browser-local Assistant or resource metadata must not resolve
platform navigation on an otherwise evidence-free first load.
Reloaded Assistant sessions may consume the backend-owned
`handoff_summary` only as safe presentation state and a Patrol finding
pointer; hidden model context, command payloads, preflight data, and action
@@ -943,6 +943,13 @@ the App/AppLayout, routing, and desktop Actions journey tests.
still belongs to the selected platform before reuse and must not become
hosted org bootstrap, entitlement, billing, acquisition, or cross-platform
query state.
Platform-navigation admission at that root shell must accept a completed
current authenticated REST bootstrap or retained server-owned WebSocket
state even while the stream has not delivered a new initial snapshot. An
authenticated empty REST response or completed empty WebSocket snapshot may
resolve the settings/alerts fallback, but browser-local metadata must never
resolve platform tabs or trigger the root redirect on an evidence-free
first load.
The same AppLayout shell may contextualize the closed Pulse Assistant
launcher around the current monitoring, Patrol, Alerts, or Settings route,
but that launcher must remain a local product affordance. It must not read
@@ -153,6 +153,11 @@ admission records a stable refusal without invoking executor or network code.
visible signed-in chrome, but it must not introduce an additional
pre-protected-state fetch, route preload, organization probe, or commercial
posture request just to resolve display identity.
The same bootstrap may expose a local resolution signal derived only from
the current authenticated REST response, a completed WebSocket snapshot,
or retained server-owned runtime state. Platform navigation may consume
that signal without adding a second state fetch, websocket subscription,
route preload, or browser-local cache read on the authenticated hot path.
17. `internal/api/slo.go` shared with `api-contracts`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary.
Governed action decisions preserve SQLite and MemoryStore parity through one
@@ -1376,6 +1376,12 @@ recovery scope, or a storage/recovery-owned secret source.
may hide top-bar org chrome for public demo posture, but it must not leak
into storage/recovery preview route ownership, first-session recovery copy,
or route-level framing decisions.
That landing decision may treat a completed current authenticated REST
bootstrap or retained server-owned WebSocket state as sufficient
platform-navigation evidence during stream recovery. It must continue
waiting when neither server-owned condition exists, so stale browser-local
metadata cannot send an evidence-free first load to a platform, storage, or
recovery route.
Retired `/operations/*` browser entry points are unregistered. They must
not grow a second authenticated shell boundary that competes with
storage/recovery route ownership.
+24 -9
View File
@@ -71,6 +71,7 @@ import {
type PlatformNavigationVisibility,
type PrimaryPlatformNavId,
} from '@/features/platformNavigation/platformNavigationModel';
import type { Resource } from '@/types/resource';
function isPublicRoutePath(pathname: string): boolean {
// Public routes must be viewable without authentication.
@@ -117,7 +118,22 @@ const PRIMARY_INFRASTRUCTURE_ROUTE_BY_ID: Record<PrimaryPlatformNavId, string> =
standalone: buildStandalonePath(),
};
function getDefaultWorkspaceRoute(
export function resolvePlatformNavigationAdmission(
resources: readonly Resource[],
runtimeStateResolved: boolean,
): {
resolved: boolean;
visibility: PlatformNavigationVisibility;
} {
return {
resolved: runtimeStateResolved,
visibility: runtimeStateResolved
? buildPrimaryPlatformNavigationVisibility(resources)
: createEmptyPlatformNavigationVisibility(),
};
}
export function getDefaultWorkspaceRoute(
visibility: PlatformNavigationVisibility,
hasSettingsAccess: boolean,
): string {
@@ -269,15 +285,14 @@ function App() {
const navigate = useNavigate();
const location = useLocation();
const isPublicRoute = createMemo(() => isPublicRoutePath(location.pathname));
const platformNavigationResolved = createMemo(() => {
const store = runtime.enhancedStore();
return Boolean(store?.initialDataReceived?.());
});
const platformNavigationVisibility = createMemo(() =>
platformNavigationResolved()
? buildPrimaryPlatformNavigationVisibility(runtime.state().resources || [])
: createEmptyPlatformNavigationVisibility(),
const platformNavigationAdmission = createMemo(() =>
resolvePlatformNavigationAdmission(
runtime.state().resources || [],
runtime.runtimeStateResolved(),
),
);
const platformNavigationResolved = () => platformNavigationAdmission().resolved;
const platformNavigationVisibility = () => platformNavigationAdmission().visibility;
const hasSettingsAccess = createMemo(() => {
const scopes = runtime.securityStatus()?.tokenScopes;
return (
@@ -1,12 +1,14 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { getDefaultWorkspaceRoute, resolvePlatformNavigationAdmission } from '@/App';
import appSource from '@/App.tsx?raw';
import appLayoutSource from '@/AppLayout.tsx?raw';
import appRuntimeContextSource from '@/contexts/appRuntime.ts?raw';
import runtimeHomeSource from '@/pages/RuntimeHome.tsx?raw';
import routePreloadSource from '@/routing/routePreload.ts?raw';
import appRuntimeStateSource from '@/useAppRuntimeState.ts?raw';
import type { Resource } from '@/types/resource';
const appStylesSource = readFileSync(join(process.cwd(), 'src/index.css'), 'utf8');
const headerAuditSource = readFileSync(join(process.cwd(), 'scripts/header-audit.mjs'), 'utf8');
@@ -25,6 +27,79 @@ function readIntegrationTestSources(dir: string): Array<{ path: string; source:
});
}
const makeResource = (overrides: Partial<Resource>): Resource =>
({
id: overrides.id ?? 'resource-1',
name: overrides.name ?? overrides.id ?? 'resource-1',
displayName: overrides.displayName ?? overrides.name ?? overrides.id ?? 'resource-1',
type: overrides.type ?? 'agent',
platformId: overrides.platformId ?? 'platform-1',
platformType: overrides.platformType ?? 'agent',
sourceType: overrides.sourceType ?? 'api',
status: overrides.status ?? 'online',
lastSeen: overrides.lastSeen ?? 1_700_000_000_000,
...overrides,
}) as Resource;
describe('App platform navigation admission', () => {
const authenticatedResources = [
makeResource({ id: 'machine-1', platformType: 'agent' }),
makeResource({ id: 'docker-1', type: 'docker-host', platformType: 'docker' }),
makeResource({ id: 'truenas-1', platformType: 'truenas', sources: ['truenas'] }),
];
it('admits current authenticated REST resources before a WebSocket initial payload', () => {
const admission = resolvePlatformNavigationAdmission(authenticatedResources, true);
expect(admission.resolved).toBe(true);
expect(admission.visibility).toMatchObject({
docker: true,
truenas: true,
standalone: true,
});
expect(getDefaultWorkspaceRoute(admission.visibility, true)).toBe('/docker/overview');
});
it('retains platform visibility through a transient WebSocket disconnect', () => {
const beforeDisconnect = resolvePlatformNavigationAdmission(authenticatedResources, true);
const duringReconnect = resolvePlatformNavigationAdmission(authenticatedResources, true);
expect(duringReconnect).toEqual(beforeDisconnect);
expect(getDefaultWorkspaceRoute(duringReconnect.visibility, true)).toBe('/docker/overview');
});
it('keeps an evidence-free first load unresolved despite stale browser-local metadata', () => {
window.localStorage.setItem('guest_metadata', JSON.stringify({ id: 'stale-machine' }));
window.localStorage.setItem('docker_metadata', JSON.stringify({ id: 'stale-docker' }));
try {
const admission = resolvePlatformNavigationAdmission([], false);
expect(admission.resolved).toBe(false);
expect(Object.values(admission.visibility)).toEqual([
false,
false,
false,
false,
false,
false,
]);
} finally {
window.localStorage.removeItem('guest_metadata');
window.localStorage.removeItem('docker_metadata');
}
});
it('resolves an authenticated empty estate without inventing platform visibility', () => {
const admission = resolvePlatformNavigationAdmission([], true);
expect(admission.resolved).toBe(true);
expect(Object.values(admission.visibility)).toEqual([false, false, false, false, false, false]);
expect(getDefaultWorkspaceRoute(admission.visibility, true)).toBe('/settings/infrastructure');
expect(getDefaultWorkspaceRoute(admission.visibility, false)).toBe('/alerts');
});
});
describe('App architecture', () => {
it('keeps App as the entry shell that delegates runtime and chrome ownership', () => {
expect(appSource).toContain("import { AppLayout } from '@/AppLayout';");
@@ -126,6 +201,10 @@ describe('App architecture', () => {
// not standalone shell tabs; platform/runtime pages own those workflows.
expect(appSource).toContain('getDefaultWorkspaceRoute');
expect(appSource).toContain('platformNavigationResolved');
expect(appSource).toContain('resolvePlatformNavigationAdmission');
expect(appSource).toContain('runtime.runtimeStateResolved()');
expect(appSource).not.toContain('store?.initialDataReceived?.()');
expect(appSource).toContain('if (!platformNavigationResolved()) return;');
expect(appSource).toContain('buildPrimaryPlatformNavigationVisibility');
expect(appLayoutSource).toContain('buildPrimaryPlatformNavigationVisibility');
expect(appLayoutSource).toContain('primaryPlatformNavigationIsVisible');
@@ -2,6 +2,7 @@ import { createRoot } from 'solid-js';
import { waitFor } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import useAppRuntimeStateSource from '@/useAppRuntimeState.ts?raw';
import type { State } from '@/types/api';
import type { Resource } from '@/types/resource';
type UseAppRuntimeStateModule = typeof import('@/useAppRuntimeState');
@@ -12,6 +13,34 @@ const flushAsync = async () => {
}
};
const makeWebSocketState = (overrides: Partial<State> = {}): State => ({
connectedInfrastructure: [],
metrics: [],
performance: {
apiCallDuration: {},
lastPollDuration: 0,
pollingStartTime: '',
totalApiCalls: 0,
failedApiCalls: 0,
cacheHits: 0,
cacheMisses: 0,
},
connectionHealth: {},
stats: {
startTime: new Date().toISOString(),
uptime: 0,
pollingCycles: 0,
webSocketClients: 0,
version: '0.0.0',
},
activeAlerts: [],
recentlyResolved: [],
lastUpdate: 0,
pveTagColors: {},
resources: [],
...overrides,
});
describe('useAppRuntimeState', () => {
let useAppRuntimeState: UseAppRuntimeStateModule['useAppRuntimeState'];
let apiFetchMock: ReturnType<typeof vi.fn>;
@@ -25,6 +54,10 @@ describe('useAppRuntimeState', () => {
let setOrgIDMock: ReturnType<typeof vi.fn>;
let showToastMock: ReturnType<typeof vi.fn>;
let aiChatSetEnabledMock: ReturnType<typeof vi.fn>;
let websocketState: State;
let websocketConnected: boolean;
let websocketReconnecting: boolean;
let websocketInitialDataReceived: boolean;
beforeEach(async () => {
vi.resetModules();
@@ -64,37 +97,17 @@ describe('useAppRuntimeState', () => {
setOrgIDMock = vi.fn();
showToastMock = vi.fn();
aiChatSetEnabledMock = vi.fn();
websocketState = makeWebSocketState();
websocketConnected = false;
websocketReconnecting = false;
websocketInitialDataReceived = false;
vi.doMock('@/stores/websocket-global', () => ({
getGlobalWebSocketStore: () => ({
state: {
connectedInfrastructure: [],
metrics: [],
performance: {
apiCallDuration: {},
lastPollDuration: 0,
pollingStartTime: '',
totalApiCalls: 0,
failedApiCalls: 0,
cacheHits: 0,
cacheMisses: 0,
},
connectionHealth: {},
stats: {
startTime: new Date().toISOString(),
uptime: 0,
pollingCycles: 0,
webSocketClients: 0,
version: '0.0.0',
},
activeAlerts: [],
recentlyResolved: [],
lastUpdate: 0,
resources: [],
},
connected: () => false,
reconnecting: () => false,
initialDataReceived: () => false,
state: websocketState,
connected: () => websocketConnected,
reconnecting: () => websocketReconnecting,
initialDataReceived: () => websocketInitialDataReceived,
reconnect: vi.fn(),
switchUrl: vi.fn(),
}),
@@ -397,6 +410,96 @@ describe('useAppRuntimeState', () => {
await waitFor(() => {
expect(hookState.state().resources).toEqual([bootstrapResource]);
});
expect(hookState.runtimeStateResolved()).toBe(true);
expect(hookState.enhancedStore()?.initialDataReceived()).toBe(false);
dispose();
});
it('keeps retained server resource state available during a transient WebSocket reconnect', async () => {
const retainedResource: Resource = {
id: 'truenas-1',
name: 'truenas-1',
displayName: 'truenas-1',
type: 'agent',
platformId: 'truenas-1',
platformType: 'truenas',
sourceType: 'api',
sources: ['truenas'],
status: 'online',
lastSeen: 1_700_000_000_000,
};
websocketState = makeWebSocketState({
resources: [retainedResource],
lastUpdate: 1_700_000_000_000,
});
websocketConnected = false;
websocketReconnecting = true;
websocketInitialDataReceived = false;
apiFetchMock.mockImplementation(async (url: string) => {
if (url === '/api/security/status') {
return new Response(
JSON.stringify({
hasAuthentication: true,
ssoEnabled: true,
ssoSessionUsername: 'sso:oidc:test:operator',
}),
{ status: 200 },
);
}
if (url === '/api/health') {
return new Response('{}', { status: 200 });
}
throw new Error(`Unhandled apiFetch URL: ${url}`);
});
const { hookState, dispose } = mountHook();
await waitFor(() => {
expect(hookState.needsAuth()).toBe(false);
expect(hookState.reconnecting()).toBe(true);
});
expect(hookState.enhancedStore()?.initialDataReceived()).toBe(false);
expect(hookState.runtimeStateResolved()).toBe(true);
expect(hookState.state().resources).toEqual([retainedResource]);
expect(apiFetchMock.mock.calls.some(([url]) => url === '/api/state')).toBe(false);
dispose();
});
it('distinguishes an evidence-free first load from an authenticated empty bootstrap', async () => {
let resolveStateResponse: ((response: Response) => void) | undefined;
const stateResponse = new Promise<Response>((resolve) => {
resolveStateResponse = resolve;
});
apiFetchMock.mockImplementation(async (url: string) => {
if (url === '/api/security/status') {
return new Response(JSON.stringify({ hasAuthentication: true }), { status: 200 });
}
if (url === '/api/state') {
return stateResponse;
}
if (url === '/api/health') {
return new Response('{}', { status: 200 });
}
throw new Error(`Unhandled apiFetch URL: ${url}`);
});
const { hookState, dispose } = mountHook();
await waitFor(() => {
expect(apiFetchMock.mock.calls.some(([url]) => url === '/api/state')).toBe(true);
});
expect(hookState.runtimeStateResolved()).toBe(false);
resolveStateResponse?.(new Response('{}', { status: 200 }));
await waitFor(() => {
expect(hookState.runtimeStateResolved()).toBe(true);
expect(hookState.isLoading()).toBe(false);
});
expect(hookState.state().resources).toEqual([]);
dispose();
});
@@ -222,6 +222,14 @@ export const useAppRuntimeState = () => {
const [wsStore, setWsStore] = createSignal<EnhancedStore | null>(null);
const [bootstrapState, setBootstrapState] = createSignal<State | null>(null);
const [backendHealthy, setBackendHealthy] = createSignal(false);
const runtimeStateResolved = (): boolean => {
const store = wsStore();
return (
bootstrapState() !== null ||
Boolean(store?.initialDataReceived()) ||
hasRuntimeStatePayload(store?.state)
);
};
const state = (): State => {
const store = wsStore();
const liveState = store?.state;
@@ -809,6 +817,7 @@ export const useAppRuntimeState = () => {
securityStatus,
proxyAuthInfo,
state,
runtimeStateResolved,
connected,
backendHealthy,
connectionStatus,