mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-15 09:49:48 +00:00
Stabilize recovery and patrol tab loading shells
This commit is contained in:
parent
df06fe84b2
commit
2d0784ca61
20 changed files with 781 additions and 353 deletions
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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';",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,5 +161,6 @@ export function useRecoveryPoints(query?: Accessor<RecoveryPointsQuery | null |
|
|||
points,
|
||||
meta,
|
||||
refetch: state.refetch,
|
||||
resolvedOnce: state.resolvedOnce,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,5 +146,6 @@ export function useRecoveryPointsFacets(query?: Accessor<RecoveryFacetsQuery | n
|
|||
response,
|
||||
facets,
|
||||
refetch: state.refetch,
|
||||
resolvedOnce: state.resolvedOnce,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -132,5 +132,6 @@ export function useRecoveryPointsSeries(query?: Accessor<RecoverySeriesQuery | n
|
|||
response,
|
||||
series,
|
||||
refetch: state.refetch,
|
||||
resolvedOnce: state.resolvedOnce,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,5 +136,6 @@ export function useRecoveryRollups(query?: () => RecoveryRollupsQuery | null | u
|
|||
return {
|
||||
rollups,
|
||||
refetch: state.refetch,
|
||||
resolvedOnce: state.resolvedOnce,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
110
frontend-modern/src/routing/routePreload.ts
Normal file
110
frontend-modern/src/routing/routePreload.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue