Hydrate shell navigation from protected state

This commit is contained in:
rcourtman 2026-05-20 15:51:27 +01:00
parent 45d958ff5d
commit 59417a7da7
4 changed files with 139 additions and 32 deletions

View file

@ -115,7 +115,7 @@ describe('AppLayout navigation icons', () => {
expect(container).toHaveTextContent('Infrastructure body');
});
it('hides platform tabs without supported infrastructure evidence', () => {
it('shows platform tabs with supported infrastructure evidence', () => {
renderLayout([
makeResource({ id: 'pve-1', type: 'agent', platformType: 'proxmox-pve' }),
makeResource({ id: 'docker-1', type: 'docker-host', platformType: 'docker' }),
@ -139,8 +139,8 @@ describe('AppLayout navigation icons', () => {
within(infrastructureGroup as HTMLElement).queryByRole('tab', { name: 'TrueNAS' }),
).toBeNull();
expect(
within(infrastructureGroup as HTMLElement).queryByRole('tab', { name: 'vSphere' }),
).toBeNull();
within(infrastructureGroup as HTMLElement).getByRole('tab', { name: 'vSphere' }),
).toBeTruthy();
});
it('keeps connected brand motion on the logo while the wordmark stays static', () => {

View file

@ -1,6 +1,7 @@
import { createRoot } from 'solid-js';
import { waitFor } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Resource } from '@/types/resource';
type UseAppRuntimeStateModule = typeof import('@/useAppRuntimeState');
@ -48,7 +49,10 @@ describe('useAppRuntimeState', () => {
writable: true,
configurable: true,
value: vi.fn((cb: IdleRequestCallback) =>
window.setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 } as IdleDeadline), 0),
window.setTimeout(
() => cb({ didTimeout: false, timeRemaining: () => 50 } as IdleDeadline),
0,
),
),
});
@ -114,6 +118,7 @@ describe('useAppRuntimeState', () => {
},
connected: () => false,
reconnecting: () => false,
initialDataReceived: () => false,
reconnect: vi.fn(),
switchUrl: vi.fn(),
}),
@ -344,6 +349,47 @@ describe('useAppRuntimeState', () => {
dispose();
});
it('uses the protected state response as shell state before websocket data arrives', async () => {
const bootstrapResource: Resource = {
id: 'pve-1',
name: 'pve-1',
displayName: 'pve-1',
type: 'agent',
platformId: 'pve-1',
platformType: 'proxmox-pve',
sourceType: 'api',
sources: ['proxmox'],
status: 'online',
lastSeen: 1_700_000_000_000,
};
apiFetchMock.mockImplementation(async (url: string) => {
if (url === '/api/security/status') {
return new Response(JSON.stringify({ hasAuthentication: true }), { status: 200 });
}
if (url === '/api/state') {
return new Response(
JSON.stringify({
resources: [bootstrapResource],
lastUpdate: 1_700_000_000_000,
}),
{ 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.state().resources).toEqual([bootstrapResource]);
});
dispose();
});
it('skips commercial posture bootstrap when upgrade prompts are hidden', async () => {
apiFetchMock.mockImplementation(async (url: string) => {
if (url === '/api/security/status') {
@ -440,9 +486,7 @@ describe('useAppRuntimeState', () => {
await flushAsync();
await flushAsync();
expect(
apiFetchMock.mock.calls.some(([url]) => url === '/api/state'),
).toBe(false);
expect(apiFetchMock.mock.calls.some(([url]) => url === '/api/state')).toBe(false);
expect(hookState.needsAuth()).toBe(true);
dispose();
@ -457,9 +501,7 @@ describe('useAppRuntimeState', () => {
await flushAsync();
await flushAsync();
expect(
apiFetchMock.mock.calls.some(([url]) => url === '/api/state'),
).toBe(false);
expect(apiFetchMock.mock.calls.some(([url]) => url === '/api/state')).toBe(false);
expect(hookState.needsAuth()).toBe(true);
dispose();
@ -475,9 +517,7 @@ describe('useAppRuntimeState', () => {
await flushAsync();
await flushAsync();
expect(
apiFetchMock.mock.calls.some(([url]) => url === '/api/state'),
).toBe(true);
expect(apiFetchMock.mock.calls.some(([url]) => url === '/api/state')).toBe(true);
dispose();
});

View file

@ -47,7 +47,7 @@ describe('platformNavigationModel', () => {
docker: false,
kubernetes: true,
truenas: true,
vmware: false,
vmware: true,
});
});

View file

@ -46,10 +46,7 @@ import {
persistThemePreference,
type ThemePreference,
} from '@/utils/theme';
import {
initKioskMode,
getPulseWebSocketUrl,
} from '@/utils/url';
import { initKioskMode, getPulseWebSocketUrl } from '@/utils/url';
import { syncKioskMode } from '@/hooks/useKioskMode';
import {
isHostedModeEnabled,
@ -73,12 +70,7 @@ import {
type EnhancedStore = ReturnType<typeof getGlobalWebSocketStore>;
export type AppConnectionStatus = {
kind:
| 'connected'
| 'sync-reconnecting'
| 'backend-healthy'
| 'reconnecting'
| 'disconnected';
kind: 'connected' | 'sync-reconnecting' | 'backend-healthy' | 'reconnecting' | 'disconnected';
label: string;
detail: string;
tone: 'healthy' | 'warning' | 'offline';
@ -90,6 +82,9 @@ const ROOT_WORKLOADS_PATH = buildWorkloadsPath();
const isPreAuthLoginBootstrapPath = (pathname: string): boolean =>
pathname === '/' || pathname === '/login';
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
export const useAppRuntimeState = () => {
initKioskMode();
syncKioskMode();
@ -218,6 +213,51 @@ export const useAppRuntimeState = () => {
lastUpdate: 0,
resources: [],
};
const normalizeBootstrapState = (value: unknown): State | null => {
if (!isRecord(value)) return null;
return {
...fallbackState,
...value,
connectedInfrastructure: Array.isArray(value.connectedInfrastructure)
? (value.connectedInfrastructure as State['connectedInfrastructure'])
: fallbackState.connectedInfrastructure,
metrics: Array.isArray(value.metrics)
? (value.metrics as State['metrics'])
: fallbackState.metrics,
performance: isRecord(value.performance)
? ({ ...fallbackState.performance, ...value.performance } as State['performance'])
: fallbackState.performance,
connectionHealth: isRecord(value.connectionHealth)
? (value.connectionHealth as State['connectionHealth'])
: fallbackState.connectionHealth,
stats: isRecord(value.stats)
? ({ ...fallbackState.stats, ...value.stats } as State['stats'])
: fallbackState.stats,
activeAlerts: Array.isArray(value.activeAlerts)
? (value.activeAlerts as State['activeAlerts'])
: fallbackState.activeAlerts,
recentlyResolved: Array.isArray(value.recentlyResolved)
? (value.recentlyResolved as State['recentlyResolved'])
: fallbackState.recentlyResolved,
lastUpdate:
typeof value.lastUpdate === 'number' ? value.lastUpdate : fallbackState.lastUpdate,
resources: Array.isArray(value.resources)
? (value.resources as State['resources'])
: fallbackState.resources,
};
};
const hasRuntimeStatePayload = (candidate: State | undefined): boolean => {
if (!candidate) return false;
return (
candidate.lastUpdate > 0 ||
candidate.resources.length > 0 ||
candidate.connectedInfrastructure.length > 0 ||
candidate.activeAlerts.length > 0 ||
candidate.recentlyResolved.length > 0 ||
candidate.metrics.length > 0
);
};
const [isLoading, setIsLoading] = createSignal(true);
const [needsAuth, setNeedsAuth] = createSignal(false);
@ -233,8 +273,16 @@ export const useAppRuntimeState = () => {
logoutURL?: string;
} | null>(null);
const [wsStore, setWsStore] = createSignal<EnhancedStore | null>(null);
const [bootstrapState, setBootstrapState] = createSignal<State | null>(null);
const [backendHealthy, setBackendHealthy] = createSignal(false);
const state = (): State => wsStore()?.state || fallbackState;
const state = (): State => {
const store = wsStore();
const liveState = store?.state;
if (liveState && (store.initialDataReceived() || hasRuntimeStatePayload(liveState))) {
return liveState;
}
return bootstrapState() || liveState || fallbackState;
};
const connected = () => wsStore()?.connected() || false;
const reconnecting = () => wsStore()?.reconnecting() || false;
const [lastUpdateText, setLastUpdateText] = createSignal('');
@ -436,6 +484,7 @@ export const useAppRuntimeState = () => {
setSelectedOrgID(target);
setActiveOrgID(target);
setBootstrapState(null);
eventBus.emit('org_switched', target);
try {
@ -491,9 +540,12 @@ export const useAppRuntimeState = () => {
}
if (typeof window.requestIdleCallback === 'function') {
const id = window.requestIdleCallback(() => {
prewarmAppShellCharts();
}, { timeout: 2_000 });
const id = window.requestIdleCallback(
() => {
prewarmAppShellCharts();
},
{ timeout: 2_000 },
);
onCleanup(() => {
window.cancelIdleCallback(id);
});
@ -555,9 +607,12 @@ export const useAppRuntimeState = () => {
}
void checkBackendHealth();
const interval = window.setInterval(() => {
void checkBackendHealth();
}, reconnecting() ? 5000 : 15000);
const interval = window.setInterval(
() => {
void checkBackendHealth();
},
reconnecting() ? 5000 : 15000,
);
onCleanup(() => {
window.clearInterval(interval);
@ -566,7 +621,10 @@ export const useAppRuntimeState = () => {
const handleThemeChange = async (newPreference: ThemePreference) => {
applyThemePreferenceLocally(newPreference);
logger.info('Theme changed', { pref: newPreference, active: computeIsDark(newPreference) ? 'dark' : 'light' });
logger.info('Theme changed', {
pref: newPreference,
active: computeIsDark(newPreference) ? 'dark' : 'light',
});
if (!needsAuth()) {
try {
@ -722,8 +780,15 @@ export const useAppRuntimeState = () => {
});
if (stateResponse.status === 401) {
setBootstrapState(null);
setNeedsAuth(true);
} else {
const protectedState = await stateResponse
.clone()
.json()
.then(normalizeBootstrapState)
.catch(() => null);
setBootstrapState(protectedState);
await beginAuthenticatedRuntime();
}
} catch (error) {
@ -736,6 +801,7 @@ export const useAppRuntimeState = () => {
}
aiChatStore.setEnabled(false);
setHasAuth(false);
setBootstrapState(null);
setNeedsAuth(true);
} finally {
setIsLoading(false);
@ -777,6 +843,7 @@ export const useAppRuntimeState = () => {
sessionStorage.clear();
localStorage.setItem('just_logged_out', 'true');
aiChatStore.setEnabled(false);
setBootstrapState(null);
if (wsStore()) {
setWsStore(null);