diff --git a/frontend-modern/src/__tests__/AppLayout.test.tsx b/frontend-modern/src/__tests__/AppLayout.test.tsx index 737797361..43cf8c391 100644 --- a/frontend-modern/src/__tests__/AppLayout.test.tsx +++ b/frontend-modern/src/__tests__/AppLayout.test.tsx @@ -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', () => { diff --git a/frontend-modern/src/__tests__/useAppRuntimeState.test.ts b/frontend-modern/src/__tests__/useAppRuntimeState.test.ts index a20887237..427581ac0 100644 --- a/frontend-modern/src/__tests__/useAppRuntimeState.test.ts +++ b/frontend-modern/src/__tests__/useAppRuntimeState.test.ts @@ -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(); }); diff --git a/frontend-modern/src/features/platformNavigation/__tests__/platformNavigationModel.test.ts b/frontend-modern/src/features/platformNavigation/__tests__/platformNavigationModel.test.ts index 9c153deb5..a64bbe266 100644 --- a/frontend-modern/src/features/platformNavigation/__tests__/platformNavigationModel.test.ts +++ b/frontend-modern/src/features/platformNavigation/__tests__/platformNavigationModel.test.ts @@ -47,7 +47,7 @@ describe('platformNavigationModel', () => { docker: false, kubernetes: true, truenas: true, - vmware: false, + vmware: true, }); }); diff --git a/frontend-modern/src/useAppRuntimeState.ts b/frontend-modern/src/useAppRuntimeState.ts index 0100a7df8..846a53e01 100644 --- a/frontend-modern/src/useAppRuntimeState.ts +++ b/frontend-modern/src/useAppRuntimeState.ts @@ -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; 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 => + 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(null); + const [bootstrapState, setBootstrapState] = createSignal(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);