diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index af2f26a3c..4843a59ac 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -39,6 +39,7 @@ import { buildStoragePath, buildWorkloadsPath, } from './routing/resourceLinks'; +import { preloadRouteModule } from '@/routing/routePreload'; import { AppLayout } from '@/AppLayout'; import { useAppRuntimeState } from '@/useAppRuntimeState'; import { @@ -89,6 +90,29 @@ const ROOT_WORKLOADS_PATH = buildWorkloadsPath(); const STORAGE_PATH = buildStoragePath(); const RECOVERY_ROUTE_PATH = buildRecoveryPath(); const ROOT_PATROL_PATH = PATROL_PATH; +const APP_SHELL_ROUTE_PRELOAD_PATHS = [ + RECOVERY_ROUTE_PATH, + ROOT_PATROL_PATH, + '/alerts', + STORAGE_PATH, + '/operations', + '/settings', +] as const; + +async function preloadAppShellRoutes() { + await Promise.all( + APP_SHELL_ROUTE_PRELOAD_PATHS.map(async (route) => { + try { + await preloadRouteModule(route); + } catch (error) { + logger.warn('Failed to preload app shell route', { + route, + error: error instanceof Error ? error.message : String(error), + }); + } + }), + ); +} // Helper to detect if an update is actively in progress (not just checking for updates) function isUpdateInProgress(status: string | undefined): boolean { @@ -219,6 +243,8 @@ function App() { ); const location = useLocation(); const isPublicRoute = createMemo(() => isPublicRoutePath(location.pathname)); + let appShellRoutePreloadCleanup: (() => void) | undefined; + let appShellRoutesPreloadScheduled = false; createEffect(() => { location.pathname; @@ -289,6 +315,28 @@ function App() { onCleanup(finish); }); + createEffect(() => { + if ( + runtime.isLoading() || + runtime.needsAuth() || + isPublicRoute() || + appShellRoutesPreloadScheduled + ) { + return; + } + + appShellRoutesPreloadScheduled = true; + const startPreload = () => { + appShellRoutePreloadCleanup = undefined; + void preloadAppShellRoutes(); + }; + + const timeoutId = window.setTimeout(() => { + startPreload(); + }, 150); + appShellRoutePreloadCleanup = () => window.clearTimeout(timeoutId); + }); + useKeyboardShortcuts({ enabled: () => !runtime.needsAuth(), isShortcutsOpen: shortcutsOpen, @@ -326,6 +374,10 @@ function App() { }); }); + onCleanup(() => { + appShellRoutePreloadCleanup?.(); + }); + return ( { - if (platform.enabled) { - navigate(platform.route); - } else { - navigate(platform.settingsRoute); - } + const targetRoute = platform.enabled ? platform.route : platform.settingsRoute; + void (async () => { + try { + await preloadRouteModule(targetRoute); + } catch (error) { + logger.warn('Failed to preload navigation target', { + route: targetRoute, + error: error instanceof Error ? error.message : String(error), + }); + } + navigate(targetRoute); + })(); }; const handleUtilityClick = (tab: UtilityTab) => { - navigate(tab.route); + void (async () => { + try { + await preloadRouteModule(tab.route); + } catch (error) { + logger.warn('Failed to preload navigation target', { + route: tab.route, + error: error instanceof Error ? error.message : String(error), + }); + } + navigate(tab.route); + })(); + }; + + const warmNavigationTarget = (route: string) => { + void preloadRouteModule(route).catch((error) => { + logger.warn('Failed to warm navigation target', { + route, + error: error instanceof Error ? error.message : String(error), + }); + }); + }; + + const getPlatformTargetRoute = (platform: PlatformTab) => { + if (platform.enabled) { + return platform.route; + } + return platform.settingsRoute; }; return ( @@ -668,6 +703,7 @@ export function AppLayout(props: AppLayoutProps) { class={className()} role="tab" aria-disabled={disabled()} + onMouseEnter={() => warmNavigationTarget(getPlatformTargetRoute(platform))} onClick={() => handlePlatformClick(platform)} title={title()} > @@ -706,6 +742,7 @@ export function AppLayout(props: AppLayoutProps) { class={className()} role="tab" aria-disabled={false} + onMouseEnter={() => warmNavigationTarget(tab.route)} onClick={() => handleUtilityClick(tab)} title={tab.tooltip} > diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index ac5ec88ce..d8a2467b8 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import appSource from '@/App.tsx?raw'; import appLayoutSource from '@/AppLayout.tsx?raw'; import appRuntimeContextSource from '@/contexts/appRuntime.ts?raw'; +import routePreloadSource from '@/routing/routePreload.ts?raw'; import appRuntimeStateSource from '@/useAppRuntimeState.ts?raw'; describe('App architecture', () => { @@ -25,6 +26,13 @@ describe('App architecture', () => { expect(appSource).toContain('clearPendingAppShellRestoreTop'); expect(appSource).toContain('const ROOT_DASHBOARD_PATH = DASHBOARD_PATH;'); expect(appSource).toContain('const ROOT_PATROL_PATH = PATROL_PATH;'); + expect(appSource).toContain("import { preloadRouteModule } from '@/routing/routePreload';"); + expect(appSource).toContain('const APP_SHELL_ROUTE_PRELOAD_PATHS = ['); + expect(appSource).toContain('RECOVERY_ROUTE_PATH,'); + expect(appSource).toContain('ROOT_PATROL_PATH,'); + expect(appSource).toContain('await preloadRouteModule(route);'); + expect(appSource).toContain('const timeoutId = window.setTimeout(() => {'); + expect(appSource).toContain('void preloadAppShellRoutes();'); expect(appSource).toContain(''); expect(appSource).toContain(' } />'); expect(appSource).toContain(' } />'); @@ -57,6 +65,7 @@ describe('App architecture', () => { it('keeps authenticated chrome in AppLayout and hosted bootstrap in useAppRuntimeState', () => { expect(appLayoutSource).toContain('export function AppLayout(props: AppLayoutProps)'); + expect(appLayoutSource).toContain("import { preloadRouteModule } from '@/routing/routePreload';"); expect(appLayoutSource).toContain("import { aiChatStore } from '@/stores/aiChat';"); expect(appLayoutSource).toContain( "import { dialogStackHasBlockingDialog } from '@/components/shared/useDialogState';", @@ -88,6 +97,9 @@ describe('App architecture', () => { expect(appLayoutSource).not.toContain('presentationPolicyHidesOrganizationSurfaces'); expect(appLayoutSource).toContain('presentationPolicyIsDemoMode'); expect(appLayoutSource).toContain("if (!presentationPolicyIsDemoMode()) {"); + expect(appLayoutSource).toContain('await preloadRouteModule(targetRoute);'); + expect(appLayoutSource).toContain('await preloadRouteModule(tab.route);'); + expect(appLayoutSource).toContain('onMouseEnter={() => warmNavigationTarget('); expect(appLayoutSource).toContain( 'aiChatStore.enabled === true &&', ); @@ -139,6 +151,10 @@ describe('App architecture', () => { expect(appRuntimeStateSource).not.toContain("import { startMetricsCollector } from '@/stores/metricsCollector';"); expect(appRuntimeStateSource).not.toContain('startMetricsCollector();'); expect(appRuntimeStateSource).not.toContain('function AppLayout('); + expect(routePreloadSource).toContain('const ROUTE_PRELOADERS: readonly RoutePreloader[] = ['); + expect(routePreloadSource).toContain("id: 'recovery',"); + expect(routePreloadSource).toContain("id: 'patrol',"); + expect(routePreloadSource).toContain('const routePreloadCache = new Map>();'); expect(appRuntimeContextSource).toContain( "import { createContext, useContext } from 'solid-js';", ); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx index 0e2856b81..0f14ab62f 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx @@ -7,6 +7,7 @@ import discoveryTabStateSource from '@/components/Discovery/useDiscoveryTabState import resourceDetailDrawerShellSource from '@/components/Infrastructure/ResourceDetailDrawer.tsx?raw'; import resourceDetailDrawerOverviewSource from '@/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx?raw'; import resourceDetailDrawerHistoryStateSource from '@/components/Infrastructure/useResourceDetailDrawerHistoryState.ts?raw'; +import createNonSuspendingQuerySource from '@/hooks/createNonSuspendingQuery.ts?raw'; import resourceDetailDrawerDerivedStateSource from '@/components/Infrastructure/useResourceDetailDrawerDerivedState.ts?raw'; import resourceDetailDrawerDiscoveryModelSource from '@/components/Infrastructure/resourceDetailDiscoveryModel.ts?raw'; import resourceDetailDrawerIdentityModelSource from '@/components/Infrastructure/resourceDetailDrawerIdentityModel.ts?raw'; @@ -214,6 +215,11 @@ describe('ResourceDetailDrawer change history section', () => { 'const modeLabel = formatSourceType(resource.sourceType);', ); expect(resourceDetailDrawerOverviewSource).not.toContain('Mode'); + expect(createNonSuspendingQuerySource).toContain( + 'const [resolvedOnce, setResolvedOnce] = createSignal(false);', + ); + expect(createNonSuspendingQuerySource).toContain('setResolvedOnce(true);'); + expect(createNonSuspendingQuerySource).toContain('setResolvedOnce(false);'); expect(resourceDetailDrawerDockerActionsStateSource).toContain('MonitoringAPI.checkDockerUpdates'); expect(resourceDetailDrawerDockerActionsStateSource).toContain( 'MonitoringAPI.updateAllDockerContainers', diff --git a/frontend-modern/src/components/Recovery/Recovery.tsx b/frontend-modern/src/components/Recovery/Recovery.tsx index 3ec920f5b..38ed3b543 100644 --- a/frontend-modern/src/components/Recovery/Recovery.tsx +++ b/frontend-modern/src/components/Recovery/Recovery.tsx @@ -282,6 +282,29 @@ const Recovery: Component = () => { const hasNodeData = createMemo(() => (facets().nodesAgents || []).length > 0); const hasNamespaceData = createMemo(() => (facets().namespaces || []).length > 0); const hasEntityIDData = createMemo(() => Boolean(facets().hasEntityId)); + const recoveryRollupsLoaded = createMemo(() => recoveryRollups.resolvedOnce()); + const recoveryRollupsLoading = createMemo( + () => !recoveryRollupsLoaded() || recoveryRollups.rollups.loading, + ); + const recoverySeriesLoaded = createMemo(() => recoverySeries.resolvedOnce()); + const recoverySeriesLoading = createMemo( + () => !recoverySeriesLoaded() || recoverySeries.response.loading, + ); + const recoveryPointsLoaded = createMemo(() => recoveryPoints.resolvedOnce()); + const recoveryPointsLoading = createMemo( + () => !recoveryPointsLoaded() || recoveryPoints.response.loading, + ); + const recoveryPointsModel = { + meta: recoveryPoints.meta, + response: { + get loading() { + return recoveryPointsLoading(); + }, + get error() { + return recoveryPoints.response.error; + }, + }, + }; const artifactColumns: ColumnDef[] = [ { id: 'time', label: 'Time' }, @@ -535,7 +558,7 @@ const Recovery: Component = () => { setCurrentPage(1); }} isMobile={isMobile()} - loading={() => recoverySeries.response.loading} + loading={recoverySeriesLoading} overallRollupsSummary={overallRollupsSummary} selectedDateKey={selectedDateKey} selectedDateLabel={selectedDateLabel} @@ -553,9 +576,10 @@ const Recovery: Component = () => { return (
recoverySeries.series() || []} - seriesLoaded={() => !recoverySeries.response.loading} + seriesLoaded={recoverySeriesLoaded} seriesFailed={() => Boolean(recoverySeries.response.error)} summary={overallRollupsSummary} timeRange={summaryRange} @@ -574,7 +598,7 @@ const Recovery: Component = () => { historyOutcomeFilter={historyOutcomeFilter} isMobile={isMobile()} kioskMode={kioskMode()} - loading={() => recoveryRollups.rollups.loading} + loading={recoveryRollupsLoading} error={() => recoveryRollups.rollups.error} onSelectRollup={handleSelectRollup} protectedStaleOnly={protectedStaleOnly} @@ -598,7 +622,7 @@ const Recovery: Component = () => { {eventsActivity()} - + { platformFilter={platformFilter} platformOptions={platformOptions} queryFilter={queryFilter} - recoveryPoints={recoveryPoints} + recoveryPoints={recoveryPointsModel} resetAdvancedArtifactFilters={resetAdvancedArtifactFilters} resetAllArtifactFilters={resetAllArtifactFilters} resourcesById={resourcesById} diff --git a/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx b/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx index 0e5b396b2..71adfd024 100644 --- a/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx +++ b/frontend-modern/src/components/Recovery/RecoveryProtectedInventorySection.tsx @@ -316,8 +316,35 @@ export const RecoveryProtectedInventorySection: Component< -
- {getRecoveryProtectedItemsLoadingState().text} +
+
+ {getRecoveryProtectedItemsLoadingState().text} +
+
+
+
+
+
+
+
+ + {() => ( +
+
+
+
+
+
+
+
+
+
+ )} + +
diff --git a/frontend-modern/src/components/Recovery/RecoverySummary.test.tsx b/frontend-modern/src/components/Recovery/RecoverySummary.test.tsx index c8dbc038d..581bbb22b 100644 --- a/frontend-modern/src/components/Recovery/RecoverySummary.test.tsx +++ b/frontend-modern/src/components/Recovery/RecoverySummary.test.tsx @@ -14,6 +14,7 @@ describe('RecoverySummary', () => { render(() => ( true} rollups={() => [ { rollupId: 'alpha', @@ -98,6 +99,34 @@ describe('RecoverySummary', () => { expect(recoverySummarySource.match(/bodyLayout="auto"/g) ?? []).toHaveLength(4); }); + it('renders a stable summary shell while the first recovery rollups request is still pending', () => { + render(() => ( + false} + rollups={() => []} + series={() => []} + seriesLoaded={() => false} + summary={() => ({ + total: 0, + counts: { + success: 0, + warning: 0, + failed: 0, + running: 0, + unknown: 0, + }, + stale: 0, + neverSucceeded: 0, + })} + timeRange={() => '30d'} + /> + )); + + expect(screen.getByTestId('recovery-summary')).toBeInTheDocument(); + expect(screen.queryByText('0 protected items')).not.toBeInTheDocument(); + expect(screen.queryByText('No recovery activity yet')).not.toBeInTheDocument(); + }); + it('keeps recovery summary outside the interactive page-group-entity summary contract', () => { expect(recoverySummarySource).not.toContain('useSummaryPageInteractionState'); expect(recoverySummarySource).not.toContain('hoveredGroupScope'); diff --git a/frontend-modern/src/components/Recovery/RecoverySummary.tsx b/frontend-modern/src/components/Recovery/RecoverySummary.tsx index 32d3777ed..8c323e33f 100644 --- a/frontend-modern/src/components/Recovery/RecoverySummary.tsx +++ b/frontend-modern/src/components/Recovery/RecoverySummary.tsx @@ -14,6 +14,7 @@ import { } from '@/utils/recoverySummaryPresentation'; export interface RecoverySummaryProps { + loaded: () => boolean; rollups: () => ProtectionRollup[]; series: () => RecoveryPointsSeriesBucket[]; seriesLoaded: () => boolean; @@ -29,6 +30,7 @@ export interface RecoverySummaryProps { } export const RecoverySummary: Component = (props) => { + const loaded = () => props.loaded(); const summary = () => props.summary(); const hasRollups = () => summary().total > 0; @@ -109,10 +111,15 @@ export const RecoverySummary: Component = (props) => { return {latestLabel}; }); return ( - + {summary().total} protected items + } + > + {summary().total} protected items + } timeRange={props.timeRange()} onTimeRangeChange={props.onTimeRangeChange ? handleTimeRangeChange : undefined} @@ -123,9 +130,9 @@ export const RecoverySummary: Component = (props) => { >
@@ -140,9 +147,9 @@ export const RecoverySummary: Component = (props) => {
@@ -157,9 +164,9 @@ export const RecoverySummary: Component = (props) => {
@@ -174,7 +181,7 @@ export const RecoverySummary: Component = (props) => { { recoveryPoints: { meta: () => ({ page: 1, limit: 200, total: 0, totalPages: 1 }), points: () => [], + resolvedOnce: () => true, response: { error: new Error('history points unavailable'), loading: false, @@ -87,9 +88,11 @@ describe('Recovery layout guards', () => { }, recoveryRollups: { rollups: () => [], + resolvedOnce: () => true, response: { error: undefined, loading: false }, }, recoverySeries: { + resolvedOnce: () => true, response: { error: undefined, loading: false }, series: () => [ { day: '2026-02-13', total: 1, snapshot: 1, local: 0, remote: 0 }, @@ -147,4 +150,28 @@ describe('Recovery layout guards', () => { expect(screen.queryByTestId('history-section')).not.toBeInTheDocument(); expect(screen.getByText('Failed to load recovery points')).toBeInTheDocument(); }); + + it('keeps the summary shell mounted while recovery data is still on its first load', () => { + mockRecoverySurfaceState.recoveryRollups = { + rollups: Object.assign(() => [], { loading: false }), + resolvedOnce: () => false, + response: { error: undefined, loading: false }, + }; + mockRecoverySurfaceState.recoverySeries = { + resolvedOnce: () => false, + response: { error: undefined, loading: false }, + series: () => [], + }; + mockRecoverySurfaceState.recoveryPoints = { + meta: () => ({ page: 1, limit: 200, total: 0, totalPages: 1 }), + points: () => [], + resolvedOnce: () => false, + response: { error: undefined, loading: false }, + }; + + render(() => ); + + expect(screen.getByTestId('recovery-summary')).toBeInTheDocument(); + expect(screen.getByTestId('protected-inventory')).toBeInTheDocument(); + }); }); diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx index d6f0cdae7..3101190f1 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx @@ -42,7 +42,7 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState ); const recency = createMemo(() => getPatrolRecencyPresentation({ - runs: state.patrolRunHistory() ?? [], + runs: state.patrolRunHistory.value() ?? [], lastPatrolAt: state.patrolStatus()?.last_patrol_at, lastActivityAt: state.patrolStatus()?.last_activity_at, }), @@ -63,7 +63,7 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState }); return ( -
+
-
-
- - {runtimePresentation().label} -
- - -
- 0} - fallback={{quickstartPresentation().summary}} - > - {quickstartPresentation().summary} - +
+
+
+ + {runtimePresentation().label}
- -
- -
- - - -
-
-

- Patrol Configuration -

- -
- -
-
-
- - -
- -
- - -
-
- -
-
- -
- -
- - {(level) => { - const isProLocked = () => - state.autoFixLocked() && (level === 'approval' || level === 'assisted'); - const isDisabled = () => !state.patrolEnabledLocal() || isProLocked(); - const isActive = () => - level === 'assisted' - ? state.autonomyLevel() === 'assisted' || - state.autonomyLevel() === 'full' - : state.autonomyLevel() === level; - - return ( - - ); - }} - -
- -
- - Upgrade to Pro - {' '} - to unlock investigation and auto-fix. - - {' '} - - -
-
-
- -
-
-
- -

- Analyze infrastructure automatically when critical alerts fire. -

-
- - state.handleAlertTriggeredAnalysisChange(e.currentTarget.checked) - } - disabled={state.isUpdatingSettings() || state.alertAnalysisLocked()} - /> -
- - -
- - Upgrade - {' '} - to enable. - - - -
-
- -
-

- Full patrols run on the {selectedScheduleLabel().toLowerCase()} schedule. -

-

- Alert and anomaly triggers run targeted scoped checks that update{' '} - Last activity without - resetting Last full patrol. -

-
- -
-
- -

- Run scoped Patrol checks when alerts fire or clear. -

-
- - state.handlePatrolAlertTriggersChange(e.currentTarget.checked) - } - disabled={state.isUpdatingSettings() || !state.patrolEnabledLocal()} - /> -
- -
-
- -

- Run scoped Patrol checks when learned baselines detect high-signal - anomalies. -

-
- - state.handlePatrolAnomalyTriggersChange(e.currentTarget.checked) - } - disabled={state.isUpdatingSettings() || !state.patrolEnabledLocal()} - /> -
- -
-
- -

- Permit Patrol to execute critical remediations without approval. -

-
- state.setFullModeUnlocked(e.currentTarget.checked)} - disabled={ - state.autoFixLocked() || - !(state.autonomyLevel() === 'assisted' || state.autonomyLevel() === 'full') - } - /> -
-
- -
- -
-
+ +
+ 0} + fallback={{quickstartPresentation().summary}} + > + {quickstartPresentation().summary} +
+ +
+ + + +
+
+

+ Patrol Configuration +

+ +
+ +
+
+
+ + +
+ +
+ + +
+
+ +
+
+ +
+ +
+ + {(level) => { + const isProLocked = () => + state.autoFixLocked() && (level === 'approval' || level === 'assisted'); + const isDisabled = () => !state.patrolEnabledLocal() || isProLocked(); + const isActive = () => + level === 'assisted' + ? state.autonomyLevel() === 'assisted' || + state.autonomyLevel() === 'full' + : state.autonomyLevel() === level; + + return ( + + ); + }} + +
+ +
+ + Upgrade to Pro + {' '} + to unlock investigation and auto-fix. + + {' '} + + +
+
+
+ +
+
+
+ +

+ Analyze infrastructure automatically when critical alerts fire. +

+
+ + state.handleAlertTriggeredAnalysisChange(e.currentTarget.checked) + } + disabled={state.isUpdatingSettings() || state.alertAnalysisLocked()} + /> +
+ + +
+ + Upgrade + {' '} + to enable. + + + +
+
+ +
+

+ Full patrols run on the {selectedScheduleLabel().toLowerCase()} schedule. +

+

+ Alert and anomaly triggers run targeted scoped checks that update{' '} + Last activity without + resetting{' '} + Last full patrol. +

+
+ +
+
+ +

+ Run scoped Patrol checks when alerts fire or clear. +

+
+ + state.handlePatrolAlertTriggersChange(e.currentTarget.checked) + } + disabled={state.isUpdatingSettings() || !state.patrolEnabledLocal()} + /> +
+ +
+
+ +

+ Run scoped Patrol checks when learned baselines detect high-signal + anomalies. +

+
+ + state.handlePatrolAnomalyTriggersChange(e.currentTarget.checked) + } + disabled={state.isUpdatingSettings() || !state.patrolEnabledLocal()} + /> +
+ +
+
+ +

+ Permit Patrol to execute critical remediations without approval. +

+
+ state.setFullModeUnlocked(e.currentTarget.checked)} + disabled={ + state.autoFixLocked() || + !( + state.autonomyLevel() === 'assisted' || state.autonomyLevel() === 'full' + ) + } + /> +
+
+ +
+ +
+
+
+
+
diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx index 1b355b52c..62e34d2cf 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx @@ -22,6 +22,48 @@ import { getPatrolRuntimePresentation } from '@/utils/patrolRuntimePresentation' import { formatRelativeTime } from '@/utils/format'; import type { PatrolIntelligenceState } from './usePatrolIntelligenceState'; +function PatrolAssessmentLoadingShell() { + return ( +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceState }) { const state = props.state; const summaryStats = createMemo(() => state.summaryStats()); @@ -46,6 +88,9 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat runtimeState === 'blocked' || runtimeState === 'disabled' || runtimeState === 'unavailable' ); }); + const showLoadingSummary = createMemo( + () => !showRuntimeSummary() && !state.intelligenceSummary() && !state.initialSurfaceReady(), + ); const assessment = createMemo(() => getPatrolAssessmentPresentation({ overallHealth: state.intelligenceSummary()?.overall_health, @@ -64,14 +109,14 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat ); const verification = createMemo(() => getPatrolVerificationPresentation({ - runs: state.patrolRunHistory() ?? [], + runs: state.patrolRunHistory.value() ?? [], runtimeState: state.runtimeState(), blockedReason: state.blockedReason(), }), ); const recency = createMemo(() => getPatrolRecencyPresentation({ - runs: state.patrolRunHistory() ?? [], + runs: state.patrolRunHistory.value() ?? [], lastPatrolAt: state.patrolStatus()?.last_patrol_at, lastActivityAt: state.patrolStatus()?.last_activity_at, }), @@ -79,7 +124,9 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat const fixedSummaryPresentation = createMemo(() => getPatrolSummaryPresentation('success', summaryStats().fixedCount > 0), ); - const latestRun = createMemo(() => getPatrolLatestRunPresentation(state.patrolRunHistory() ?? [])); + const latestRun = createMemo(() => + getPatrolLatestRunPresentation(state.patrolRunHistory.value() ?? []), + ); const triggerSummary = createMemo(() => getPatrolTriggerStatusSummary(state.patrolStatus()?.trigger_status), ); @@ -182,6 +229,10 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat return ( <> + + + +
- + {(summary) => (
diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx index 1aa27838e..ca8362782 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx @@ -126,7 +126,7 @@ export function PatrolIntelligenceWorkspace(props: { state: PatrolIntelligenceSt ('findings'); const [showInvestigationContext, setShowInvestigationContext] = createSignal(false); const [findingsFilterOverride, setFindingsFilterOverride] = createSignal< @@ -106,15 +107,19 @@ export function usePatrolIntelligenceState() { } }; - const [patrolStatus, { refetch: refetchPatrolStatus }] = createResource( - async () => { + const patrolStatusState = createNonSuspendingQuery({ + source: () => 'patrol-status', + fetcher: async () => { try { return await getPatrolStatus(); } catch { return null; } }, - ); + initialValue: null, + }); + const patrolStatus = patrolStatusState.value; + const refetchPatrolStatus = patrolStatusState.refetch; const patrolStream = usePatrolStream({ running: () => @@ -338,9 +343,9 @@ export function usePatrolIntelligenceState() { } } - const [patrolRunHistory] = createResource( - () => activityRefreshTrigger(), - async () => { + const patrolRunHistory = createNonSuspendingQuery({ + source: activityRefreshTrigger, + fetcher: async () => { try { return await getPatrolRunHistory(30); } catch (err) { @@ -348,7 +353,8 @@ export function usePatrolIntelligenceState() { return []; } }, - ); + initialValue: [], + }); const licenseRequired = createMemo(() => patrolStatus()?.license_required ?? false); const upgradeDestination = createMemo(() => getUpgradeActionDestination('ai_autofix')); @@ -490,7 +496,7 @@ export function usePatrolIntelligenceState() { const displayRunHistory = createMemo(() => { const live = liveRunRecord(); - const history = patrolRunHistory() || []; + const history = patrolRunHistory.value() || []; return live ? [live, ...history] : history; }); @@ -645,13 +651,17 @@ export function usePatrolIntelligenceState() { }); onMount(async () => { - await Promise.all([ - loadRuntimeCapabilities(), - loadAllData(), - loadAutonomySettings(), - loadAIRuntimeModels(), - loadAIRuntimeSettings(), - ]); + try { + await Promise.all([ + loadRuntimeCapabilities(), + loadAllData(), + loadAutonomySettings(), + loadAIRuntimeModels(), + loadAIRuntimeSettings(), + ]); + } finally { + setInitialSurfaceReady(true); + } }); onMount(() => { @@ -715,6 +725,7 @@ export function usePatrolIntelligenceState() { handleStartTrial, handleTogglePatrol, hasInvestigationContext, + initialSurfaceReady, intelligenceSummary, investigationContextSummary, isRefreshing, diff --git a/frontend-modern/src/hooks/createNonSuspendingQuery.ts b/frontend-modern/src/hooks/createNonSuspendingQuery.ts index 3f698c0fe..80f5f4cdf 100644 --- a/frontend-modern/src/hooks/createNonSuspendingQuery.ts +++ b/frontend-modern/src/hooks/createNonSuspendingQuery.ts @@ -19,6 +19,7 @@ export function createNonSuspendingQuery(options: CreateNonSuspendingQuery const [value, setValue] = createSignal(options.initialValue); const [loading, setLoading] = createSignal(false); const [error, setError] = createSignal(null); + const [resolvedOnce, setResolvedOnce] = createSignal(false); let latestRequestId = 0; @@ -27,6 +28,7 @@ export function createNonSuspendingQuery(options: CreateNonSuspendingQuery setValue(() => options.initialValue); setLoading(false); setError(null); + setResolvedOnce(false); return options.initialValue; }; @@ -50,8 +52,11 @@ export function createNonSuspendingQuery(options: CreateNonSuspendingQuery } return value(); } finally { - if (requestId === latestRequestId && !runOptions.background) { - setLoading(false); + if (requestId === latestRequestId) { + setResolvedOnce(true); + if (!runOptions.background) { + setLoading(false); + } } } }; @@ -84,6 +89,7 @@ export function createNonSuspendingQuery(options: CreateNonSuspendingQuery error, loading, refetch, + resolvedOnce, value, }; } diff --git a/frontend-modern/src/hooks/useRecoveryPoints.ts b/frontend-modern/src/hooks/useRecoveryPoints.ts index 5cbc95000..bfabb9195 100644 --- a/frontend-modern/src/hooks/useRecoveryPoints.ts +++ b/frontend-modern/src/hooks/useRecoveryPoints.ts @@ -161,5 +161,6 @@ export function useRecoveryPoints(query?: Accessor RecoveryRollupsQuery | null | u return { rollups, refetch: state.refetch, + resolvedOnce: state.resolvedOnce, }; } diff --git a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx index 594287ddf..f917987da 100644 --- a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx +++ b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; -import { createSignal } from 'solid-js'; +import { Suspense, createSignal } from 'solid-js'; import { resetAIRuntimeState } from '@/stores/aiRuntimeState'; import { getPublicPricingUrl } from '@/utils/pricingHandoff'; @@ -666,6 +666,24 @@ describe('AIIntelligence entitlement gating', () => { expect(screen.queryByText('Policy posture')).not.toBeInTheDocument(); }); + it('renders a stable patrol summary loading shell before the first assessment payload arrives', async () => { + getPatrolStatusMock.mockImplementation(() => new Promise(() => {})); + intelligenceState.summary = null; + + render(() => ( + Loading view...
}> + + + )); + + await waitFor(() => { + expect(screen.getByTestId('patrol-summary-loading')).toBeInTheDocument(); + }); + + expect(screen.queryByText('Loading view...')).not.toBeInTheDocument(); + expect(screen.queryByText('Patrol assessment')).not.toBeInTheDocument(); + }); + it('does not present a healthy patrol summary when patrol is blocked on exhausted quickstart credits', async () => { hasFeatureMock.mockReturnValue(true); licenseStatusMock.mockReturnValue({ subscription_state: 'active' }); diff --git a/frontend-modern/src/routing/routePreload.ts b/frontend-modern/src/routing/routePreload.ts new file mode 100644 index 000000000..3be62d11e --- /dev/null +++ b/frontend-modern/src/routing/routePreload.ts @@ -0,0 +1,110 @@ +import { + buildInfrastructurePath, + buildRecoveryPath, + buildStoragePath, + buildWorkloadsPath, + DASHBOARD_PATH, + PATROL_PATH, +} from '@/routing/resourceLinks'; + +type RoutePreloader = { + id: string; + matches: (route: string) => boolean; + preload: () => Promise; +}; + +const ROOT_INFRASTRUCTURE_PATH = buildInfrastructurePath(); +const ROOT_WORKLOADS_PATH = buildWorkloadsPath(); +const STORAGE_PATH = buildStoragePath(); +const RECOVERY_ROUTE_PATH = buildRecoveryPath(); +const ALERTS_PATH = '/alerts'; +const OPERATIONS_PATH = '/operations'; +const SETTINGS_PATH = '/settings'; +const routePreloadCache = new Map>(); + +function normalizeRoute(route: string): string { + const [pathname] = route.split(/[?#]/, 1); + if (!pathname) return ''; + if (pathname.length > 1 && pathname.endsWith('/')) { + return pathname.slice(0, -1); + } + return pathname; +} + +const ROUTE_PRELOADERS: readonly RoutePreloader[] = [ + { + id: 'dashboard', + matches: (route) => route === DASHBOARD_PATH, + preload: () => + import('@/pages/Dashboard').then(() => undefined), + }, + { + id: 'infrastructure', + matches: (route) => route === ROOT_INFRASTRUCTURE_PATH, + preload: () => + import('@/pages/Infrastructure').then(() => undefined), + }, + { + id: 'workloads', + matches: (route) => route === ROOT_WORKLOADS_PATH, + preload: () => + import('@/components/Dashboard/Dashboard').then(() => undefined), + }, + { + id: 'storage', + matches: (route) => route === STORAGE_PATH, + preload: () => + import('@/pages/Storage').then(() => undefined), + }, + { + id: 'recovery', + matches: (route) => route === RECOVERY_ROUTE_PATH, + preload: () => + import('@/pages/RecoveryRoute').then(() => undefined), + }, + { + id: 'alerts', + matches: (route) => route === ALERTS_PATH || route.startsWith(`${ALERTS_PATH}/`), + preload: () => + import('@/pages/Alerts').then(() => undefined), + }, + { + id: 'patrol', + matches: (route) => route === PATROL_PATH || route.startsWith(`${PATROL_PATH}/`), + preload: () => + import('@/pages/AIIntelligence').then(() => undefined), + }, + { + id: 'operations', + matches: (route) => route === OPERATIONS_PATH || route.startsWith(`${OPERATIONS_PATH}/`), + preload: () => + import('@/pages/Operations').then(() => undefined), + }, + { + id: 'settings', + matches: (route) => route === SETTINGS_PATH || route.startsWith(`${SETTINGS_PATH}/`), + preload: () => + import('@/components/Settings/Settings').then(() => undefined), + }, +] as const; + +export async function preloadRouteModule(route: string): Promise { + const normalizedRoute = normalizeRoute(route); + if (!normalizedRoute) return; + + const preloader = ROUTE_PRELOADERS.find((candidate) => candidate.matches(normalizedRoute)); + if (!preloader) return; + + const cached = routePreloadCache.get(preloader.id); + if (cached) { + return cached; + } + + const promise = preloader.preload().catch((error) => { + routePreloadCache.delete(preloader.id); + throw error; + }); + + routePreloadCache.set(preloader.id, promise); + await promise; +}