diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index c3c283260..29d310937 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 06af67682..fd20b0bed 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index a40929177..8bc2d3c8d 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -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", diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index f17f46670..6336097d2 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -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() { - + ); diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index 120344214..fbc756b17 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -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'));", ); diff --git a/frontend-modern/src/features/operations/OperationsPageSurface.tsx b/frontend-modern/src/features/operations/OperationsPageSurface.tsx new file mode 100644 index 000000000..524e80337 --- /dev/null +++ b/frontend-modern/src/features/operations/OperationsPageSurface.tsx @@ -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> = { + diagnostics: ActivityIcon, + reporting: FileTextIcon, + logs: TerminalIcon, +}; + +export function OperationsPageSurface() { + const location = useLocation(); + const navigate = useNavigate(); + + const activeTab = createMemo(() => getOperationsTabFromPath(location.pathname)); + + const tabs = createMemo(() => + OPERATIONS_TABS.map((tab) => { + const Icon = operationsTabIcons[tab.id]; + return { + value: tab.id, + label: ( + + + {tab.label} + + ) satisfies JSX.Element, + }; + }), + ); + + const handleTabChange = (tabId: string) => { + navigate(buildOperationsPath(tabId as OperationsTabId)); + }; + + return ( + + + + + + + + + + } + > + {activeTab() === 'diagnostics' && } + {activeTab() === 'reporting' && } + {activeTab() === 'logs' && } + + + + ); +} + +export default OperationsPageSurface; diff --git a/frontend-modern/src/features/operations/operationsPageModel.ts b/frontend-modern/src/features/operations/operationsPageModel.ts new file mode 100644 index 000000000..c8c94a450 --- /dev/null +++ b/frontend-modern/src/features/operations/operationsPageModel.ts @@ -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}`; +} diff --git a/frontend-modern/src/pages/Operations.tsx b/frontend-modern/src/pages/Operations.tsx index 6a8b4e1d9..ac3f07adf 100644 --- a/frontend-modern/src/pages/Operations.tsx +++ b/frontend-modern/src/pages/Operations.tsx @@ -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(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 ( - - {/* Modern Tabs Navigation */} - - - {tabs.map((tab) => { - const isActive = () => activeTab() === tab.id; - return ( - 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.label} - - ); - })} - - - - {/* View Content */} - - - - - } - > - {activeTab() === 'diagnostics' && } - {activeTab() === 'reporting' && } - {activeTab() === 'logs' && } - - - - ); + return ; }; export default Operations; diff --git a/frontend-modern/src/pages/__tests__/Operations.helpers.test.ts b/frontend-modern/src/pages/__tests__/Operations.helpers.test.ts new file mode 100644 index 000000000..b7f974a2e --- /dev/null +++ b/frontend-modern/src/pages/__tests__/Operations.helpers.test.ts @@ -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(''); + 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'); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index f3d4668ca..3d754bd26 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -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(''); + 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(''); + expect(operationsPageSurfaceSource).toContain(''); + expect(operationsPageSurfaceSource).toContain(''); + 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(