Stabilize recovery and patrol tab loading shells

This commit is contained in:
rcourtman 2026-04-17 19:10:06 +01:00
parent df06fe84b2
commit 2d0784ca61
20 changed files with 781 additions and 353 deletions

View file

@ -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 (
<Show
when={!runtime.isLoading()}

View file

@ -30,7 +30,9 @@ import type { VersionInfo } from '@/api/updates';
import type { Alert, State } from '@/types/api';
import { useKioskMode } from '@/hooks/useKioskMode';
import { layoutStore } from '@/utils/layout';
import { logger } from '@/utils/logger';
import { getActiveTabForPath } from '@/routing/navigation';
import { preloadRouteModule } from '@/routing/routePreload';
import {
buildInfrastructurePath,
buildWorkloadsPath,
@ -462,15 +464,48 @@ export function AppLayout(props: AppLayoutProps) {
});
const handlePlatformClick = (platform: PlatformTab) => {
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}
>

View file

@ -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('<Route path={ROOT_DASHBOARD_PATH} component={DashboardPage} />');
expect(appSource).toContain('<Route path="/login" component={() => <Navigate href={ROOT_DASHBOARD_PATH} />} />');
expect(appSource).toContain('<Route path="/" component={() => <Navigate href={ROOT_DASHBOARD_PATH} />} />');
@ -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<string, Promise<void>>();');
expect(appRuntimeContextSource).toContain(
"import { createContext, useContext } from 'solid-js';",
);

View file

@ -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('<span class="text-muted">Mode</span>');
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',

View file

@ -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 (
<div data-testid="recovery-page" class="flex flex-col gap-2">
<RecoverySummary
loaded={recoveryRollupsLoaded}
rollups={rollups}
series={() => 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 = () => {
<Show when={workspaceView() === 'events'}>
{eventsActivity()}
<Show when={!recoveryPoints.response.loading && recoveryPoints.response.error}>
<Show when={!recoveryPointsLoading() && recoveryPoints.response.error}>
<Card
padding="none"
tone="card"
@ -642,7 +666,7 @@ const Recovery: Component = () => {
platformFilter={platformFilter}
platformOptions={platformOptions}
queryFilter={queryFilter}
recoveryPoints={recoveryPoints}
recoveryPoints={recoveryPointsModel}
resetAdvancedArtifactFilters={resetAdvancedArtifactFilters}
resetAllArtifactFilters={resetAllArtifactFilters}
resourcesById={resourcesById}

View file

@ -316,8 +316,35 @@ export const RecoveryProtectedInventorySection: Component<
<Card padding="none" tone="card" class="overflow-hidden border-border-subtle bg-surface">
<Show when={props.loading() && props.filteredRollups().length === 0}>
<div class="px-6 py-6 text-sm text-muted">
{getRecoveryProtectedItemsLoadingState().text}
<div
data-testid="recovery-protected-loading"
class="animate-pulse pointer-events-none select-none"
>
<div class="border-b border-border bg-surface-alt/80 px-3 py-2 text-[11px] font-medium uppercase tracking-wide text-muted">
{getRecoveryProtectedItemsLoadingState().text}
</div>
<div class="space-y-3 px-4 py-4">
<div class="flex items-center gap-3">
<div class="h-4 w-32 rounded bg-surface-hover" />
<div class="h-4 w-20 rounded bg-surface-hover" />
<div class="h-4 w-24 rounded bg-surface-hover" />
<div class="ml-auto h-4 w-16 rounded bg-surface-hover" />
</div>
<For each={[1, 2, 3, 4]}>
{() => (
<div class="grid grid-cols-[minmax(0,1.5fr)_110px_110px_120px_90px] gap-3 border-t border-border-subtle pt-3">
<div class="space-y-2">
<div class="h-4 w-3/4 rounded bg-surface-hover" />
<div class="h-3 w-1/2 rounded bg-surface-hover" />
</div>
<div class="h-4 w-16 rounded bg-surface-hover" />
<div class="h-4 w-20 rounded bg-surface-hover" />
<div class="h-4 w-24 rounded bg-surface-hover" />
<div class="h-6 w-16 rounded bg-surface-hover" />
</div>
)}
</For>
</div>
</div>
</Show>

View file

@ -14,6 +14,7 @@ describe('RecoverySummary', () => {
render(() => (
<RecoverySummary
loaded={() => 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(() => (
<RecoverySummary
loaded={() => 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');

View file

@ -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<RecoverySummaryProps> = (props) => {
const loaded = () => props.loaded();
const summary = () => props.summary();
const hasRollups = () => summary().total > 0;
@ -109,10 +111,15 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
return <span class="ml-auto truncate text-xs text-muted">{latestLabel}</span>;
});
return (
<Show when={hasRollups()}>
<Show when={!loaded() || hasRollups()}>
<SummaryPanel
headerLeft={
<span class="font-medium text-base-content">{summary().total} protected items</span>
<Show
when={loaded()}
fallback={<div class="h-4 w-32 rounded bg-surface-hover animate-pulse" />}
>
<span class="font-medium text-base-content">{summary().total} protected items</span>
</Show>
}
timeRange={props.timeRange()}
onTimeRangeChange={props.onTimeRangeChange ? handleTimeRangeChange : undefined}
@ -123,9 +130,9 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
>
<SummaryMetricCard
label="Posture"
secondaryLabel={postureSecondaryLabel()}
secondaryLabel={loaded() ? postureSecondaryLabel() : undefined}
bodyLayout="auto"
loaded={true}
loaded={loaded()}
hasData={hasRollups()}
>
<div class="flex h-full flex-col gap-1.5">
@ -140,9 +147,9 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
<SummaryMetricCard
label="Freshness"
secondaryLabel={freshnessSecondaryLabel()}
secondaryLabel={loaded() ? freshnessSecondaryLabel() : undefined}
bodyLayout="auto"
loaded={true}
loaded={loaded()}
hasData={hasRollups()}
>
<div class="flex h-full flex-col gap-1.5">
@ -157,9 +164,9 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
<SummaryMetricCard
label="Coverage"
secondaryLabel={coverageSecondaryLabel()}
secondaryLabel={loaded() ? coverageSecondaryLabel() : undefined}
bodyLayout="auto"
loaded={true}
loaded={loaded()}
hasData={hasRollups()}
>
<div class="flex h-full flex-col gap-1.5">
@ -174,7 +181,7 @@ export const RecoverySummary: Component<RecoverySummaryProps> = (props) => {
<SummaryMetricCard
label="Activity"
secondaryLabel={activitySecondaryLabel()}
secondaryLabel={props.seriesLoaded() ? activitySecondaryLabel() : undefined}
bodyLayout="auto"
loaded={props.seriesLoaded()}
hasData={activity().hasData}

View file

@ -80,6 +80,7 @@ describe('Recovery layout guards', () => {
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(() => <Recovery />);
expect(screen.getByTestId('recovery-summary')).toBeInTheDocument();
expect(screen.getByTestId('protected-inventory')).toBeInTheDocument();
});
});

View file

@ -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 (
<div class="flex-shrink-0 bg-surface border-b border-border px-4 py-3">
<div class="space-y-4">
<PageHeader
id="patrol-title"
description={headerMeta.description}
@ -132,315 +132,318 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
}
/>
<div class="flex items-center gap-4 mt-2 mb-1">
<div class="flex items-center gap-2 bg-surface-hover px-3 py-1.5 rounded-md border border-border">
<TogglePrimitive
checked={state.patrolEnabledLocal()}
disabled={state.isTogglingPatrol()}
onToggle={state.handleTogglePatrol}
size="sm"
ariaLabel="Toggle Patrol"
/>
<span class="text-sm font-medium text-base-content">{runtimePresentation().label}</span>
</div>
<Show when={showQuickstartStatus()}>
<div
class={`flex items-center gap-1.5 px-3 py-1.5 rounded-md border text-xs font-medium ${quickstartPresentation().className}`}
aria-label={quickstartPresentation().title}
title={quickstartPresentation().title}
>
<Show
when={(state.patrolStatus()?.quickstart_credits_remaining ?? 0) > 0}
fallback={<span>{quickstartPresentation().summary}</span>}
>
<span>{quickstartPresentation().summary}</span>
</Show>
<div class="rounded-md border border-border bg-surface px-4 py-3">
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2 bg-surface-hover px-3 py-1.5 rounded-md border border-border">
<TogglePrimitive
checked={state.patrolEnabledLocal()}
disabled={state.isTogglingPatrol()}
onToggle={state.handleTogglePatrol}
size="sm"
ariaLabel="Toggle Patrol"
/>
<span class="text-sm font-medium text-base-content">{runtimePresentation().label}</span>
</div>
</Show>
<div class="flex-1"></div>
<div class="relative" ref={state.setAdvancedSettingsRef}>
<button
onClick={() => state.setShowAdvancedSettings(!state.showAdvancedSettings())}
disabled={!state.patrolEnabledLocal()}
class={`flex items-center gap-2 px-3 py-1.5 text-sm font-medium rounded-md transition-all shadow-sm ${state.showAdvancedSettings() ? 'bg-blue-50 text-blue-700 border border-blue-200 dark:bg-blue-900 dark:text-blue-300 dark:border-blue-800' : ' text-base-content border border-border hover:bg-surface-alt'} ${!state.patrolEnabledLocal() ? 'opacity-50 cursor-not-allowed hidden' : ''}`}
>
<SettingsIcon class="w-4 h-4" />
Configure Patrol
</button>
<Show when={state.showAdvancedSettings()}>
<div class="absolute right-0 top-10 z-50 w-[340px] p-5 bg-surface rounded-md shadow-sm border border-border animate-slide-up transform origin-top-right">
<div class="flex items-center justify-between mb-5 pb-3 border-b border-border-subtle">
<h4 class="text-base font-semibold tracking-tight text-base-content">
Patrol Configuration
</h4>
<button
onClick={() => state.setShowAdvancedSettings(false)}
class="p-1 rounded-md hover:text-base-content hover:bg-surface-hover transition-colors"
>
<XIcon class="w-4 h-4" />
</button>
</div>
<div class="space-y-6">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-xs font-semibold uppercase tracking-wider text-muted">
Provider model
</label>
<select
ref={state.setPatrolModelSelectRef}
value={state.patrolModel()}
onChange={(e) => state.handleModelChange(e.currentTarget.value)}
disabled={state.isUpdatingSettings() || !state.patrolEnabledLocal()}
class="w-full text-sm bg-base border border-border rounded-md py-2 pl-3 pr-8 text-base-content focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50"
>
<option value="">
Default ({state.defaultModel().split(':').pop() || 'not set'})
</option>
<Show when={patrolModelStale()}>
<option value={state.patrolModel()} disabled>
{state.patrolModel().split(':').pop()} (unavailable)
</option>
</Show>
{Array.from(groupModelsByProvider(state.availableModels()).entries()).map(
([provider, models]) => (
<optgroup label={provider.charAt(0).toUpperCase() + provider.slice(1)}>
{models.map((model) => (
<option value={model.id}>
{model.name || model.id.split(':').pop()}
</option>
))}
</optgroup>
),
)}
</select>
</div>
<div class="space-y-1.5">
<label class="text-xs font-semibold uppercase tracking-wider text-muted">
Run Every
</label>
<select
value={state.patrolInterval()}
onChange={(e) =>
state.handleIntervalChange(parseInt(e.currentTarget.value, 10))
}
disabled={state.isUpdatingSettings() || !state.patrolEnabledLocal()}
class="w-full text-sm bg-base border border-border rounded-md py-2 pl-3 pr-8 text-base-content focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50"
>
<For each={scheduleOptions()}>
{(preset) => <option value={preset.value}>{preset.label}</option>}
</For>
</select>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-xs font-semibold uppercase tracking-wider text-muted flex items-center gap-1.5">
Operational Mode
<div class="relative group">
<CircleHelpIcon class="w-3.5 h-3.5 cursor-help" />
<div class="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 hidden group-hover:block w-64 p-3 bg-surface text-white rounded-md shadow-md text-xs z-50 pointer-events-none before:absolute before:top-full before:left-1/2 before:-translate-x-1/2 before:border-4 before:border-transparent before:border-t-slate-800">
<strong>Monitor:</strong> Detect only.
<br />
<strong>Investigate:</strong> Detect & propose fixes.
<br />
<strong>Auto-fix:</strong> Execute safe fixes automatically.
</div>
</div>
</label>
</div>
<div class="flex items-center bg-base rounded-md p-1 border shadow-inner">
<For each={['monitor', 'approval', 'assisted'] as const}>
{(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 (
<button
onClick={() => state.handleAutonomyChange(level)}
disabled={isDisabled()}
title={
!presentationPolicyHidesUpgradePrompts() && isProLocked()
? level === 'approval'
? 'Upgrade to Pro to investigate findings'
: 'Upgrade to Pro for automatic fixes'
: undefined
}
class={`flex-1 py-1.5 px-2 text-xs font-semibold rounded-md transition-all duration-200 ${isActive() ? ' text-blue-600 dark:text-blue-400 shadow-[0_1px_3px_rgba(0,0,0,0.1)]' : isDisabled() ? ' ' : 'text-muted hover:text-base-content hover:bg-surface-hover'} ${isDisabled() ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{level === 'monitor'
? 'Monitor'
: level === 'approval'
? 'Investigate'
: 'Auto-fix'}
</button>
);
}}
</For>
</div>
<Show when={!presentationPolicyHidesUpgradePrompts() && state.autoFixLocked()}>
<div class="pl-1 text-[11px] text-slate-500">
<UpgradeLink
destination={state.upgradeDestination()}
class="text-indigo-500 font-medium hover:underline"
>
Upgrade to Pro
</UpgradeLink>{' '}
to unlock investigation and auto-fix.
<Show when={state.canStartTrial()}>
{' '}
<button
type="button"
onClick={state.handleStartTrial}
disabled={state.startingTrial()}
class="text-indigo-500 hover:underline"
>
Start free trial
</button>
</Show>
</div>
</Show>
</div>
<div class="space-y-4 pt-4 border-t border-border-subtle">
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<label class="text-sm font-medium text-base-content">
Alert-Triggered Analysis
</label>
<p class="text-[11px] text-muted mt-0.5 leading-tight">
Analyze infrastructure automatically when critical alerts fire.
</p>
</div>
<Toggle
checked={state.alertTriggeredAnalysis()}
onChange={(e) =>
state.handleAlertTriggeredAnalysisChange(e.currentTarget.checked)
}
disabled={state.isUpdatingSettings() || state.alertAnalysisLocked()}
/>
</div>
<Show
when={!presentationPolicyHidesUpgradePrompts() && state.alertAnalysisLocked()}
>
<div class="-my-1 pl-1 text-[11px]">
<UpgradeLink
destination={state.alertAnalysisUpgradeDestination()}
class="text-indigo-500 font-medium hover:underline"
>
Upgrade
</UpgradeLink>{' '}
to enable.
<Show when={state.canStartTrial()}>
<button
type="button"
onClick={state.handleStartTrial}
disabled={state.startingTrial()}
class="ml-1 text-indigo-500 hover:underline"
>
Start free trial
</button>
</Show>
</div>
</Show>
<div class="rounded-md border border-border-subtle bg-surface-alt/60 px-3 py-2.5">
<p class="text-[11px] font-medium text-base-content">
Full patrols run on the {selectedScheduleLabel().toLowerCase()} schedule.
</p>
<p class="mt-1 text-[11px] leading-tight text-muted">
Alert and anomaly triggers run targeted scoped checks that update{' '}
<span class="font-medium text-base-content">Last activity</span> without
resetting <span class="font-medium text-base-content">Last full patrol</span>.
</p>
</div>
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<label class="text-sm font-medium text-base-content">
Alert-Triggered Patrols
</label>
<p class="text-[11px] text-muted mt-0.5 leading-tight">
Run scoped Patrol checks when alerts fire or clear.
</p>
</div>
<Toggle
checked={state.patrolAlertTriggers()}
onChange={(e) =>
state.handlePatrolAlertTriggersChange(e.currentTarget.checked)
}
disabled={state.isUpdatingSettings() || !state.patrolEnabledLocal()}
/>
</div>
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<label class="text-sm font-medium text-base-content">
Anomaly-Triggered Patrols
</label>
<p class="text-[11px] text-muted mt-0.5 leading-tight">
Run scoped Patrol checks when learned baselines detect high-signal
anomalies.
</p>
</div>
<Toggle
checked={state.patrolAnomalyTriggers()}
onChange={(e) =>
state.handlePatrolAnomalyTriggersChange(e.currentTarget.checked)
}
disabled={state.isUpdatingSettings() || !state.patrolEnabledLocal()}
/>
</div>
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<label class="text-sm font-medium text-red-600 dark:text-red-400">
Auto-fix critical issues
</label>
<p class="text-[11px] text-muted mt-0.5 leading-tight">
Permit Patrol to execute critical remediations without approval.
</p>
</div>
<Toggle
checked={!state.autoFixLocked() && state.fullModeUnlocked()}
onChange={(e) => state.setFullModeUnlocked(e.currentTarget.checked)}
disabled={
state.autoFixLocked() ||
!(state.autonomyLevel() === 'assisted' || state.autonomyLevel() === 'full')
}
/>
</div>
</div>
<div class="pt-4 border-t border-border-subtle">
<button
onClick={state.saveAdvancedSettings}
disabled={state.isSavingAdvanced()}
class="w-full py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md shadow-sm transition-all focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-70 flex items-center justify-center gap-2"
>
<Show when={state.isSavingAdvanced()}>
<div class="animate-spin w-4 h-4 border-2 border-current border-t-transparent rounded-full"></div>
</Show>
<Show when={!state.isSavingAdvanced()}>Apply Configuration</Show>
</button>
</div>
</div>
<Show when={showQuickstartStatus()}>
<div
class={`flex items-center gap-1.5 px-3 py-1.5 rounded-md border text-xs font-medium ${quickstartPresentation().className}`}
aria-label={quickstartPresentation().title}
title={quickstartPresentation().title}
>
<Show
when={(state.patrolStatus()?.quickstart_credits_remaining ?? 0) > 0}
fallback={<span>{quickstartPresentation().summary}</span>}
>
<span>{quickstartPresentation().summary}</span>
</Show>
</div>
</Show>
<div class="relative ml-auto" ref={state.setAdvancedSettingsRef}>
<button
onClick={() => state.setShowAdvancedSettings(!state.showAdvancedSettings())}
disabled={!state.patrolEnabledLocal()}
class={`flex items-center gap-2 px-3 py-1.5 text-sm font-medium rounded-md transition-all shadow-sm ${state.showAdvancedSettings() ? 'bg-blue-50 text-blue-700 border border-blue-200 dark:bg-blue-900 dark:text-blue-300 dark:border-blue-800' : ' text-base-content border border-border hover:bg-surface-alt'} ${!state.patrolEnabledLocal() ? 'opacity-50 cursor-not-allowed hidden' : ''}`}
>
<SettingsIcon class="w-4 h-4" />
Configure Patrol
</button>
<Show when={state.showAdvancedSettings()}>
<div class="absolute right-0 top-10 z-50 w-[340px] p-5 bg-surface rounded-md shadow-sm border border-border animate-slide-up transform origin-top-right">
<div class="flex items-center justify-between mb-5 pb-3 border-b border-border-subtle">
<h4 class="text-base font-semibold tracking-tight text-base-content">
Patrol Configuration
</h4>
<button
onClick={() => state.setShowAdvancedSettings(false)}
class="p-1 rounded-md hover:text-base-content hover:bg-surface-hover transition-colors"
>
<XIcon class="w-4 h-4" />
</button>
</div>
<div class="space-y-6">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-xs font-semibold uppercase tracking-wider text-muted">
Provider model
</label>
<select
ref={state.setPatrolModelSelectRef}
value={state.patrolModel()}
onChange={(e) => state.handleModelChange(e.currentTarget.value)}
disabled={state.isUpdatingSettings() || !state.patrolEnabledLocal()}
class="w-full text-sm bg-base border border-border rounded-md py-2 pl-3 pr-8 text-base-content focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50"
>
<option value="">
Default ({state.defaultModel().split(':').pop() || 'not set'})
</option>
<Show when={patrolModelStale()}>
<option value={state.patrolModel()} disabled>
{state.patrolModel().split(':').pop()} (unavailable)
</option>
</Show>
{Array.from(groupModelsByProvider(state.availableModels()).entries()).map(
([provider, models]) => (
<optgroup label={provider.charAt(0).toUpperCase() + provider.slice(1)}>
{models.map((model) => (
<option value={model.id}>
{model.name || model.id.split(':').pop()}
</option>
))}
</optgroup>
),
)}
</select>
</div>
<div class="space-y-1.5">
<label class="text-xs font-semibold uppercase tracking-wider text-muted">
Run Every
</label>
<select
value={state.patrolInterval()}
onChange={(e) =>
state.handleIntervalChange(parseInt(e.currentTarget.value, 10))
}
disabled={state.isUpdatingSettings() || !state.patrolEnabledLocal()}
class="w-full text-sm bg-base border border-border rounded-md py-2 pl-3 pr-8 text-base-content focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50"
>
<For each={scheduleOptions()}>
{(preset) => <option value={preset.value}>{preset.label}</option>}
</For>
</select>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-xs font-semibold uppercase tracking-wider text-muted flex items-center gap-1.5">
Operational Mode
<div class="relative group">
<CircleHelpIcon class="w-3.5 h-3.5 cursor-help" />
<div class="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 hidden group-hover:block w-64 p-3 bg-surface text-white rounded-md shadow-md text-xs z-50 pointer-events-none before:absolute before:top-full before:left-1/2 before:-translate-x-1/2 before:border-4 before:border-transparent before:border-t-slate-800">
<strong>Monitor:</strong> Detect only.
<br />
<strong>Investigate:</strong> Detect & propose fixes.
<br />
<strong>Auto-fix:</strong> Execute safe fixes automatically.
</div>
</div>
</label>
</div>
<div class="flex items-center bg-base rounded-md p-1 border shadow-inner">
<For each={['monitor', 'approval', 'assisted'] as const}>
{(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 (
<button
onClick={() => state.handleAutonomyChange(level)}
disabled={isDisabled()}
title={
!presentationPolicyHidesUpgradePrompts() && isProLocked()
? level === 'approval'
? 'Upgrade to Pro to investigate findings'
: 'Upgrade to Pro for automatic fixes'
: undefined
}
class={`flex-1 py-1.5 px-2 text-xs font-semibold rounded-md transition-all duration-200 ${isActive() ? ' text-blue-600 dark:text-blue-400 shadow-[0_1px_3px_rgba(0,0,0,0.1)]' : isDisabled() ? ' ' : 'text-muted hover:text-base-content hover:bg-surface-hover'} ${isDisabled() ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{level === 'monitor'
? 'Monitor'
: level === 'approval'
? 'Investigate'
: 'Auto-fix'}
</button>
);
}}
</For>
</div>
<Show when={!presentationPolicyHidesUpgradePrompts() && state.autoFixLocked()}>
<div class="pl-1 text-[11px] text-slate-500">
<UpgradeLink
destination={state.upgradeDestination()}
class="text-indigo-500 font-medium hover:underline"
>
Upgrade to Pro
</UpgradeLink>{' '}
to unlock investigation and auto-fix.
<Show when={state.canStartTrial()}>
{' '}
<button
type="button"
onClick={state.handleStartTrial}
disabled={state.startingTrial()}
class="text-indigo-500 hover:underline"
>
Start free trial
</button>
</Show>
</div>
</Show>
</div>
<div class="space-y-4 pt-4 border-t border-border-subtle">
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<label class="text-sm font-medium text-base-content">
Alert-Triggered Analysis
</label>
<p class="text-[11px] text-muted mt-0.5 leading-tight">
Analyze infrastructure automatically when critical alerts fire.
</p>
</div>
<Toggle
checked={state.alertTriggeredAnalysis()}
onChange={(e) =>
state.handleAlertTriggeredAnalysisChange(e.currentTarget.checked)
}
disabled={state.isUpdatingSettings() || state.alertAnalysisLocked()}
/>
</div>
<Show
when={!presentationPolicyHidesUpgradePrompts() && state.alertAnalysisLocked()}
>
<div class="-my-1 pl-1 text-[11px]">
<UpgradeLink
destination={state.alertAnalysisUpgradeDestination()}
class="text-indigo-500 font-medium hover:underline"
>
Upgrade
</UpgradeLink>{' '}
to enable.
<Show when={state.canStartTrial()}>
<button
type="button"
onClick={state.handleStartTrial}
disabled={state.startingTrial()}
class="ml-1 text-indigo-500 hover:underline"
>
Start free trial
</button>
</Show>
</div>
</Show>
<div class="rounded-md border border-border-subtle bg-surface-alt/60 px-3 py-2.5">
<p class="text-[11px] font-medium text-base-content">
Full patrols run on the {selectedScheduleLabel().toLowerCase()} schedule.
</p>
<p class="mt-1 text-[11px] leading-tight text-muted">
Alert and anomaly triggers run targeted scoped checks that update{' '}
<span class="font-medium text-base-content">Last activity</span> without
resetting{' '}
<span class="font-medium text-base-content">Last full patrol</span>.
</p>
</div>
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<label class="text-sm font-medium text-base-content">
Alert-Triggered Patrols
</label>
<p class="text-[11px] text-muted mt-0.5 leading-tight">
Run scoped Patrol checks when alerts fire or clear.
</p>
</div>
<Toggle
checked={state.patrolAlertTriggers()}
onChange={(e) =>
state.handlePatrolAlertTriggersChange(e.currentTarget.checked)
}
disabled={state.isUpdatingSettings() || !state.patrolEnabledLocal()}
/>
</div>
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<label class="text-sm font-medium text-base-content">
Anomaly-Triggered Patrols
</label>
<p class="text-[11px] text-muted mt-0.5 leading-tight">
Run scoped Patrol checks when learned baselines detect high-signal
anomalies.
</p>
</div>
<Toggle
checked={state.patrolAnomalyTriggers()}
onChange={(e) =>
state.handlePatrolAnomalyTriggersChange(e.currentTarget.checked)
}
disabled={state.isUpdatingSettings() || !state.patrolEnabledLocal()}
/>
</div>
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<label class="text-sm font-medium text-red-600 dark:text-red-400">
Auto-fix critical issues
</label>
<p class="text-[11px] text-muted mt-0.5 leading-tight">
Permit Patrol to execute critical remediations without approval.
</p>
</div>
<Toggle
checked={!state.autoFixLocked() && state.fullModeUnlocked()}
onChange={(e) => state.setFullModeUnlocked(e.currentTarget.checked)}
disabled={
state.autoFixLocked() ||
!(
state.autonomyLevel() === 'assisted' || state.autonomyLevel() === 'full'
)
}
/>
</div>
</div>
<div class="pt-4 border-t border-border-subtle">
<button
onClick={state.saveAdvancedSettings}
disabled={state.isSavingAdvanced()}
class="w-full py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md shadow-sm transition-all focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-70 flex items-center justify-center gap-2"
>
<Show when={state.isSavingAdvanced()}>
<div class="animate-spin w-4 h-4 border-2 border-current border-t-transparent rounded-full"></div>
</Show>
<Show when={!state.isSavingAdvanced()}>Apply Configuration</Show>
</button>
</div>
</div>
</div>
</Show>
</div>
</div>
</div>
</div>

View file

@ -22,6 +22,48 @@ import { getPatrolRuntimePresentation } from '@/utils/patrolRuntimePresentation'
import { formatRelativeTime } from '@/utils/format';
import type { PatrolIntelligenceState } from './usePatrolIntelligenceState';
function PatrolAssessmentLoadingShell() {
return (
<section
data-testid="patrol-summary-loading"
class="overflow-hidden rounded-md border border-border bg-surface shadow-sm animate-pulse pointer-events-none select-none"
>
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-border-subtle px-4 py-3">
<div class="h-5 w-32 rounded bg-surface-hover" />
<div class="h-5 w-24 rounded bg-surface-hover" />
</div>
<div class="px-4 py-4 sm:px-5 sm:py-5">
<div class="flex items-start gap-3">
<div class="h-11 w-11 rounded-md border border-border-subtle bg-surface-alt/60" />
<div class="min-w-0 flex-1 space-y-2">
<div class="h-5 w-44 rounded bg-surface-hover" />
<div class="h-4 max-w-3xl rounded bg-surface-hover" />
<div class="h-4 w-2/3 rounded bg-surface-hover" />
</div>
</div>
<div class="mt-5 overflow-hidden rounded-md border border-border-subtle bg-surface-alt/60">
<div class="grid divide-y divide-border-subtle lg:grid-cols-[minmax(0,1.35fr)_minmax(0,1fr)] lg:divide-x lg:divide-y-0">
<div class="space-y-2 p-3">
<div class="h-3 w-20 rounded bg-surface-hover" />
<div class="h-4 w-40 rounded bg-surface-hover" />
<div class="h-4 w-full rounded bg-surface-hover" />
<div class="h-4 w-3/4 rounded bg-surface-hover" />
</div>
<div class="space-y-2 p-3">
<div class="h-3 w-24 rounded bg-surface-hover" />
<div class="h-4 w-36 rounded bg-surface-hover" />
<div class="h-4 w-full rounded bg-surface-hover" />
<div class="h-4 w-2/3 rounded bg-surface-hover" />
</div>
</div>
</div>
</div>
</section>
);
}
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 (
<>
<Show when={showLoadingSummary()}>
<PatrolAssessmentLoadingShell />
</Show>
<Show when={showRuntimeSummary()}>
<section class="overflow-hidden rounded-md border border-border bg-surface shadow-sm">
<div
@ -223,7 +274,7 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat
</section>
</Show>
<Show when={!showRuntimeSummary()}>
<Show when={!showRuntimeSummary() && !showLoadingSummary()}>
<Show when={state.intelligenceSummary()}>
{(summary) => (
<section class="overflow-hidden rounded-md border border-border bg-surface shadow-sm">

View file

@ -126,7 +126,7 @@ export function PatrolIntelligenceWorkspace(props: { state: PatrolIntelligenceSt
<Show when={state.activeTab() === 'history'}>
<RunHistoryPanel
runs={state.displayRunHistory()}
loading={state.patrolRunHistory.loading}
loading={state.patrolRunHistory.loading()}
selectedRun={state.selectedRun()}
onSelectRun={state.setSelectedRun}
patrolStream={state.patrolStream}

View file

@ -1,7 +1,6 @@
import {
createEffect,
createMemo,
createResource,
createSignal,
onCleanup,
onMount,
@ -29,6 +28,7 @@ import {
import { notificationStore } from '@/stores/notifications';
import { hasTriggeringAlert } from '@/utils/findingAlertIdentity';
import { usePatrolStream } from '@/hooks/usePatrolStream';
import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery';
import {
hasFeature,
loadRuntimeCapabilities,
@ -46,6 +46,7 @@ import { runStartProTrialAction } from '@/utils/trialStartAction';
type PatrolTab = 'findings' | 'history';
export function usePatrolIntelligenceState() {
const [initialSurfaceReady, setInitialSurfaceReady] = createSignal(false);
const [activeTab, setActiveTab] = createSignal<PatrolTab>('findings');
const [showInvestigationContext, setShowInvestigationContext] = createSignal(false);
const [findingsFilterOverride, setFindingsFilterOverride] = createSignal<
@ -106,15 +107,19 @@ export function usePatrolIntelligenceState() {
}
};
const [patrolStatus, { refetch: refetchPatrolStatus }] = createResource<PatrolStatus | null>(
async () => {
const patrolStatusState = createNonSuspendingQuery<PatrolStatus | null, string>({
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<PatrolRunRecord[], number>({
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,

View file

@ -19,6 +19,7 @@ export function createNonSuspendingQuery<T, K>(options: CreateNonSuspendingQuery
const [value, setValue] = createSignal<T>(options.initialValue);
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal<unknown>(null);
const [resolvedOnce, setResolvedOnce] = createSignal(false);
let latestRequestId = 0;
@ -27,6 +28,7 @@ export function createNonSuspendingQuery<T, K>(options: CreateNonSuspendingQuery
setValue(() => options.initialValue);
setLoading(false);
setError(null);
setResolvedOnce(false);
return options.initialValue;
};
@ -50,8 +52,11 @@ export function createNonSuspendingQuery<T, K>(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<T, K>(options: CreateNonSuspendingQuery
error,
loading,
refetch,
resolvedOnce,
value,
};
}

View file

@ -161,5 +161,6 @@ export function useRecoveryPoints(query?: Accessor<RecoveryPointsQuery | null |
points,
meta,
refetch: state.refetch,
resolvedOnce: state.resolvedOnce,
};
}

View file

@ -146,5 +146,6 @@ export function useRecoveryPointsFacets(query?: Accessor<RecoveryFacetsQuery | n
response,
facets,
refetch: state.refetch,
resolvedOnce: state.resolvedOnce,
};
}

View file

@ -132,5 +132,6 @@ export function useRecoveryPointsSeries(query?: Accessor<RecoverySeriesQuery | n
response,
series,
refetch: state.refetch,
resolvedOnce: state.resolvedOnce,
};
}

View file

@ -136,5 +136,6 @@ export function useRecoveryRollups(query?: () => RecoveryRollupsQuery | null | u
return {
rollups,
refetch: state.refetch,
resolvedOnce: state.resolvedOnce,
};
}

View file

@ -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(() => (
<Suspense fallback={<div>Loading view...</div>}>
<AIIntelligence />
</Suspense>
));
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' });

View file

@ -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<void>;
};
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<string, Promise<void>>();
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<void> {
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;
}