mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-25 04:33:03 +00:00
Fix platform navigation during stream reconnects
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user