Extract operations route surface owners

This commit is contained in:
rcourtman 2026-03-20 21:47:23 +00:00
parent e2275133a5
commit 68bae84bcf
10 changed files with 208 additions and 105 deletions

View file

@ -226,6 +226,7 @@ work must extend that split rather than pulling org bootstrap and app chrome
back into one monolithic route component.
That same route/provider shell must stay page-oriented as well: `App.tsx`
should lazy-load route shells like `frontend-modern/src/pages/Storage.tsx`
and `frontend-modern/src/pages/Operations.tsx`
instead of wiring product-surface components such as
`frontend-modern/src/components/Storage/Storage.tsx` directly into the router,
so hosted bootstrap ownership stays at the app boundary rather than leaking

View file

@ -61,6 +61,12 @@ work extends shared components instead of creating new local variants.
39. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx`
40. `frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx`
41. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx`
42. `frontend-modern/src/components/Settings/ReportingPanel.tsx`
43. `frontend-modern/src/components/Settings/SystemLogsPanel.tsx`
44. `frontend-modern/src/features/operations/OperationsPageSurface.tsx`
45. `frontend-modern/src/features/operations/operationsPageModel.ts`
46. `frontend-modern/src/pages/Operations.tsx`
47. `frontend-modern/src/pages/__tests__/Operations.helpers.test.ts`
## Shared Boundaries
@ -140,6 +146,15 @@ diagnostics run/export lifecycle, results rendering, and sanitization/model
helpers. The shell must not re-accumulate inline API calls, export-download
plumbing, or diagnostics-card composition.
The operations route now follows the same thin-route pattern as infrastructure,
storage, and Patrol. `frontend-modern/src/pages/Operations.tsx` stays the route
shell, `frontend-modern/src/features/operations/OperationsPageSurface.tsx` owns
the tabbed operations surface, and
`frontend-modern/src/features/operations/operationsPageModel.ts` owns the tab
and path contract. The operations route must keep its navigation routed through
the shared `frontend-modern/src/components/shared/Subtabs.tsx` primitive rather
than rebuilding a bespoke page-local tab bar.
The updates settings surface now follows the same presentation-owner rule.
`frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx` stays the
top-level settings shell, while

View file

@ -1654,6 +1654,7 @@
"frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx",
"frontend-modern/src/components/Settings/NetworkSettingsPanel.tsx",
"frontend-modern/src/components/Settings/RecoverySettingsPanel.tsx",
"frontend-modern/src/components/Settings/ReportingPanel.tsx",
"frontend-modern/src/components/Settings/SecurityAuthPanel.tsx",
"frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx",
"frontend-modern/src/components/Settings/Settings.tsx",
@ -1662,6 +1663,7 @@
"frontend-modern/src/components/Settings/settingsPanelRegistry.ts",
"frontend-modern/src/components/Settings/ssoProvidersModel.ts",
"frontend-modern/src/components/Settings/SSOProvidersPanel.tsx",
"frontend-modern/src/components/Settings/SystemLogsPanel.tsx",
"frontend-modern/src/components/Settings/UpdateInstallGuide.tsx",
"frontend-modern/src/components/Settings/updatesSettingsModel.ts",
"frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx",
@ -1669,7 +1671,10 @@
"frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts",
"frontend-modern/src/components/Settings/useSSOProvidersState.ts",
"frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx",
"frontend-modern/src/components/SetupWizard/SetupWizard.tsx"
"frontend-modern/src/components/SetupWizard/SetupWizard.tsx",
"frontend-modern/src/features/operations/operationsPageModel.ts",
"frontend-modern/src/features/operations/OperationsPageSurface.tsx",
"frontend-modern/src/pages/Operations.tsx"
],
"verification": {
"allow_same_subsystem_tests": false,
@ -1740,6 +1745,25 @@
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts"
]
},
{
"id": "route-shell-and-operations",
"label": "route shell and operations proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/components/Settings/ReportingPanel.tsx",
"frontend-modern/src/components/Settings/SystemLogsPanel.tsx",
"frontend-modern/src/features/operations/operationsPageModel.ts",
"frontend-modern/src/features/operations/OperationsPageSurface.tsx",
"frontend-modern/src/pages/Operations.tsx"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/__tests__/App.architecture.test.ts",
"frontend-modern/src/pages/__tests__/Operations.helpers.test.ts",
"frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts"
]
},
{
"id": "first-session-runtime-and-preview",
"label": "first-session runtime and preview proof",

View file

@ -73,7 +73,7 @@ const NotFoundPage = lazy(() => import('./pages/NotFound'));
const PricingPage = lazy(() => import('./pages/PricingV6'));
const CloudPricingPage = lazy(() => import('./pages/CloudPricing'));
const HostedSignupPage = lazy(() => import('./pages/HostedSignup'));
const Operations = lazy(() => import('./pages/Operations'));
const OperationsPage = lazy(() => import('./pages/Operations'));
const SetupCompletionPreviewPage = lazy(() =>
import('./components/SetupWizard/SetupCompletionPreview').then((module) => ({
default: module.SetupCompletionPreview,
@ -393,7 +393,7 @@ function App() {
<Route path="/ai/*" component={AIIntelligencePage} />
<Route path="/settings/operations/*" component={LegacyOperationsSettingsRedirect} />
<Route path="/settings/*" component={SettingsRoute} />
<Route path="/operations/*" component={Operations} />
<Route path="/operations/*" component={OperationsPage} />
<Route path="*all" component={NotFoundPage} />
</Router>
);

View file

@ -9,6 +9,7 @@ describe('App architecture', () => {
expect(appSource).toContain("import { useAppRuntimeState } from '@/useAppRuntimeState';");
expect(appSource).toContain('const runtime = useAppRuntimeState();');
expect(appSource).toContain("const StoragePage = lazy(() => import('./pages/Storage'));");
expect(appSource).toContain("const OperationsPage = lazy(() => import('./pages/Operations'));");
expect(appSource).not.toContain(
"const StorageComponent = lazy(() => import('./components/Storage/Storage'));",
);

View file

@ -0,0 +1,79 @@
import { Suspense, createMemo, type Component, type JSX } from 'solid-js';
import { useLocation, useNavigate } from '@solidjs/router';
import ActivityIcon from 'lucide-solid/icons/activity';
import FileTextIcon from 'lucide-solid/icons/file-text';
import TerminalIcon from 'lucide-solid/icons/terminal';
import { DiagnosticsPanel } from '@/components/Settings/DiagnosticsPanel';
import { ReportingPanel } from '@/components/Settings/ReportingPanel';
import { SystemLogsPanel } from '@/components/Settings/SystemLogsPanel';
import { Subtabs, type SubtabOption } from '@/components/shared/Subtabs';
import {
buildOperationsPath,
getOperationsTabFromPath,
OPERATIONS_TABS,
type OperationsTabId,
} from '@/features/operations/operationsPageModel';
const operationsTabIcons: Record<OperationsTabId, Component<{ class?: string }>> = {
diagnostics: ActivityIcon,
reporting: FileTextIcon,
logs: TerminalIcon,
};
export function OperationsPageSurface() {
const location = useLocation();
const navigate = useNavigate();
const activeTab = createMemo(() => getOperationsTabFromPath(location.pathname));
const tabs = createMemo<SubtabOption[]>(() =>
OPERATIONS_TABS.map((tab) => {
const Icon = operationsTabIcons[tab.id];
return {
value: tab.id,
label: (
<span class="inline-flex items-center gap-2.5" title={tab.description}>
<Icon class="h-4 w-4" />
<span>{tab.label}</span>
</span>
) satisfies JSX.Element,
};
}),
);
const handleTabChange = (tabId: string) => {
navigate(buildOperationsPath(tabId as OperationsTabId));
};
return (
<div class="space-y-6">
<div class="mb-6">
<Subtabs
value={activeTab()}
onChange={handleTabChange}
tabs={tabs()}
ariaLabel="Operations"
class="rounded-md border border-border bg-surface-alt p-1.5 sm:w-max"
listClass="gap-2 overflow-x-auto scrollbar-hide"
tabClass="min-h-10 whitespace-nowrap rounded-md border border-transparent px-4 py-2 text-sm"
/>
</div>
<div class="mt-4 animate-fade-in animate-duration-200">
<Suspense
fallback={
<div class="flex justify-center p-6">
<div class="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent"></div>
</div>
}
>
{activeTab() === 'diagnostics' && <DiagnosticsPanel />}
{activeTab() === 'reporting' && <ReportingPanel />}
{activeTab() === 'logs' && <SystemLogsPanel />}
</Suspense>
</div>
</div>
);
}
export default OperationsPageSurface;

View file

@ -0,0 +1,36 @@
export type OperationsTabId = 'diagnostics' | 'reporting' | 'logs';
export interface OperationsTabDefinition {
id: OperationsTabId;
label: string;
description: string;
}
export const OPERATIONS_TABS: readonly OperationsTabDefinition[] = [
{
id: 'diagnostics',
label: 'Diagnostics & Health',
description: 'System health, connection tests, and troubleshooting',
},
{
id: 'reporting',
label: 'Data Export & Reports',
description: 'Export system metrics and configuration data',
},
{
id: 'logs',
label: 'System Logs',
description: 'View real-time Pulse system logs',
},
];
export function getOperationsTabFromPath(pathname: string): OperationsTabId {
const lastPathSegment = pathname.split('/').pop() || '';
if (lastPathSegment === 'reporting') return 'reporting';
if (lastPathSegment === 'logs') return 'logs';
return 'diagnostics';
}
export function buildOperationsPath(tabId: OperationsTabId): string {
return `/operations/${tabId}`;
}

View file

@ -1,107 +1,8 @@
import { Component, createSignal, createEffect, Suspense } from 'solid-js';
import { useLocation, useNavigate } from '@solidjs/router';
import ActivityIcon from 'lucide-solid/icons/activity';
import FileTextIcon from 'lucide-solid/icons/file-text';
import TerminalIcon from 'lucide-solid/icons/terminal';
// Import the panels
import { DiagnosticsPanel } from '@/components/Settings/DiagnosticsPanel';
import { ReportingPanel } from '@/components/Settings/ReportingPanel';
import { SystemLogsPanel } from '@/components/Settings/SystemLogsPanel';
type OperationsTabId = 'diagnostics' | 'reporting' | 'logs';
import type { Component } from 'solid-js';
import { OperationsPageSurface } from '@/features/operations/OperationsPageSurface';
export const Operations: Component = () => {
const location = useLocation();
const navigate = useNavigate();
// Parse active tab from URL path
const getActiveTab = (): OperationsTabId => {
const path = location.pathname.split('/').pop() || '';
if (path === 'reporting') return 'reporting';
if (path === 'logs') return 'logs';
return 'diagnostics'; // default
};
const [activeTab, setActiveTabSignal] = createSignal<OperationsTabId>(getActiveTab());
createEffect(() => {
setActiveTabSignal(getActiveTab());
});
const handleTabChange = (tabId: OperationsTabId) => {
navigate(`/operations/${tabId}`);
};
const tabs = [
{
id: 'diagnostics' as OperationsTabId,
label: 'Diagnostics & Health',
icon: ActivityIcon,
desc: 'System health, connection tests, and troubleshooting',
},
{
id: 'reporting' as OperationsTabId,
label: 'Data Export & Reports',
icon: FileTextIcon,
desc: 'Export system metrics and configuration data',
},
{
id: 'logs' as OperationsTabId,
label: 'System Logs',
icon: TerminalIcon,
desc: 'View real-time Pulse system logs',
},
];
return (
<div class="space-y-6">
{/* Modern Tabs Navigation */}
<div class="mb-6">
<nav
class="flex space-x-2 bg-surface-alt p-1.5 rounded-md sm:w-max border border-border overflow-x-auto scrollbar-hide"
aria-label="Tabs"
style="-webkit-overflow-scrolling: touch;"
>
{tabs.map((tab) => {
const isActive = () => activeTab() === tab.id;
return (
<button
onClick={() => handleTabChange(tab.id)}
class={`flex items-center gap-2.5 whitespace-nowrap px-4 py-2 rounded-md font-medium text-sm transition-all outline-none relative overflow-hidden group ${
isActive()
? 'bg-surface text-base-content shadow-sm border border-border'
: 'text-muted hover:bg-surface hover:text-base-content border border-transparent'
}`}
aria-current={isActive() ? 'page' : undefined}
title={tab.desc}
>
<tab.icon
class={`w-4 h-4 transition-transform duration-200 ${isActive() ? 'text-blue-500 scale-110' : 'text-muted group-hover:scale-110 group-hover:text-blue-500'}`}
/>
<span class="relative z-10">{tab.label}</span>
</button>
);
})}
</nav>
</div>
{/* View Content */}
<div class="mt-4 animate-fade-in animate-duration-200">
<Suspense
fallback={
<div class="p-6 flex justify-center">
<div class="animate-spin w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full"></div>
</div>
}
>
{activeTab() === 'diagnostics' && <DiagnosticsPanel />}
{activeTab() === 'reporting' && <ReportingPanel />}
{activeTab() === 'logs' && <SystemLogsPanel />}
</Suspense>
</div>
</div>
);
return <OperationsPageSurface />;
};
export default Operations;

View file

@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import appSource from '@/App.tsx?raw';
import operationsPageRouteSource from '@/pages/Operations.tsx?raw';
import operationsPageSurfaceSource from '@/features/operations/OperationsPageSurface.tsx?raw';
import operationsPageModelSource from '@/features/operations/operationsPageModel.ts?raw';
describe('operations page route shell', () => {
it('keeps App routing on a page shell instead of a page-local route controller', () => {
expect(appSource).toContain("const OperationsPage = lazy(() => import('./pages/Operations'));");
expect(operationsPageRouteSource).toContain(
"import { OperationsPageSurface } from '@/features/operations/OperationsPageSurface';",
);
expect(operationsPageRouteSource).toContain('<OperationsPageSurface />');
expect(operationsPageRouteSource).not.toContain('useLocation');
expect(operationsPageRouteSource).not.toContain('useNavigate');
expect(operationsPageRouteSource).not.toContain('createSignal');
expect(operationsPageSurfaceSource).toContain('@/components/shared/Subtabs');
expect(operationsPageSurfaceSource).toContain('getOperationsTabFromPath');
expect(operationsPageSurfaceSource).toContain('buildOperationsPath');
expect(operationsPageSurfaceSource).not.toContain('-webkit-overflow-scrolling');
expect(operationsPageModelSource).toContain('export const OPERATIONS_TABS');
expect(operationsPageModelSource).toContain('export function getOperationsTabFromPath');
expect(operationsPageModelSource).toContain('export function buildOperationsPath');
});
});

View file

@ -30,6 +30,7 @@ import environmentLockBadgeSource from '@/components/shared/EnvironmentLockBadge
import environmentLockPresentationSource from '@/utils/environmentLockPresentation.ts?raw';
import dockerRuntimeSettingsCardSource from '@/components/Settings/DockerRuntimeSettingsCard.tsx?raw';
import infrastructurePageShellSource from '@/pages/Infrastructure.tsx?raw';
import operationsPageRouteSource from '@/pages/Operations.tsx?raw';
import discoveryTargetSource from '@/utils/discoveryTarget.ts?raw';
import infrastructureEmptyStatePresentationSource from '@/utils/infrastructureEmptyStatePresentation.ts?raw';
import recoverySummarySource from '@/components/Recovery/RecoverySummary.tsx?raw';
@ -97,6 +98,8 @@ import useChatSource from '@/components/AI/Chat/hooks/useChat.ts?raw';
import patrolStatusBarSource from '@/components/patrol/PatrolStatusBar.tsx?raw';
import patrolFormatSource from '@/utils/patrolFormat.ts?raw';
import aiFindingPresentationSource from '@/utils/aiFindingPresentation.ts?raw';
import operationsPageSurfaceSource from '@/features/operations/OperationsPageSurface.tsx?raw';
import operationsPageModelSource from '@/features/operations/operationsPageModel.ts?raw';
import chatIdentifiersSource from '@/utils/chatIdentifiers.ts?raw';
import resourceIdentitySource from '@/utils/resourceIdentity.ts?raw';
import stringUtilsSource from '@/utils/stringUtils.ts?raw';
@ -2748,6 +2751,24 @@ describe('frontend resource type boundaries', () => {
);
expect(aiIntelligenceSource).not.toContain('getPatrolSummaryPresentation');
expect(aiIntelligenceSource).not.toContain('getAIQuickstartCreditsPresentation');
expect(operationsPageRouteSource).toContain(
"import { OperationsPageSurface } from '@/features/operations/OperationsPageSurface';",
);
expect(operationsPageRouteSource).toContain('<OperationsPageSurface />');
expect(operationsPageRouteSource).not.toContain('useLocation');
expect(operationsPageRouteSource).not.toContain('useNavigate');
expect(operationsPageSurfaceSource).toContain('@/components/shared/Subtabs');
expect(operationsPageSurfaceSource).toContain('getOperationsTabFromPath');
expect(operationsPageSurfaceSource).toContain('buildOperationsPath');
expect(operationsPageSurfaceSource).toContain('<DiagnosticsPanel />');
expect(operationsPageSurfaceSource).toContain('<ReportingPanel />');
expect(operationsPageSurfaceSource).toContain('<SystemLogsPanel />');
expect(operationsPageSurfaceSource).not.toContain('-webkit-overflow-scrolling');
expect(operationsPageModelSource).toContain('export const OPERATIONS_TABS');
expect(operationsPageModelSource).toContain('export function getOperationsTabFromPath');
expect(operationsPageModelSource).toContain('export function buildOperationsPath');
expect(reportingPanelSource).toContain('OperationsPanel');
expect(systemLogsPanelSource).toContain('OperationsPanel');
expect(patrolIntelligenceSurfaceSource).toContain('getPatrolSummaryPresentation');
expect(patrolIntelligenceSurfaceSource).toContain('getAIQuickstartCreditsPresentation');
expect(patrolIntelligenceSurfaceSource).not.toContain(