diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index ada43eaf4..94908c24f 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 6fabc4cfa..03a41e3c1 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -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 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 7179f7211..a959116f5 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index b760cd454..6eeebcb33 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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. diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 976382308..68ce8f032 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -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 = 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 ( diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index a31a27808..19862f5c7 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -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 => + ({ + 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'); diff --git a/frontend-modern/src/__tests__/useAppRuntimeState.test.ts b/frontend-modern/src/__tests__/useAppRuntimeState.test.ts index 7fec4d190..5b9c3639a 100644 --- a/frontend-modern/src/__tests__/useAppRuntimeState.test.ts +++ b/frontend-modern/src/__tests__/useAppRuntimeState.test.ts @@ -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 => ({ + 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; @@ -25,6 +54,10 @@ describe('useAppRuntimeState', () => { let setOrgIDMock: ReturnType; let showToastMock: ReturnType; let aiChatSetEnabledMock: ReturnType; + 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((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(); }); diff --git a/frontend-modern/src/useAppRuntimeState.ts b/frontend-modern/src/useAppRuntimeState.ts index 9302e6f7d..e1e25c725 100644 --- a/frontend-modern/src/useAppRuntimeState.ts +++ b/frontend-modern/src/useAppRuntimeState.ts @@ -222,6 +222,14 @@ export const useAppRuntimeState = () => { const [wsStore, setWsStore] = createSignal(null); const [bootstrapState, setBootstrapState] = createSignal(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,