mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-10 03:51:54 +00:00
Extract operations route surface owners
This commit is contained in:
parent
e2275133a5
commit
68bae84bcf
10 changed files with 208 additions and 105 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'));",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue