mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Hydrate shell navigation from protected state
This commit is contained in:
parent
45d958ff5d
commit
59417a7da7
4 changed files with 139 additions and 32 deletions
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ describe('platformNavigationModel', () => {
|
|||
docker: false,
|
||||
kubernetes: true,
|
||||
truenas: true,
|
||||
vmware: false,
|
||||
vmware: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue