diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 753476cbd..eb116a91b 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -3844,7 +3844,7 @@ }, { "repo": "pulse", - "path": "frontend-modern/src/pages/Infrastructure.tsx", + "path": "frontend-modern/src/pages/Proxmox.tsx", "kind": "file" }, { diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 158ab3adc..4ab7b126e 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -615,6 +615,18 @@ runtime cost control, and shared AI transport surfaces. ## Current State +Primary nav moved to platform-first on 2026-05-16 through +`frontend-modern/src/App.tsx` and `frontend-modern/src/AppLayout.tsx`: the top +of the app exposes the five canonical platform pages (Proxmox, Docker, +Kubernetes, TrueNAS, vSphere) plus Alerts, Patrol, and Settings. The legacy +`/infrastructure`, `/workloads`, `/storage`, `/recovery`, and `/ceph` route +shells were retired alongside their page wrappers. The AI Chat launcher, the +Patrol surfaces, and the `AssistantHandoffPayload` deep links must use +platform routes (`/proxmox/overview`, `/proxmox/storage`, `/kubernetes/pods`, +etc.) as canonical anchors rather than the retired top-level paths; adding a +platform tab through the same shell files must not fork Assistant or Patrol +shell state or smuggle in AI-owned platform reads. + The route-backed Proxmox platform tab is app-shell navigation only. Adding the tab through `frontend-modern/src/App.tsx` and `frontend-modern/src/AppLayout.tsx` must not fork Assistant or Patrol shell diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 1f5eebb82..65d0e275c 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -642,6 +642,18 @@ or other self-hosted uncapped continuity plans. ## Current State +Primary nav moved to platform-first on 2026-05-16 through +`frontend-modern/src/App.tsx`, `frontend-modern/src/AppLayout.tsx`, and +`frontend-modern/src/pages/RuntimeHome.tsx`. The authenticated-runtime landing +redirect now resolves to `/proxmox/overview` via `buildProxmoxPath()` rather +than the retired `/infrastructure` shell; kiosk-mode blocked-prefix redirects +follow the same canonical home. The hosted bootstrap / activation / recovery +routes (`/pricing`, `/preview/setup-complete`, `/login`, settings panels +under `/settings/...`) are unchanged. Pricing-handoff and hosted-signup +upgrade paths must use platform routes as their canonical "back to product" +destination instead of the retired top-level Infrastructure / Workloads / +Storage / Recovery pages. + The Proxmox platform tab in the authenticated app shell is ordinary product navigation. It must stay separate from hosted acquisition, monitored-system pressure, billing prompts, and Pulse Account handoffs: self-hosted operators diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 2677a2c64..ee9f0b7f4 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -1354,6 +1354,24 @@ AI runtime. ## Current State +Command palette and keyboard shortcuts moved to platform-first on 2026-05-16 +(`frontend-modern/src/components/shared/commandPaletteModel.ts`, +`frontend-modern/src/components/shared/useCommandPaletteState.ts`, +`frontend-modern/src/components/shared/KeyboardShortcutsModal.tsx`, +`frontend-modern/src/hooks/useKeyboardShortcuts.ts`, +`frontend-modern/src/routing/routePreload.ts`, +`frontend-modern/src/routing/navigation.ts`). The legacy `nav-infrastructure`, +`nav-workloads`, `nav-storage`, and `nav-recovery` palette entries — together +with the `g i` / `g w` / `g s` / `g b` chord bindings — were retired and +replaced with `nav-proxmox`, `nav-docker`, `nav-kubernetes`, `nav-truenas`, +`nav-vmware` (chords `g p` / `g d` / `g k` / `g n` / `g v`) plus a dedicated +`nav-kubernetes-pods` entry that lands on `/kubernetes/pods`. The shell +preload set and `getActiveTabForPath` matcher no longer recognize the legacy +top-level routes. New palette commands and shortcut chords must therefore +anchor on canonical platform routes; do not reintroduce a top-level +Infrastructure / Workloads / Storage / Recovery entry by reanimating the +legacy paths. + The shared table chrome now allows `TableCardHeader` to expose a right-aligned action slot, currently used by the Workloads/Proxmox metric display control. That slot belongs to the table header band and must not reintroduce nested diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index f09443afa..af50f32ac 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -127,7 +127,7 @@ regression protection. 102. `frontend-modern/src/useAppRuntimeState.ts` 103. `frontend-modern/src/components/Storage/StorageSummary.tsx` 104. `frontend-modern/src/utils/storageSummaryCache.ts` -105. `frontend-modern/src/pages/Workloads.tsx` +105. `frontend-modern/src/components/Workloads/WorkloadsSurface.tsx` ## Shared Boundaries diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index dbb97f7cc..734248d1c 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -2968,12 +2968,12 @@ "scripts/dev-deploy-agent.sh", "scripts/dev-launchd-setup.sh", "scripts/dev-launchd-wrapper.sh", - "scripts/hot-dev-bg.sh", - "scripts/hot-dev.sh", - "scripts/lib/hot-dev-auth.sh", - "scripts/lib/hot-dev-runtime.sh", - "scripts/toggle-mock.sh", - "tests/integration/playwright.config.ts", + "scripts/hot-dev-bg.sh", + "scripts/hot-dev.sh", + "scripts/lib/hot-dev-auth.sh", + "scripts/lib/hot-dev-runtime.sh", + "scripts/toggle-mock.sh", + "tests/integration/playwright.config.ts", "tests/integration/QUICK_START.md", "tests/integration/README.md", "tests/integration/scripts/managed-dev-runtime.mjs", @@ -2983,11 +2983,11 @@ "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ - "scripts/release_control/ssh_host_key_policy_test.py", - "scripts/tests/test-hot-dev-auth.sh", - "scripts/tests/test-hot-dev-bg.sh", - "scripts/tests/test-hot-dev-runtime.sh", - "scripts/tests/test-toggle-mock.sh", + "scripts/release_control/ssh_host_key_policy_test.py", + "scripts/tests/test-hot-dev-auth.sh", + "scripts/tests/test-hot-dev-bg.sh", + "scripts/tests/test-hot-dev-runtime.sh", + "scripts/tests/test-toggle-mock.sh", "tests/integration/tests/16-dev-runtime-recovery.spec.ts" ] }, @@ -3286,8 +3286,6 @@ "frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts", "frontend-modern/src/components/Settings/__tests__/SystemLogsPanel.test.tsx", "frontend-modern/src/pages/__tests__/Operations.helpers.test.ts", - "frontend-modern/src/pages/__tests__/Recovery.test.tsx", - "frontend-modern/src/pages/__tests__/Workloads.helpers.test.ts", "frontend-modern/src/routing/__tests__/routePreload.test.ts", "frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts", "frontend-modern/src/utils/__tests__/reportableResourceTypes.test.ts", @@ -4309,7 +4307,6 @@ "frontend-modern/src/components/Workloads/workloadTopology.ts", "frontend-modern/src/components/Workloads/workloadUrlSyncModel.ts", "frontend-modern/src/hooks/useWorkloads.ts", - "frontend-modern/src/pages/Workloads.tsx", "frontend-modern/src/routing/routePreload.ts", "frontend-modern/src/useAppRuntimeState.ts", "frontend-modern/src/utils/thresholdSliderPresentation.ts", @@ -4458,7 +4455,6 @@ "frontend-modern/src/components/Workloads/workloadTopology.ts", "frontend-modern/src/components/Workloads/workloadUrlSyncModel.ts", "frontend-modern/src/hooks/useWorkloads.ts", - "frontend-modern/src/pages/Workloads.tsx", "frontend-modern/src/utils/thresholdSliderPresentation.ts", "frontend-modern/src/utils/workloadsSummaryCache.ts" ], @@ -4495,7 +4491,6 @@ "frontend-modern/src/components/Workloads/ThresholdSlider.test.tsx", "frontend-modern/src/components/Workloads/WorkloadsSummary.test.tsx", "frontend-modern/src/hooks/__tests__/useWorkloads.test.ts", - "frontend-modern/src/pages/__tests__/Workloads.helpers.test.ts", "frontend-modern/src/utils/__tests__/thresholdSliderPresentation.test.ts", "frontend-modern/src/utils/__tests__/workloadsSummaryCache.test.ts" ] @@ -4851,9 +4846,6 @@ "frontend-modern/src/hooks/useRecoveryPointsFacets.ts", "frontend-modern/src/hooks/useRecoveryPointsSeries.ts", "frontend-modern/src/hooks/useRecoveryRollups.ts", - "frontend-modern/src/pages/Ceph.tsx", - "frontend-modern/src/pages/Recovery.tsx", - "frontend-modern/src/pages/Storage.tsx", "frontend-modern/src/types/recovery.ts", "frontend-modern/src/utils/recoveryActionPresentation.ts", "frontend-modern/src/utils/recoveryArtifactModePresentation.ts", @@ -4875,7 +4867,6 @@ "exact_files": [ "frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx", "frontend-modern/src/components/Storage/code_standards.test.ts", - "frontend-modern/src/pages/__tests__/Recovery.test.tsx", "internal/recovery/recovery_test.go" ], "require_explicit_path_policy_coverage": true, @@ -4911,7 +4902,6 @@ "frontend-modern/src/hooks/useRecoveryPointsFacets.ts", "frontend-modern/src/hooks/useRecoveryPointsSeries.ts", "frontend-modern/src/hooks/useRecoveryRollups.ts", - "frontend-modern/src/pages/Recovery.tsx", "frontend-modern/src/types/recovery.ts", "frontend-modern/src/utils/recoveryActionPresentation.ts", "frontend-modern/src/utils/recoveryArtifactModePresentation.ts", @@ -4931,7 +4921,6 @@ "test_prefixes": [], "exact_files": [ "frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx", - "frontend-modern/src/pages/__tests__/Recovery.test.tsx", "frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts", "tests/integration/tests/17-recovery-layout.spec.ts" ] @@ -4943,10 +4932,7 @@ "frontend-modern/src/components/Storage/", "frontend-modern/src/features/storageBackups/" ], - "match_files": [ - "frontend-modern/src/pages/Ceph.tsx", - "frontend-modern/src/pages/Storage.tsx" - ], + "match_files": [], "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ @@ -4964,7 +4950,6 @@ "frontend-modern/src/features/storageBackups/__tests__/storageModelCore.test.ts", "frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts", "frontend-modern/src/features/storageBackups/__tests__/storagePoolsTablePresentation.test.ts", - "frontend-modern/src/pages/__tests__/Storage.helpers.test.ts", "frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts" ] } @@ -5025,7 +5010,6 @@ "frontend-modern/src/features/infrastructure/useInfrastructurePageRouteState.ts", "frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts", "frontend-modern/src/hooks/useUnifiedResources.ts", - "frontend-modern/src/pages/Infrastructure.tsx", "frontend-modern/src/routing/resourceLinks.ts", "frontend-modern/src/types/resource.ts", "frontend-modern/src/utils/actionAuditPresentation.ts", @@ -5149,7 +5133,6 @@ "frontend-modern/src/features/infrastructure/useInfrastructurePageRouteState.ts", "frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts", "frontend-modern/src/hooks/useUnifiedResources.ts", - "frontend-modern/src/pages/Infrastructure.tsx", "frontend-modern/src/routing/resourceLinks.ts", "frontend-modern/src/types/resource.ts", "frontend-modern/src/utils/resourceIdentity.ts", @@ -5175,8 +5158,6 @@ "frontend-modern/src/features/infrastructure/__tests__/infrastructurePageModel.test.ts", "frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts", "frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts", - "frontend-modern/src/pages/__tests__/Infrastructure.empty-state.test.tsx", - "frontend-modern/src/pages/__tests__/Infrastructure.pbs-pmg.test.tsx", "frontend-modern/src/routing/__tests__/resourceLinks.test.ts", "frontend-modern/src/stores/__tests__/websocket-unified.test.ts", "frontend-modern/src/types/__tests__/resource.test.ts", diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index dfeeade5b..abcfa6696 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -39,28 +39,25 @@ state. 10. `frontend-modern/src/components/Recovery/RecoveryHistoryItemFilter.tsx` 11. `frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx` 12. `frontend-modern/src/components/Recovery/useRecoveryHistorySectionState.ts` -13. `frontend-modern/src/pages/Storage.tsx` -14. `frontend-modern/src/pages/Ceph.tsx` -15. `frontend-modern/src/components/Storage/Storage.tsx` -16. `frontend-modern/src/features/storageBackups/storageModelCore.ts` -17. `frontend-modern/src/utils/storageSources.ts` -18. `frontend-modern/src/hooks/useRecoveryPoints.ts` -19. `frontend-modern/src/hooks/useRecoveryRollups.ts` -20. `frontend-modern/src/hooks/useRecoveryPointsFacets.ts` -21. `frontend-modern/src/hooks/useRecoveryPointsSeries.ts` -22. `frontend-modern/src/pages/Recovery.tsx` -23. `frontend-modern/src/routing/resourceLinks.ts` -24. `frontend-modern/src/types/recovery.ts` -25. `frontend-modern/src/utils/recoveryDatePresentation.ts` -26. `frontend-modern/src/utils/recoveryEmptyStatePresentation.ts` -27. `frontend-modern/src/utils/recoveryTablePresentation.ts` -28. `frontend-modern/src/utils/recoveryTimelinePresentation.ts` -29. `frontend-modern/src/utils/recoveryItemTypePresentation.ts` -30. `frontend-modern/src/utils/textPresentation.ts` -31. `frontend-modern/src/components/Storage/StorageSummary.tsx` -32. `frontend-modern/src/utils/storageSummaryCache.ts` -33. `frontend-modern/src/components/Storage/useStorageSummaryCharts.ts` -34. `frontend-modern/src/features/storageBackups/storageCapacityDeltaPresentation.ts` +13. `frontend-modern/src/components/Storage/Storage.tsx` +14. `frontend-modern/src/features/storageBackups/storageModelCore.ts` +15. `frontend-modern/src/utils/storageSources.ts` +16. `frontend-modern/src/hooks/useRecoveryPoints.ts` +17. `frontend-modern/src/hooks/useRecoveryRollups.ts` +18. `frontend-modern/src/hooks/useRecoveryPointsFacets.ts` +19. `frontend-modern/src/hooks/useRecoveryPointsSeries.ts` +20. `frontend-modern/src/routing/resourceLinks.ts` +21. `frontend-modern/src/types/recovery.ts` +22. `frontend-modern/src/utils/recoveryDatePresentation.ts` +23. `frontend-modern/src/utils/recoveryEmptyStatePresentation.ts` +24. `frontend-modern/src/utils/recoveryTablePresentation.ts` +25. `frontend-modern/src/utils/recoveryTimelinePresentation.ts` +26. `frontend-modern/src/utils/recoveryItemTypePresentation.ts` +27. `frontend-modern/src/utils/textPresentation.ts` +28. `frontend-modern/src/components/Storage/StorageSummary.tsx` +29. `frontend-modern/src/utils/storageSummaryCache.ts` +30. `frontend-modern/src/components/Storage/useStorageSummaryCharts.ts` +31. `frontend-modern/src/features/storageBackups/storageCapacityDeltaPresentation.ts` ## Shared Boundaries @@ -201,7 +198,7 @@ bypass the API fail-closed execution gate. A selected timeline day is an active event-history filter: table totals, footer ranges, empty states, and search/date matching must describe the visible filtered recovery points, not the unfiltered API page metadata. -3. Add or change storage page UX through `frontend-modern/src/pages/Storage.tsx`, `frontend-modern/src/pages/Ceph.tsx`, `frontend-modern/src/components/Storage/`, `frontend-modern/src/features/storageBackups/`, and the shared storage-source contract in `frontend-modern/src/utils/storageSources.ts` +3. Add or change storage UX through `frontend-modern/src/components/Storage/Storage.tsx` (the canonical `StorageSurface`, embedded inside platform pages), `frontend-modern/src/components/Storage/`, `frontend-modern/src/features/storageBackups/`, the shared storage-source contract in `frontend-modern/src/utils/storageSources.ts`, and the Proxmox-native Ceph table at `frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx`. The standalone Storage, Ceph, and Recovery route shells under the legacy frontend-modern pages directory were retired with the platform-first primary nav (2026-05-16); the underlying surfaces stay alive as embedded views inside the matching platform page sub-tabs through `frontend-modern/src/pages/Proxmox.tsx`. The retired dashboard route must not reintroduce storage or recovery widgets as compatibility panels. Storage capacity, storage health, protected-item, and recovery-outcome readiness claims belong on the Storage diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 046a10206..828890192 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -701,6 +701,33 @@ AI-only summary payloads, or page-local heuristics. ## Current State +Cross-resource drilldown affordances were retired on 2026-05-16 alongside the +platform-first primary nav. The default `buildResourceHref` in +`frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx` +and `frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx` +now returns `null` instead of building a `/infrastructure?resource=...` link, +so correlation, change, dependency, dependent, and related-resource labels +render as plain text inside resource drawers +(`frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx`) +unless the caller supplies a platform-aware override. The host-row workloads +icon +(`frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx`) +and the PBS "Open Recovery Events" link +(`frontend-modern/src/components/Infrastructure/serviceDetailLinks.ts`) were +removed because they targeted the retired `/workloads` and `/recovery` route +shells; the workloads-href helper module +(`frontend-modern/src/components/Infrastructure/workloadsLink.ts`) was deleted +in the same pass. The K8s Namespaces and Deployments drawer "Open Pods" / +"View Pods" buttons +(`frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx`, +`frontend-modern/src/components/Kubernetes/K8sDeploymentsDrawer.tsx`) now +route to `/kubernetes/pods` rather than building +`/workloads?type=pod&context=...&namespace=...` URLs; the legacy +context/namespace query filter does not carry forward because the new +Kubernetes Pods sub-tab does not consume those parameters. New drawer or +correlation surfaces must anchor on platform routes (or stay-in-place drawer +expansion) instead of resurrecting the retired top-level paths. + The Proxmox platform page is a route-level consumer of canonical unified resources, not a new resource source. It filters the existing resource snapshot to Proxmox VE, Backup Server, Mail Gateway, storage, disk, and workload rows, diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 32463594d..3a84c21cb 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -33,10 +33,6 @@ import { PROXMOX_PATH, TRUENAS_PATH, VMWARE_PATH, - buildRecoveryPath, - buildInfrastructurePath, - buildStoragePath, - buildWorkloadsPath, } from './routing/resourceLinks'; import { APP_SHELL_ROUTE_PRELOAD_PATHS, preloadRouteModule } from '@/routing/routePreload'; import { AppLayout } from '@/AppLayout'; @@ -54,20 +50,15 @@ function isPublicRoutePath(pathname: string): boolean { return pathname === '/pricing' || pathname === '/preview/setup-complete'; } -const StoragePage = lazy(() => import('./pages/Storage')); -const RecoveryPage = lazy(() => import('./pages/Recovery')); -const CephPage = lazy(() => import('./pages/Ceph')); const AlertsPage = lazy(() => import('./pages/Alerts').then((module) => ({ default: module.Alerts })), ); const SettingsPage = lazy(() => import('./components/Settings/Settings')); -const InfrastructurePage = lazy(() => import('./pages/Infrastructure')); const ProxmoxPage = lazy(() => import('./pages/Proxmox')); const DockerPage = lazy(() => import('./pages/Docker')); const KubernetesPage = lazy(() => import('./pages/Kubernetes')); const TrueNASPage = lazy(() => import('./pages/TrueNAS')); const VmwarePage = lazy(() => import('./pages/Vmware')); -const WorkloadsPage = lazy(() => import('./pages/Workloads')); const AIIntelligencePage = lazy(() => import('./pages/AIIntelligence').then((module) => ({ default: module.AIIntelligence })), ); @@ -79,10 +70,6 @@ const SetupCompletionPreviewPage = lazy(() => default: module.SetupCompletionPreview, })), ); -const INFRASTRUCTURE_ROUTE_PATH = buildInfrastructurePath(); -const ROOT_WORKLOADS_PATH = buildWorkloadsPath(); -const STORAGE_PATH = buildStoragePath(); -const RECOVERY_ROUTE_PATH = buildRecoveryPath(); const ROOT_PATROL_PATH = PATROL_PATH; async function preloadAppShellRoutes() { @@ -481,11 +468,6 @@ function App() { - - - - - diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index 7db1fe194..d3cf847c7 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -31,7 +31,6 @@ import { getActiveTabForPath } from '@/routing/navigation'; import { preloadRouteModule } from '@/routing/routePreload'; import { buildDockerPath, - buildInfrastructurePath, buildKubernetesPath, buildProxmoxPath, buildTrueNASPath, @@ -45,7 +44,6 @@ import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentat import { AI_CHAT_LAUNCHER_ARIA_LABEL, getAIChatLauncherTitle } from '@/utils/aiChatPresentation'; import type { AppConnectionStatus } from '@/useAppRuntimeState'; -const ROOT_INFRASTRUCTURE_PATH = buildInfrastructurePath(); const ROOT_PROXMOX_PATH = buildProxmoxPath(); const ROOT_DOCKER_PATH = buildDockerPath(); const ROOT_KUBERNETES_PATH = buildKubernetesPath(); @@ -204,10 +202,6 @@ export function AppLayout(props: AppLayoutProps) { kubernetes: 'Kubernetes', truenas: 'TrueNAS', vmware: 'vSphere', - infrastructure: 'Infrastructure', - workloads: 'Workloads', - storage: 'Storage', - recovery: 'Recovery', alerts: 'Alerts', ai: 'Patrol', settings: 'Settings', @@ -260,8 +254,8 @@ export function AppLayout(props: AppLayoutProps) { !normalizedPath.startsWith('/alerts/overview/') && !normalizedPath.startsWith('/alerts/history/'); - if ((isBlocked || isAlertConfigTab) && normalizedPath !== ROOT_INFRASTRUCTURE_PATH) { - navigate(ROOT_INFRASTRUCTURE_PATH, { replace: true }); + if ((isBlocked || isAlertConfigTab) && normalizedPath !== ROOT_PROXMOX_PATH) { + navigate(ROOT_PROXMOX_PATH, { replace: true }); } }); diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index c94d35a1f..14bc95bcc 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -28,7 +28,6 @@ describe('App architecture', () => { expect(appSource).toContain('setAppScrollShellRef'); expect(appSource).toContain('readPendingAppShellRestoreTop'); expect(appSource).toContain('clearPendingAppShellRestoreTop'); - expect(appSource).toContain('const INFRASTRUCTURE_ROUTE_PATH = buildInfrastructurePath();'); expect(appSource).toContain("const ProxmoxPage = lazy(() => import('./pages/Proxmox'));"); expect(appSource).toContain('const ROOT_PATROL_PATH = PATROL_PATH;'); expect(appSource).toContain( @@ -36,18 +35,24 @@ describe('App architecture', () => { ); expect(routePreloadSource).toContain('export const APP_SHELL_ROUTE_PRELOAD_PATHS = ['); expect(routePreloadSource).toContain('ROOT_PROXMOX_PATH,'); - expect(routePreloadSource).toContain('ROOT_WORKLOADS_PATH,'); - expect(routePreloadSource).toContain('RECOVERY_ROUTE_PATH,'); expect(routePreloadSource).toContain('PATROL_PATH,'); + // Legacy top-level routes (Infrastructure/Workloads/Storage/Recovery/Ceph) + // were retired when primary nav moved to platform-first. Their preloaders + // and route entries must not return. + expect(routePreloadSource).not.toContain('ROOT_INFRASTRUCTURE_PATH'); + expect(routePreloadSource).not.toContain('ROOT_WORKLOADS_PATH'); + expect(routePreloadSource).not.toContain('RECOVERY_ROUTE_PATH'); + expect(routePreloadSource).not.toContain('STORAGE_PATH'); + expect(appSource).not.toContain('INFRASTRUCTURE_ROUTE_PATH'); + expect(appSource).not.toContain('ROOT_WORKLOADS_PATH'); + expect(appSource).not.toContain('RECOVERY_ROUTE_PATH'); + expect(appSource).not.toContain('STORAGE_PATH'); expect(appSource).toContain('await preloadRouteModule(route);'); expect(appRuntimeStateSource).not.toContain('preloadLazyRoutes'); expect(appRuntimeStateSource).not.toContain("import('@/pages/Alerts')"); expect(appRuntimeStateSource).not.toContain("import('@/components/Settings/Settings')"); expect(appSource).toContain('const timeoutId = window.setTimeout(() => {'); expect(appSource).toContain('void preloadAppShellRoutes();'); - expect(appSource).toContain( - '', - ); expect(appSource).toContain(''); expect(appSource).toContain(''); expect(appSource).toContain("const DockerPage = lazy(() => import('./pages/Docker'));"); @@ -92,13 +97,15 @@ describe('App architecture', () => { ); expect(appSource).not.toContain(' import('./pages/Proxmox'));"); expect(appSource).toContain("const OperationsPage = lazy(() => import('./pages/Operations'));"); - expect(appSource).toContain("const WorkloadsPage = lazy(() => import('./pages/Workloads'));"); - expect(appSource).toContain("const RecoveryPage = lazy(() => import('./pages/Recovery'));"); - expect(appSource).toContain(''); - expect(appSource).toContain(''); + // Legacy page wrappers were deleted when primary nav moved to + // platform-first; their tables are reused inside platform pages directly. + expect(appSource).not.toContain("import('./pages/Storage')"); + expect(appSource).not.toContain("import('./pages/Workloads')"); + expect(appSource).not.toContain("import('./pages/Recovery')"); + expect(appSource).not.toContain("import('./pages/Infrastructure')"); + expect(appSource).not.toContain("import('./pages/Ceph')"); expect(appSource).not.toContain( "const StorageComponent = lazy(() => import('./components/Storage/Storage'));", ); @@ -300,15 +307,16 @@ describe('App architecture', () => { expect(routePreloadSource).toContain('const ROUTE_PRELOADERS: readonly RoutePreloader[] = ['); expect(routePreloadSource).toContain('export const APP_SHELL_ROUTE_PRELOAD_PATHS = ['); expect(routePreloadSource).toContain("id: 'proxmox',"); - expect(routePreloadSource).toContain("id: 'recovery',"); expect(routePreloadSource).toContain("id: 'patrol',"); expect(routePreloadSource).toContain( 'const routePreloadCache = new Map>();', ); - expect(routePreloadSource).toContain("import('@/pages/Infrastructure')"); expect(routePreloadSource).toContain("import('@/pages/Proxmox')"); - expect(routePreloadSource).toContain("import('@/pages/Workloads')"); - expect(routePreloadSource).toContain("import('@/pages/Recovery')"); + expect(routePreloadSource).not.toContain("import('@/pages/Infrastructure')"); + expect(routePreloadSource).not.toContain("import('@/pages/Workloads')"); + expect(routePreloadSource).not.toContain("import('@/pages/Recovery')"); + expect(routePreloadSource).not.toContain("import('@/pages/Storage')"); + expect(routePreloadSource).not.toContain("import('@/pages/Ceph')"); expect(routePreloadSource).not.toContain("import('@/components/Workloads/WorkloadsSurface')"); expect(routePreloadSource).not.toContain("import('@/pages/RecoveryRoute')"); expect(appRuntimeContextSource).toContain( diff --git a/frontend-modern/src/components/Ceph/CephServiceIcon.tsx b/frontend-modern/src/components/Ceph/CephServiceIcon.tsx deleted file mode 100644 index 7bf111698..000000000 --- a/frontend-modern/src/components/Ceph/CephServiceIcon.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import type { Component } from 'solid-js'; - -export const CephServiceIcon: Component<{ type: string; class?: string }> = (props) => { - const iconClass = () => `${props.class || 'w-4 h-4'}`; - - switch (props.type.toLowerCase()) { - case 'mon': - return ( - - - - - ); - case 'mgr': - return ( - - - - - ); - case 'osd': - return ( - - - - ); - case 'mds': - return ( - - - - ); - case 'rgw': - return ( - - - - ); - default: - return ( - - - - ); - } -}; - -export default CephServiceIcon; diff --git a/frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx b/frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx index 8698d239c..39710a0f3 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx @@ -1,7 +1,6 @@ import { type Component, For, Show, createMemo } from 'solid-js'; import type { ResourceChange } from '@/types/resource'; import { formatRelativeTime } from '@/utils/format'; -import { buildInfrastructureResourceHref } from '@/routing/resourceLinks'; import { getResourceChangeKindPresentation, getResourceChangeSourceAdapterPresentation, @@ -28,7 +27,7 @@ export const ResourceChangeSummary: Component = (pro const visibleChanges = createMemo(() => sortedChanges().slice(0, maxChanges())); const hasChanges = () => visibleChanges().length > 0; const compact = () => props.compact ?? false; - const buildResourceHref = props.buildResourceHref ?? buildInfrastructureResourceHref; + const buildResourceHref = props.buildResourceHref ?? (() => null); const resolveResourceLabel = props.resolveResourceLabel; const itemPadding = () => (compact() ? 'p-2.5' : 'p-3'); const itemText = () => (compact() ? 'text-[11px]' : 'text-xs'); diff --git a/frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx b/frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx index a21251634..ba95c18c8 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx @@ -2,7 +2,6 @@ import { For, Show, createMemo, type Component } from 'solid-js'; import type { ResourceCorrelation } from '@/types/aiIntelligence'; import type { ResourceRelationship } from '@/types/resource'; import { formatRelativeTime } from '@/utils/format'; -import { buildInfrastructureResourceHref } from '@/routing/resourceLinks'; import { formatResourceCorrelationEndpoint, formatResourceCorrelationHeadline, @@ -36,7 +35,7 @@ export const ResourceCorrelationSummary: Component sortResourceRelationships(props.relationships ?? [])); const dependencies = () => props.dependencies ?? []; const dependents = () => props.dependents ?? []; - const buildResourceHref = props.buildResourceHref ?? buildInfrastructureResourceHref; + const buildResourceHref = props.buildResourceHref ?? (() => null); const resolveResourceLabel = props.resolveResourceLabel; const maxCorrelations = () => props.maxCorrelations ?? 3; const hasContent = () => diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx index 42e36e95c..ed109f43d 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx @@ -41,7 +41,6 @@ import { import { formatConfidenceLabel } from '@/utils/confidencePresentation'; import { formatIdentifierLabel } from '@/utils/textPresentation'; import { shouldShowResourcePlatformId } from '@/utils/resourceIdentity'; -import { buildInfrastructureResourceHref } from '@/routing/resourceLinks'; import { getDiscoveryLoadingState } from '@/utils/discoveryPresentation'; import { RESOURCE_ANALYSIS_LABEL, @@ -541,16 +540,7 @@ export const ResourceDetailDrawerOverviewTab: Component {(relatedResource) => { const label = drawer.resolveResourceLabel(relatedResource); - const href = buildInfrastructureResourceHref(relatedResource); - return href ? ( - - {label} - - ) : ( + return ( {label} diff --git a/frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx b/frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx index 596e9092c..d109c2af9 100644 --- a/frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx +++ b/frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx @@ -41,7 +41,6 @@ import { import { getAvailabilityProbePresentation } from '@/utils/availabilityProbePresentation'; import { ResourceDetailDrawer } from './ResourceDetailDrawer'; import { getResourceHealthIssuePresentation } from './resourceHealthPresentation'; -import { buildWorkloadsHref } from './workloadsLink'; import { ClusterDeployBanner } from './ClusterDeployBanner'; import { ResourceFacetSummary } from './ResourceFacetSummary'; import { UnifiedResourceSourceBadgeCell } from './UnifiedResourceSourceBadgeCell'; @@ -330,7 +329,6 @@ export const UnifiedResourceHostTableCard: Component getAvailabilityProbePresentation(resource), ); - const workloadsHref = createMemo(() => buildWorkloadsHref(resource)); const resourceRowInteraction = createSummaryInteractiveRowPreviewHandlers({ onPreview: () => tableProps.onHoverChange?.(resource.id), onPreviewClear: () => tableProps.onHoverChange?.(null), @@ -418,36 +416,6 @@ export const UnifiedResourceHostTableCard: Component - - {(href) => ( - event.stopPropagation()} - > - - - - - - )} - diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceChangeSummary.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceChangeSummary.test.tsx index 7ad3b28fa..fc51f4601 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceChangeSummary.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceChangeSummary.test.tsx @@ -49,16 +49,15 @@ describe('ResourceChangeSummary', () => { expect(screen.getByText('Restart: running → restarting')).toBeInTheDocument(); expect(screen.getByText('Platform event')).toBeInTheDocument(); expect(screen.getByText('Docker adapter')).toBeInTheDocument(); - expect( - screen.getByRole('link', { name: 'Open resource storage-2 in Infrastructure' }), - ).toHaveAttribute('href', '/infrastructure?resource=storage-2'); - expect( - screen.getByRole('link', { name: 'Open related resource VM 200 in Infrastructure' }), - ).toHaveAttribute('href', '/infrastructure?resource=vm-200'); + // Cross-jump links to /infrastructure?resource=... were retired with the + // legacy Infrastructure surface; the change feed now renders resource + // labels as plain text by default. + expect(screen.queryAllByRole('link')).toHaveLength(0); + expect(screen.getByText('storage-2')).toBeInTheDocument(); expect(screen.getByText('VM 200')).toBeInTheDocument(); expect(screen.getByText('By agent:ops-helper')).toBeInTheDocument(); expect(screen.getByText('Restart after maintenance')).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'Open resource storage-1 in Infrastructure' })).toBeNull(); + expect(screen.queryByText('storage-1')).toBeNull(); }); it('renders the empty state when no recent changes are available', () => { diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceCorrelationSummary.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceCorrelationSummary.test.tsx index 8ea734fd6..48f2cb131 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceCorrelationSummary.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceCorrelationSummary.test.tsx @@ -30,12 +30,11 @@ describe('ResourceCorrelationSummary', () => { expect(screen.getByText('Learned correlations')).toBeInTheDocument(); expect(screen.getByText('5 total')).toBeInTheDocument(); - expect( - screen.getByRole('link', { name: 'Open source resource Storage 1 in Infrastructure' }), - ).toHaveAttribute('href', '/infrastructure?resource=storage-1'); - expect( - screen.getByRole('link', { name: 'Open target resource Host 1 in Infrastructure' }), - ).toHaveAttribute('href', '/infrastructure?resource=host-1'); + // Cross-jump links to the legacy /infrastructure surface were retired; + // labels render as plain text by default in the platform-first layout. + expect(screen.queryAllByRole('link')).toHaveLength(0); + expect(screen.getByText('Storage 1')).toBeInTheDocument(); + expect(screen.getByText('Host 1')).toBeInTheDocument(); expect(screen.getByText('Disk Full → Restart')).toBeInTheDocument(); expect(screen.getByText(/2 occurrences · avg delay 2m · 88% confidence/)).toBeInTheDocument(); expect(screen.getByText('Disk pressure often precedes restarts')).toBeInTheDocument(); @@ -97,23 +96,14 @@ describe('ResourceCorrelationSummary', () => { expect(screen.getByText('Depends on')).toBeInTheDocument(); expect(screen.getByText('Used by')).toBeInTheDocument(); expect(screen.getByText('Correlations')).toBeInTheDocument(); - expect( - screen.getByRole('link', { name: 'Open source resource PVE 1 in Infrastructure' }), - ).toHaveAttribute('href', '/infrastructure?resource=node%3Apve-1'); - expect( - screen.getByRole('link', { name: 'Open target resource VM Child in Infrastructure' }), - ).toHaveAttribute('href', '/infrastructure?resource=vm-child'); + // Cross-jump links to /infrastructure were retired; labels render as + // plain text by default in the platform-first layout. + expect(screen.queryAllByRole('link')).toHaveLength(0); + expect(screen.getByText('PVE 1')).toBeInTheDocument(); + expect(screen.getAllByText('VM Child').length).toBeGreaterThan(0); + expect(screen.getByText('Storage 1 alias')).toBeInTheDocument(); expect(screen.getByText('Runs On')).toBeInTheDocument(); expect(screen.getByText(/100% confidence · Proxmox Adapter · last seen/)).toBeInTheDocument(); - expect( - screen.getByRole('link', { - name: 'Open dependency resource Storage 1 alias in Infrastructure', - }), - ).toHaveAttribute('href', '/infrastructure?resource=storage-1'); - expect(screen.getByText('Storage 1 alias')).toBeInTheDocument(); - expect( - screen.getByRole('link', { name: 'Open dependent resource VM Child in Infrastructure' }), - ).toHaveAttribute('href', '/infrastructure?resource=vm-child'); expect(screen.getAllByText(/last seen/i).length).toBeGreaterThanOrEqual(2); }); }); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts index 8f9c88a69..1554f558a 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest'; import type { Resource } from '@/types/resource'; import { toDiscoveryConfig } from '@/components/Infrastructure/resourceDetailDiscoveryModel'; -import { buildWorkloadsHref } from '@/components/Infrastructure/workloadsLink'; const baseResource = (): Resource => ({ id: 'host-abcd', @@ -257,120 +256,3 @@ describe('toDiscoveryConfig', () => { }); }); -describe('buildWorkloadsHref', () => { - it('builds k8s workload route for cluster resources with context', () => { - const resource: Resource = { - ...baseResource(), - type: 'k8s-cluster', - name: 'cluster-a', - displayName: 'cluster-a', - clusterId: 'cluster-a', - platformData: { - sources: ['kubernetes'], - kubernetes: { - clusterName: 'cluster-a', - }, - }, - }; - - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=pod&platform=kubernetes&context=cluster-a', - ); - }); - - it('builds k8s workload route for node resources using cluster context', () => { - const resource: Resource = { - ...baseResource(), - type: 'k8s-node', - name: 'worker-1', - displayName: 'worker-1', - clusterId: 'cluster-a', - platformData: { - sources: ['kubernetes'], - kubernetes: { - clusterId: 'cluster-a', - }, - }, - }; - - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=pod&platform=kubernetes&context=cluster-a', - ); - }); - - it('returns null for non-kubernetes infrastructure resources', () => { - const resource: Resource = { - ...baseResource(), - type: 'pbs', - platformType: 'proxmox-pbs', - sourceType: 'api', - platformData: { - sources: ['pbs'], - }, - }; - expect(buildWorkloadsHref(resource)).toBeNull(); - }); - - it('builds workloads route with host hint for proxmox node resources', () => { - const resource: Resource = { - ...baseResource(), - type: 'agent', - platformData: { - sources: ['proxmox'], - proxmox: { nodeName: 'pve1' }, - }, - }; - expect(buildWorkloadsHref(resource)).toBe('/workloads?agent=pve1'); - }); - - it('builds workloads route with docker type and host hint for docker hosts', () => { - const resource: Resource = { - ...baseResource(), - type: 'docker-host', - platformType: 'docker', - sourceType: 'agent', - platformData: { - sources: ['docker'], - docker: { hostname: 'docker-host-1' }, - }, - }; - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=app-container&platform=docker&agent=docker-host-1', - ); - }); - - it('reuses the shared preferred hostname fallback for docker and agent workloads links', () => { - const dockerResource: Resource = { - ...baseResource(), - type: 'docker-host', - platformType: 'docker', - name: '', - displayName: '', - platformId: 'docker-platform-id', - identity: undefined, - platformData: { - sources: ['docker', 'agent'], - agent: { hostname: 'docker-agent-host.local' }, - }, - }; - - expect(buildWorkloadsHref(dockerResource)).toBe( - '/workloads?type=app-container&platform=docker&agent=docker-agent-host.local', - ); - - const agentResource: Resource = { - ...baseResource(), - type: 'agent', - name: '', - displayName: '', - platformId: 'agent-platform-id', - identity: undefined, - platformData: { - sources: ['agent'], - agent: { hostname: 'agent-host.local' }, - }, - }; - - expect(buildWorkloadsHref(agentResource)).toBe('/workloads?agent=agent-host.local'); - }); -}); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx index 8f73263ad..a1646c5a3 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx @@ -526,11 +526,10 @@ describe('ResourceDetailDrawer change history section', () => { const relationshipMap = within(screen.getByTestId('resource-relationship-map-section')); expect(screen.getByText('Relationship map')).toBeInTheDocument(); expect(relationshipMap.getByText('Canonical relationships')).toBeInTheDocument(); - expect( - relationshipMap.getByRole('link', { - name: 'Open source resource PVE Node 1 in Infrastructure', - }), - ).toHaveAttribute('href', '/infrastructure?resource=node%3Apve-1'); + // Legacy /infrastructure?resource=... cross-jumps were retired; the + // relationship map renders resource labels as plain text now. + expect(relationshipMap.queryAllByRole('link')).toHaveLength(0); + expect(relationshipMap.getByText('PVE Node 1')).toBeInTheDocument(); expect(relationshipMap.getByText('Runs On')).toBeInTheDocument(); expect(screen.getByText('Depends on')).toBeInTheDocument(); expect(screen.getByText('Used by')).toBeInTheDocument(); @@ -735,9 +734,9 @@ describe('ResourceDetailDrawer change history section', () => { expect(panel.queryByText('Recent activity')).toBeNull(); expect(panel.queryByText('Filterable event history for this resource.')).toBeNull(); expect(panel.queryByText('Event log')).toBeNull(); - expect( - panel.getByRole('link', { name: 'Open related resource PVE Node 1 in Infrastructure' }), - ).toHaveAttribute('href', '/infrastructure?resource=node%3Apve-1'); + // Cross-jump to /infrastructure?resource=... retired; related resources + // render as plain text now. + expect(panel.queryAllByRole('link')).toHaveLength(0); expect(panel.getByText('PVE Node 1')).toBeInTheDocument(); expect(panel.getByText('Routine restart requested')).toBeInTheDocument(); expect(panel.getByText('Confidence')).toBeInTheDocument(); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx index d4512abac..79d627710 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.identity-runtime.test.tsx @@ -651,7 +651,7 @@ describe('ResourceDetailDrawer runtime and identity cards', () => { it('surfaces canonical analysis context for the resource overview', async () => { const resource = baseResource({}); - const { getByRole, getByTestId, getByText, queryByText } = render(() => ( + const { getByRole, getByTestId, getByText, queryByRole, queryByText } = render(() => ( )); @@ -678,16 +678,18 @@ describe('ResourceDetailDrawer runtime and identity cards', () => { await waitFor(() => { expect(getByText('Storage 1')).toBeInTheDocument(); }); + // Cross-jump to /infrastructure?resource=... retired with the legacy + // surface; dependency / dependent labels render as plain text now. expect( - getByRole('link', { + queryByRole('link', { name: 'Open dependency resource storage-1 in Infrastructure', }), - ).toHaveAttribute('href', expect.stringContaining('/infrastructure?resource=storage-1')); + ).toBeNull(); expect( - getByRole('link', { + queryByRole('link', { name: 'Open dependent resource vm-child in Infrastructure', }), - ).toHaveAttribute('href', expect.stringContaining('/infrastructure?resource=vm-child')); + ).toBeNull(); expect(getByText('Storage 1')).toBeInTheDocument(); expect(getByText('Host 1')).toBeInTheDocument(); expect(getByText('Disk Full → Restart')).toBeInTheDocument(); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.service-cards.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.service-cards.test.tsx index 15cca4dc2..6316d5a57 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.service-cards.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.service-cards.test.tsx @@ -110,10 +110,9 @@ describe('ResourceDetailDrawer service cards', () => { expect(getByText('Datastores')).toBeInTheDocument(); expect(getByText('Jobs')).toBeInTheDocument(); expect(getByText('Types')).toBeInTheDocument(); - expect(getByRole('link', { name: /open recovery events/i })).toHaveAttribute( - 'href', - '/recovery?view=events&platform=proxmox-pbs&mode=remote', - ); + // The legacy "Open Recovery Events" cross-jump was retired with the + // standalone Recovery surface; PBS detail no longer surfaces it. + expect(queryByText(/open recovery events/i)).toBeNull(); }); it('surfaces active PBS tasks before and inside job detail', () => { diff --git a/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.workloads-link.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.workloads-link.test.tsx index e3877cc1d..44f1340b4 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.workloads-link.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.workloads-link.test.tsx @@ -88,8 +88,7 @@ describe('UnifiedResourceTable workloads links', () => { ).toBe(true); }); - it('renders workloads links for supported resource types and prevents row toggle on link click', async () => { - const onExpandedResourceChange = vi.fn(); + it('no longer renders host-row workloads cross-jump links in the platform-first layout', () => { const resources: Resource[] = [ baseResource({ id: 'node-1', @@ -114,41 +113,21 @@ describe('UnifiedResourceTable workloads links', () => { }, }, }), - baseResource({ - id: 'pbs-1', - type: 'pbs', - name: 'pbs-main', - displayName: 'pbs-main', - platformType: 'proxmox-pbs', - sourceType: 'api', - platformData: { - sources: ['pbs'], - }, - }), ]; - const { getAllByRole } = render(() => ( + const { queryAllByRole } = render(() => ( )); - const links = getAllByRole('link', { name: /view workloads/i }); - expect(links).toHaveLength(2); - const hrefs = links - .map((link) => link.getAttribute('href')) - .filter((href): href is string => typeof href === 'string'); - expect(hrefs).toContain('/workloads?agent=pve1'); - expect(hrefs).toContain('/workloads?type=pod&platform=kubernetes&context=cluster-a'); - - const hostLink = links.find((link) => link.getAttribute('href') === '/workloads?agent=pve1'); - expect(hostLink).toBeDefined(); - hostLink!.addEventListener('click', (event) => event.preventDefault()); - await fireEvent.click(hostLink!); - expect(onExpandedResourceChange).not.toHaveBeenCalled(); + // Legacy cross-jump from a host row to /workloads?... was retired with + // the standalone Workloads page; the platform pages expose workloads as + // adjacent sub-tabs instead. + expect(queryAllByRole('link', { name: /view workloads/i })).toHaveLength(0); }); it('renders PBS and PMG resources in a dedicated service table with service-native columns', async () => { @@ -286,17 +265,11 @@ describe('UnifiedResourceTable workloads links', () => { expect(row.queryByText('Relationships 1')).toBeNull(); } - const pbsLink = getByRole('link', { name: /open recovery events/i }); - expect(pbsLink).toHaveTextContent('Recovery'); - expect(pbsLink).toHaveAttribute( - 'href', - '/recovery?view=events&platform=proxmox-pbs&mode=remote', - ); const pmgLink = getByRole('link', { name: /open pmg thresholds/i }); expect(pmgLink).toHaveTextContent('Thresholds'); expect(pmgLink).toHaveAttribute('href', '/alerts/thresholds/mail-gateway'); - pbsLink.addEventListener('click', (event) => event.preventDefault()); - await fireEvent.click(pbsLink); + pmgLink.addEventListener('click', (event) => event.preventDefault()); + await fireEvent.click(pmgLink); expect(onExpandedResourceChange).not.toHaveBeenCalled(); }); }); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/serviceDetailLinks.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/serviceDetailLinks.test.ts index 24b1fd7e9..6d4d2d509 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/serviceDetailLinks.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/serviceDetailLinks.test.ts @@ -18,7 +18,7 @@ const baseResource = (overrides: Partial): Resource => ({ }); describe('buildServiceDetailLinks', () => { - it('returns PBS drill-down link to recovery events filtered for remote PBS activity', () => { + it('returns no service links for PBS now that the standalone Recovery surface is retired', () => { const links = buildServiceDetailLinks( baseResource({ id: 'pbs-main', @@ -29,14 +29,7 @@ describe('buildServiceDetailLinks', () => { }), ); - expect(links).toEqual([ - { - href: '/recovery?view=events&platform=proxmox-pbs&mode=remote', - label: 'Open Recovery Events', - compactLabel: 'Recovery', - ariaLabel: 'Open recovery events for PBS Main', - }, - ]); + expect(links).toEqual([]); }); it('returns PMG drill-down link to mail gateway thresholds', () => { diff --git a/frontend-modern/src/components/Infrastructure/__tests__/workloadsLink.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/workloadsLink.test.ts deleted file mode 100644 index 53581fa9f..000000000 --- a/frontend-modern/src/components/Infrastructure/__tests__/workloadsLink.test.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { Resource } from '@/types/resource'; -import { buildWorkloadsHref } from '@/components/Infrastructure/workloadsLink'; - -const makeResource = (overrides: Partial): Resource => ({ - id: 'test-1', - type: 'agent', - name: 'test-resource', - displayName: 'Test Resource', - platformId: 'plat-1', - platformType: 'proxmox-pve', - sourceType: 'api', - status: 'online', - lastSeen: Date.now(), - ...overrides, -}); - -describe('buildWorkloadsHref', () => { - // ── Kubernetes cluster ────────────────────────────────────────── - - describe('k8s-cluster resources', () => { - it('uses kubernetes.clusterName from platformData as context', () => { - const resource = makeResource({ - type: 'k8s-cluster', - platformType: 'kubernetes', - platformData: { kubernetes: { clusterName: 'prod-cluster' } }, - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=pod&platform=kubernetes&context=prod-cluster', - ); - }); - - it('falls back to kubernetes.context when clusterName is missing', () => { - const resource = makeResource({ - type: 'k8s-cluster', - platformType: 'kubernetes', - platformData: { kubernetes: { context: 'my-context' } }, - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=pod&platform=kubernetes&context=my-context', - ); - }); - - it('falls back to kubernetes.clusterId when clusterName and context are missing', () => { - const resource = makeResource({ - type: 'k8s-cluster', - platformType: 'kubernetes', - platformData: { kubernetes: { clusterId: 'cid-123' } }, - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=pod&platform=kubernetes&context=cid-123', - ); - }); - - it('falls back to resource.clusterId when platformData has no useful fields', () => { - const resource = makeResource({ - type: 'k8s-cluster', - platformType: 'kubernetes', - clusterId: 'resource-cluster', - platformData: { kubernetes: {} }, - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=pod&platform=kubernetes&context=resource-cluster', - ); - }); - - it('falls back to resource.displayName then resource.name for k8s-cluster', () => { - const resource = makeResource({ - type: 'k8s-cluster', - platformType: 'kubernetes', - name: 'fallback-name', - displayName: 'Fallback Display', - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=pod&platform=kubernetes&context=Fallback+Display', - ); - }); - - it('uses the local infrastructure label for redacted k8s-cluster fallback', () => { - const resource = makeResource({ - type: 'k8s-cluster', - platformType: 'kubernetes', - name: 'secret-cluster', - displayName: 'secret-cluster', - policy: { - sensitivity: 'restricted', - routing: { scope: 'local-only', redact: ['hostname'] }, - }, - aiSafeSummary: 'Governed Cluster', - }); - - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=pod&platform=kubernetes&context=secret-cluster', - ); - }); - - it('falls back to resource.name when displayName is empty for k8s-cluster', () => { - const resource = makeResource({ - type: 'k8s-cluster', - platformType: 'kubernetes', - name: 'last-resort', - displayName: '', - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=pod&platform=kubernetes&context=last-resort', - ); - }); - - it('returns path without context when all k8s-cluster fields are empty', () => { - const resource = makeResource({ - id: '', - type: 'k8s-cluster', - platformType: 'kubernetes', - name: '', - displayName: '', - clusterId: undefined, - platformId: '', - }); - expect(buildWorkloadsHref(resource)).toBe('/workloads?type=pod&platform=kubernetes'); - }); - - it('skips whitespace-only values in k8s-cluster resolution', () => { - const resource = makeResource({ - type: 'k8s-cluster', - platformType: 'kubernetes', - platformData: { kubernetes: { clusterName: ' ', context: 'valid-context' } }, - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=pod&platform=kubernetes&context=valid-context', - ); - }); - }); - - // ── Kubernetes node ───────────────────────────────────────────── - - describe('k8s-node resources', () => { - it('uses kubernetes.clusterName from platformData as context', () => { - const resource = makeResource({ - type: 'k8s-node', - platformType: 'kubernetes', - platformData: { kubernetes: { clusterName: 'node-cluster' } }, - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=pod&platform=kubernetes&context=node-cluster', - ); - }); - - it('falls back to kubernetes.context for k8s-node', () => { - const resource = makeResource({ - type: 'k8s-node', - platformType: 'kubernetes', - platformData: { kubernetes: { context: 'node-ctx' } }, - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=pod&platform=kubernetes&context=node-ctx', - ); - }); - - it('falls back to kubernetes.clusterId for k8s-node', () => { - const resource = makeResource({ - type: 'k8s-node', - platformType: 'kubernetes', - platformData: { kubernetes: { clusterId: 'k-cid' } }, - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=pod&platform=kubernetes&context=k-cid', - ); - }); - - it('falls back to resource.clusterId for k8s-node', () => { - const resource = makeResource({ - type: 'k8s-node', - platformType: 'kubernetes', - clusterId: 'res-cluster-id', - platformData: { kubernetes: {} }, - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=pod&platform=kubernetes&context=res-cluster-id', - ); - }); - - it('does NOT fall back to displayName/name for k8s-node (unlike k8s-cluster)', () => { - const resource = makeResource({ - type: 'k8s-node', - platformType: 'kubernetes', - name: 'node-name', - displayName: 'Node Display', - clusterId: undefined, - }); - // k8s-node has no displayName/name fallback — context should be undefined - expect(buildWorkloadsHref(resource)).toBe('/workloads?type=pod&platform=kubernetes'); - }); - }); - - // ── Docker host ───────────────────────────────────────────────── - - describe('docker-host resources', () => { - it('uses docker.hostname from platformData as host', () => { - const resource = makeResource({ - type: 'docker-host', - platformType: 'docker', - platformData: { docker: { hostname: 'docker-box' } }, - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=app-container&platform=docker&agent=docker-box', - ); - }); - - it('falls back to agent.hostname when docker.hostname is missing', () => { - const resource = makeResource({ - type: 'docker-host', - platformType: 'docker', - platformData: { agent: { hostname: 'agent-host' } }, - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=app-container&platform=docker&agent=agent-host', - ); - }); - - it('falls back to identity.hostname', () => { - const resource = makeResource({ - type: 'docker-host', - platformType: 'docker', - identity: { hostname: 'identity-host' }, - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=app-container&platform=docker&agent=identity-host', - ); - }); - - it('falls back through name → displayName → platformId → id', () => { - const resource = makeResource({ - type: 'docker-host', - platformType: 'docker', - name: 'docker-name', - displayName: 'Docker Display', - platformId: 'docker-plat', - id: 'docker-id', - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=app-container&platform=docker&agent=docker-name', - ); - }); - - it('falls back to platformId when name is empty for docker-host', () => { - const resource = makeResource({ - type: 'docker-host', - platformType: 'docker', - name: '', - displayName: 'Docker Display', - platformId: 'plat-id', - id: 'docker-id', - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=app-container&platform=docker&agent=plat-id', - ); - }); - - it('falls back to platformId when name and displayName are empty for docker-host', () => { - const resource = makeResource({ - type: 'docker-host', - platformType: 'docker', - name: '', - displayName: '', - platformId: 'docker-plat-id', - id: 'docker-id', - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=app-container&platform=docker&agent=docker-plat-id', - ); - }); - - it('falls back to resource.id as last resort for docker-host', () => { - const resource = makeResource({ - type: 'docker-host', - platformType: 'docker', - name: '', - displayName: '', - platformId: '', - id: 'last-resort-id', - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=app-container&platform=docker&agent=last-resort-id', - ); - }); - }); - - describe('node resources', () => { - it('resolves Proxmox node using proxmox.nodeName', () => { - const resource = makeResource({ - type: 'agent', - platformData: { proxmox: { nodeName: 'pve-node-3' } }, - }); - expect(buildWorkloadsHref(resource)).toBe('/workloads?agent=pve-node-3'); - }); - - it('routes dual-mode hosts to container workloads when docker facets are present', () => { - const resource = makeResource({ - type: 'agent', - platformData: { - agent: { hostname: 'tower' }, - docker: { hostname: 'tower' }, - }, - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=app-container&platform=docker&agent=tower', - ); - }); - - it('prefers canonical docker runtime ids for hybrid host workload routes', () => { - const resource = makeResource({ - type: 'agent', - metricsTarget: { resourceType: 'docker-host', resourceId: 'docker-host-1' }, - platformData: { - agent: { hostname: 'tower.local' }, - docker: { hostname: 'tower.local' }, - }, - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=app-container&platform=docker&agent=docker-host-1', - ); - }); - - it('falls back through the expected chain for node resources', () => { - const resource = makeResource({ - type: 'agent', - name: 'node-name', - displayName: '', - platformId: '', - id: 'node-id', - }); - expect(buildWorkloadsHref(resource)).toBe('/workloads?agent=node-name'); - }); - - it('routes truenas systems to platform-scoped app workloads', () => { - const resource = makeResource({ - type: 'agent', - platformType: 'truenas', - name: 'truenas-main', - }); - - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=app-container&platform=truenas&agent=truenas-main', - ); - }); - - it('routes hybrid agent resources with merged truenas sources to platform-scoped app workloads', () => { - const resource = makeResource({ - type: 'agent', - platformType: 'agent', - name: 'truenas-main', - platformData: { - sources: ['agent', 'truenas'], - }, - }); - - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=app-container&platform=truenas&agent=truenas-main', - ); - }); - - it('routes top-level truenas resources to platform-scoped app workloads', () => { - const resource = makeResource({ - type: 'agent', - platformType: 'truenas', - name: 'truenas-main', - }); - - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=app-container&platform=truenas&agent=truenas-main', - ); - }); - }); - - // ── Unsupported types ─────────────────────────────────────────── - - describe('unsupported resource types', () => { - it.each([ - 'container', - 'docker-container', - 'docker-service', - 'k8s-deployment', - 'k8s-service', - 'host', - 'pbs', - 'pmg', - 'storage', - 'datastore', - 'pool', - 'dataset', - 'physical_disk', - 'ceph', - ] as const)('returns null for %s', (type) => { - const resource = makeResource({ type } as Partial); - expect(buildWorkloadsHref(resource)).toBeNull(); - }); - }); - - // ── Edge cases ────────────────────────────────────────────────── - - describe('edge cases', () => { - it('handles missing platformData gracefully', () => { - const resource = makeResource({ - type: 'agent', - platformData: undefined, - platformId: '', - name: '', - displayName: '', - id: 'bare-id', - }); - // With no platformData and empty name/platformId, falls to resource.id - expect(buildWorkloadsHref(resource)).toBe('/workloads?agent=bare-id'); - }); - - it('trims whitespace from resolved values', () => { - const resource = makeResource({ - type: 'docker-host', - platformType: 'docker', - platformData: { docker: { hostname: ' trimmed-host ' } }, - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=app-container&platform=docker&agent=trimmed-host', - ); - }); - - it('skips null values in the resolution chain', () => { - const resource = makeResource({ - type: 'k8s-cluster', - platformType: 'kubernetes', - platformData: { kubernetes: { clusterName: null as unknown as string } }, - clusterId: 'real-value', - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=pod&platform=kubernetes&context=real-value', - ); - }); - - it('skips undefined values in the resolution chain', () => { - const resource = makeResource({ - type: 'k8s-cluster', - platformType: 'kubernetes', - platformData: { kubernetes: { clusterName: undefined, context: undefined } }, - clusterId: 'cluster-fallback', - }); - expect(buildWorkloadsHref(resource)).toBe( - '/workloads?type=pod&platform=kubernetes&context=cluster-fallback', - ); - }); - }); -}); diff --git a/frontend-modern/src/components/Infrastructure/serviceDetailLinks.ts b/frontend-modern/src/components/Infrastructure/serviceDetailLinks.ts index 6653978b7..ff06cd9f8 100644 --- a/frontend-modern/src/components/Infrastructure/serviceDetailLinks.ts +++ b/frontend-modern/src/components/Infrastructure/serviceDetailLinks.ts @@ -1,5 +1,5 @@ import type { Resource } from '@/types/resource'; -import { buildRecoveryPath, PMG_THRESHOLDS_PATH } from '@/routing/resourceLinks'; +import { PMG_THRESHOLDS_PATH } from '@/routing/resourceLinks'; import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity'; export type ServiceDetailLink = { @@ -12,17 +12,6 @@ export type ServiceDetailLink = { export const buildServiceDetailLinks = (resource: Resource): ServiceDetailLink[] => { const label = getPreferredInfrastructureDisplayName(resource); - if (resource.type === 'pbs') { - return [ - { - href: buildRecoveryPath({ view: 'events', platform: 'proxmox-pbs', mode: 'remote' }), - label: 'Open Recovery Events', - compactLabel: 'Recovery', - ariaLabel: `Open recovery events for ${label}`, - }, - ]; - } - if (resource.type === 'pmg') { return [ { diff --git a/frontend-modern/src/components/Infrastructure/workloadsLink.ts b/frontend-modern/src/components/Infrastructure/workloadsLink.ts deleted file mode 100644 index 291e31c6b..000000000 --- a/frontend-modern/src/components/Infrastructure/workloadsLink.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Resource } from '@/types/resource'; -import { buildWorkloadsHrefForResource } from '@/routing/resourceLinks'; - -export const buildWorkloadsHref = (resource: Resource): string | null => - buildWorkloadsHrefForResource(resource); diff --git a/frontend-modern/src/components/Kubernetes/K8sDeploymentsDrawer.tsx b/frontend-modern/src/components/Kubernetes/K8sDeploymentsDrawer.tsx index 8fd9dade9..6e02a52d9 100644 --- a/frontend-modern/src/components/Kubernetes/K8sDeploymentsDrawer.tsx +++ b/frontend-modern/src/components/Kubernetes/K8sDeploymentsDrawer.tsx @@ -15,7 +15,7 @@ import { TableCell, } from '@/components/shared/Table'; import { EmptyState } from '@/components/shared/EmptyState'; -import { buildWorkloadsPath } from '@/routing/resourceLinks'; +import { buildKubernetesPath } from '@/routing/resourceLinks'; import { getK8sDeploymentsDrawerPresentation, getK8sDeploymentsEmptyState, @@ -160,16 +160,9 @@ export const K8sDeploymentsDrawer: Component<{ }); }); - const openPods = (ns?: string) => { - const cluster = clusterName(); - if (!cluster) return; - navigate( - buildWorkloadsPath({ - type: 'pod', - context: cluster, - namespace: asTrimmedString(ns) || null, - }), - ); + const openPods = (_ns?: string) => { + if (!clusterName()) return; + navigate(buildKubernetesPath('pods')); }; const headingId = () => `k8s-deployments-drawer-heading-${clusterName() || 'cluster'}`; diff --git a/frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx b/frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx index ea751b9d9..dbd28f572 100644 --- a/frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx +++ b/frontend-modern/src/components/Kubernetes/K8sNamespacesDrawer.tsx @@ -14,7 +14,7 @@ import { } from '@/components/shared/Table'; import { EmptyState } from '@/components/shared/EmptyState'; import { StatusDot } from '@/components/shared/StatusDot'; -import { buildWorkloadsPath } from '@/routing/resourceLinks'; +import { buildKubernetesPath } from '@/routing/resourceLinks'; import { getK8sNamespacesDrawerPresentation, getK8sNamespacesEmptyState, @@ -79,16 +79,9 @@ export const K8sNamespacesDrawer: Component<{ return rows().filter((row) => row.namespace.toLowerCase().includes(term)); }); - const openPods = (namespace: string | null) => { - const cluster = clusterName(); - if (!cluster) return; - navigate( - buildWorkloadsPath({ - type: 'pod', - context: cluster, - namespace: asTrimmedString(namespace) ?? null, - }), - ); + const openPods = (_namespace: string | null) => { + if (!clusterName()) return; + navigate(buildKubernetesPath('pods')); }; const headingId = () => `k8s-namespaces-drawer-heading-${clusterName() || 'cluster'}`; diff --git a/frontend-modern/src/components/Kubernetes/__tests__/K8sDeploymentsDrawer.test.tsx b/frontend-modern/src/components/Kubernetes/__tests__/K8sDeploymentsDrawer.test.tsx index c53b6e4d6..75ee4832d 100644 --- a/frontend-modern/src/components/Kubernetes/__tests__/K8sDeploymentsDrawer.test.tsx +++ b/frontend-modern/src/components/Kubernetes/__tests__/K8sDeploymentsDrawer.test.tsx @@ -741,7 +741,7 @@ describe('K8sDeploymentsDrawer', () => { }); describe('navigation', () => { - it('"Open Pods" button navigates with cluster context', async () => { + it('"Open Pods" button navigates to the Kubernetes Pods sub-tab', async () => { mockApiResponse([makeDeployment()]); render(() => ); @@ -753,13 +753,10 @@ describe('K8sDeploymentsDrawer', () => { fireEvent.click(openPodsBtn); expect(navigateMock).toHaveBeenCalledTimes(1); - const path = navigateMock.mock.calls[0][0] as string; - expect(path).toContain('/workloads'); - expect(path).toContain('type=pod'); - expect(path).toContain('context=my-cluster'); + expect(navigateMock).toHaveBeenCalledWith('/kubernetes/pods'); }); - it('"Open Pods" passes selected namespace to navigation', async () => { + it('"Open Pods" routes to the Kubernetes Pods sub-tab regardless of selected namespace', async () => { mockApiResponse([ makeDeployment({ id: '1', name: 'dep-a', kubernetes: { namespace: 'production' } }), makeDeployment({ id: '2', name: 'dep-b', kubernetes: { namespace: 'staging' } }), @@ -770,7 +767,9 @@ describe('K8sDeploymentsDrawer', () => { expect(screen.getByText('dep-a')).toBeInTheDocument(); }); - // Select a namespace first + // Selecting a namespace narrows the in-drawer view but no longer + // forwards as a URL filter (the platform-first Kubernetes Pods tab + // does not consume legacy ?namespace= query params). const select = screen.getByLabelText('Namespace'); fireEvent.change(select, { target: { value: 'production' } }); @@ -778,11 +777,10 @@ describe('K8sDeploymentsDrawer', () => { fireEvent.click(openPodsBtn); expect(navigateMock).toHaveBeenCalledTimes(1); - const path = navigateMock.mock.calls[0][0] as string; - expect(path).toContain('namespace=production'); + expect(navigateMock).toHaveBeenCalledWith('/kubernetes/pods'); }); - it('"View Pods" button on a row navigates with that deployment\'s namespace', async () => { + it('"View Pods" button on a row navigates to the Kubernetes Pods sub-tab', async () => { mockApiResponse([ makeDeployment({ id: '1', name: 'dep-a', kubernetes: { namespace: 'kube-system' } }), ]); @@ -796,8 +794,7 @@ describe('K8sDeploymentsDrawer', () => { fireEvent.click(viewPodsBtn); expect(navigateMock).toHaveBeenCalledTimes(1); - const path = navigateMock.mock.calls[0][0] as string; - expect(path).toContain('namespace=kube-system'); + expect(navigateMock).toHaveBeenCalledWith('/kubernetes/pods'); }); it('does not navigate when cluster is empty', async () => { diff --git a/frontend-modern/src/components/Kubernetes/__tests__/K8sNamespacesDrawer.test.tsx b/frontend-modern/src/components/Kubernetes/__tests__/K8sNamespacesDrawer.test.tsx index 2c1f61882..9ce701312 100644 --- a/frontend-modern/src/components/Kubernetes/__tests__/K8sNamespacesDrawer.test.tsx +++ b/frontend-modern/src/components/Kubernetes/__tests__/K8sNamespacesDrawer.test.tsx @@ -33,7 +33,7 @@ describe('K8sNamespacesDrawer', () => { cleanup(); }); - it('navigates to canonical pod workloads for the cluster', async () => { + it('navigates to the Kubernetes Pods sub-tab from the cluster summary', async () => { apiFetchJSONMock.mockResolvedValueOnce(makeResponse([{ namespace: 'default' }])); render(() => ); @@ -44,10 +44,10 @@ describe('K8sNamespacesDrawer', () => { await fireEvent.click(screen.getByRole('button', { name: 'Open All Pods' })); - expect(navigateMock).toHaveBeenCalledWith('/workloads?type=pod&context=cluster-a'); + expect(navigateMock).toHaveBeenCalledWith('/kubernetes/pods'); }); - it('navigates to canonical pod workloads scoped by namespace', async () => { + it('navigates to the Kubernetes Pods sub-tab from a namespace row', async () => { apiFetchJSONMock.mockResolvedValueOnce(makeResponse([{ namespace: 'kube-system' }])); render(() => ); @@ -58,9 +58,7 @@ describe('K8sNamespacesDrawer', () => { await fireEvent.click(screen.getByRole('button', { name: 'Open Pods' })); - expect(navigateMock).toHaveBeenCalledWith( - '/workloads?type=pod&context=cluster-a&namespace=kube-system', - ); + expect(navigateMock).toHaveBeenCalledWith('/kubernetes/pods'); }); it('does not navigate when cluster is empty', async () => { diff --git a/frontend-modern/src/components/Workloads/GuestRow.tsx b/frontend-modern/src/components/Workloads/GuestRow.tsx index 6ec969ce2..1cd5b0b79 100644 --- a/frontend-modern/src/components/Workloads/GuestRow.tsx +++ b/frontend-modern/src/components/Workloads/GuestRow.tsx @@ -1,5 +1,4 @@ import { createMemo, Show } from 'solid-js'; -import { useNavigate } from '@solidjs/router'; import type { VM } from '@/types/api'; import type { WorkloadGuest } from '@/types/workloads'; import { formatUptime, formatSpeed, getShortImageName } from '@/utils/format'; @@ -41,7 +40,6 @@ export type { GuestRowProps, WorkloadIOEmphasis } from './guestRowModel'; import { getGuestColumnStyle } from './guestRowModel'; export function GuestRow(props: GuestRowProps) { - const navigate = useNavigate(); const { agentVersion, appContainerRuntimeLabel, @@ -66,7 +64,6 @@ export function GuestRow(props: GuestRowProps) { hasOsInfo, infoTooltip, infoValue, - infrastructureHref, ipAddresses, isColVisible, isMobile, @@ -437,17 +434,12 @@ export function GuestRow(props: GuestRowProps) { } > - + diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx index bf637c715..68b27dee6 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx @@ -104,15 +104,6 @@ vi.mock('@/components/shared/workloadTypeBadges', () => ({ }), })); -vi.mock('@/routing/resourceLinks', async () => { - const actual = - await vi.importActual('@/routing/resourceLinks'); - return { - ...actual, - buildInfrastructureHrefForWorkload: () => '/infrastructure/node1', - }; -}); - vi.mock('../workloadTopology', () => ({ getWorkloadAlertResourceIdCandidates: (guest: WorkloadGuest) => [guest.id], getWorkloadAlertThresholdScope: (guest: WorkloadGuest) => @@ -543,23 +534,26 @@ describe('GuestRow', () => { }); describe('node column', () => { - it('renders node name as a link button', () => { + it('renders node name as plain text in the platform-first layout', () => { renderGuestRow({ guest: makeGuest({ node: 'pve1' }), visibleColumnIds: ['name', 'node'], }); - const nodeButton = screen.getByText('pve1'); - expect(nodeButton.tagName).toBe('BUTTON'); + const nodeLabel = screen.getByText('pve1'); + // The legacy cross-jump to /infrastructure?source=...&query= was + // dropped; the node name is now non-interactive context inside the + // owning platform page. + expect(nodeLabel.tagName).toBe('SPAN'); }); - it('navigates on node click', () => { + it('does not navigate when the node label is clicked', () => { renderGuestRow({ guest: makeGuest({ node: 'pve1' }), visibleColumnIds: ['name', 'node'], }); - const nodeButton = screen.getByText('pve1'); - fireEvent.click(nodeButton); - expect(mockNavigate).toHaveBeenCalledWith('/infrastructure/node1'); + const nodeLabel = screen.getByText('pve1'); + fireEvent.click(nodeLabel); + expect(mockNavigate).not.toHaveBeenCalled(); }); }); @@ -1159,19 +1153,6 @@ describe('info merged column', () => { }); describe('event propagation', () => { - it('node button click does not trigger row onClick', () => { - const rowClick = vi.fn(); - renderGuestRow({ - guest: makeGuest({ node: 'pve1' }), - onClick: rowClick, - visibleColumnIds: ['name', 'node'], - }); - const nodeButton = screen.getByText('pve1'); - fireEvent.click(nodeButton); - // Node click calls stopPropagation, so row onClick should not fire - expect(rowClick).not.toHaveBeenCalled(); - }); - it('name custom URL click does not trigger row onClick', () => { const rowClick = vi.fn(); const { container } = renderGuestRow({ diff --git a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx index 32ceedcc0..f56b9a120 100644 --- a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx @@ -1058,7 +1058,9 @@ describe('Workloads performance contract', () => { expect(guestRowModelSource).toContain('export const VIEW_MODE_COLUMNS'); expect(guestRowStateSource).toContain('getCanonicalWorkloadId'); expect(guestRowStateSource).toContain('buildMetricKey'); - expect(guestRowStateSource).toContain("from '@/routing/resourceLinks'"); + // The legacy /infrastructure cross-jump was retired; guest row state no + // longer needs anything from resourceLinks. + expect(guestRowStateSource).not.toContain("from '@/routing/resourceLinks'"); expect(guestRowStateSource).toContain("from './workloadTopology'"); expect(guestRowStateSource).not.toContain('./infrastructureLink'); expect(guestRowModelSource).not.toContain("id: 'link'"); diff --git a/frontend-modern/src/components/Workloads/useGuestRowState.ts b/frontend-modern/src/components/Workloads/useGuestRowState.ts index 820ed827e..6c58d44ca 100644 --- a/frontend-modern/src/components/Workloads/useGuestRowState.ts +++ b/frontend-modern/src/components/Workloads/useGuestRowState.ts @@ -14,7 +14,6 @@ import { isDockerManagedAppContainer, resolveWorkloadType, } from '@/utils/workloads'; -import { buildInfrastructureHrefForWorkload } from '@/routing/resourceLinks'; import { getWorkloadTypeBadge } from '@/components/shared/workloadTypeBadges'; import { @@ -35,7 +34,6 @@ export function useGuestRowState(props: GuestRowProps) { const alertsActivation = useAlertsActivation(); const guestId = createMemo(() => getCanonicalWorkloadId(props.guest)); - const infrastructureHref = createMemo(() => buildInfrastructureHrefForWorkload(props.guest)); const visibleColumnIdSet = createMemo(() => props.visibleColumnIds ? new Set(props.visibleColumnIds) : null, @@ -297,7 +295,6 @@ export function useGuestRowState(props: GuestRowProps) { hasOsInfo, infoTooltip, infoValue, - infrastructureHref, ipAddresses, isColVisible, isMobile, diff --git a/frontend-modern/src/components/shared/KeyboardShortcutsModal.tsx b/frontend-modern/src/components/shared/KeyboardShortcutsModal.tsx index 0b663c8cc..74cfbc336 100644 --- a/frontend-modern/src/components/shared/KeyboardShortcutsModal.tsx +++ b/frontend-modern/src/components/shared/KeyboardShortcutsModal.tsx @@ -14,11 +14,13 @@ interface KeyboardShortcutsModalProps { const UNIFIED_NAV_SHORTCUTS: ShortcutGroup = { title: 'Navigation', items: [ - { keys: 'g then i', description: 'Go to Infrastructure' }, - { keys: 'g then w', description: 'Go to Workloads' }, - { keys: 'g then s', description: 'Go to Storage' }, - { keys: 'g then b', description: 'Go to Recovery' }, + { keys: 'g then p', description: 'Go to Proxmox' }, + { keys: 'g then d', description: 'Go to Docker' }, + { keys: 'g then k', description: 'Go to Kubernetes' }, + { keys: 'g then n', description: 'Go to TrueNAS' }, + { keys: 'g then v', description: 'Go to vSphere' }, { keys: 'g then a', description: 'Go to Alerts' }, + { keys: 'g then r', description: 'Go to Patrol' }, { keys: 'g then t', description: 'Go to Settings' }, ], }; diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index d590ae3f5..b71363993 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -157,7 +157,6 @@ import deployConfirmStepSource from '@/components/Infrastructure/deploy/ConfirmS import deployDeployingStepSource from '@/components/Infrastructure/deploy/DeployingStep.tsx?raw'; import deployPreflightStepSource from '@/components/Infrastructure/deploy/PreflightStep.tsx?raw'; import deployResultsStepSource from '@/components/Infrastructure/deploy/ResultsStep.tsx?raw'; -import cephPageSource from '@/pages/Ceph.tsx?raw'; import pmgMailGatewaySource from '@/components/PMG/MailGateway.tsx?raw'; import pmgInstancePanelSource from '@/components/PMG/PMGInstancePanel.tsx?raw'; import resourceDetailDrawerOverviewSource from '@/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx?raw'; @@ -310,8 +309,14 @@ describe('shared primitive guardrails', () => { expect(commandPaletteStateSource).toContain('useNavigate'); expect(commandPaletteStateSource).toContain('createSignal'); - expect(commandPaletteStateSource).toContain('buildInfrastructurePath'); + expect(commandPaletteStateSource).toContain('buildProxmoxPath'); expect(commandPaletteStateSource).toContain('export function useCommandPaletteState'); + // Legacy navigation entries (Infrastructure / Workloads / Storage / + // Recovery) were retired when primary nav moved to platform-first. + expect(commandPaletteStateSource).not.toContain('buildInfrastructurePath'); + expect(commandPaletteStateSource).not.toContain('buildWorkloadsPath'); + expect(commandPaletteStateSource).not.toContain('buildStoragePath'); + expect(commandPaletteStateSource).not.toContain('buildRecoveryPath'); expect(commandPaletteModelSource).toContain('buildCommandPaletteCommands'); expect(commandPaletteModelSource).toContain('normalizeCommandPaletteQuery'); @@ -560,7 +565,6 @@ describe('shared primitive guardrails', () => { infrastructureSourceManagerSource, configuredNodeTablesSource, aiCostDashboardSource, - cephPageSource, deployCandidatesStepSource, deployConfirmStepSource, deployDeployingStepSource, diff --git a/frontend-modern/src/components/shared/__tests__/CommandPaletteModal.test.tsx b/frontend-modern/src/components/shared/__tests__/CommandPaletteModal.test.tsx index 882460b1e..1828a1246 100644 --- a/frontend-modern/src/components/shared/__tests__/CommandPaletteModal.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/CommandPaletteModal.test.tsx @@ -32,28 +32,46 @@ describe('CommandPaletteModal', () => { expect(commandPaletteStateSource).toContain('useNavigate'); expect(commandPaletteStateSource).toContain('createSignal'); - expect(commandPaletteStateSource).toContain('buildInfrastructurePath'); + expect(commandPaletteStateSource).toContain('buildProxmoxPath'); expect(commandPaletteStateSource).toContain('export function useCommandPaletteState'); + // Legacy navigation entries (Infrastructure / Workloads / Storage / Recovery) + // were retired when primary nav moved to platform-first. The palette + // exposes the platform pages directly instead. + expect(commandPaletteStateSource).not.toContain('buildInfrastructurePath'); + expect(commandPaletteStateSource).not.toContain('buildWorkloadsPath'); + expect(commandPaletteStateSource).not.toContain('buildStoragePath'); + expect(commandPaletteStateSource).not.toContain('buildRecoveryPath'); + expect(commandPaletteModelSource).toContain('buildCommandPaletteCommands'); expect(commandPaletteModelSource).toContain('normalizeCommandPaletteQuery'); expect(commandPaletteModelSource).toContain('filterCommandPaletteCommands'); + expect(commandPaletteModelSource).toContain("id: 'nav-proxmox'"); + expect(commandPaletteModelSource).toContain("id: 'nav-docker'"); + expect(commandPaletteModelSource).toContain("id: 'nav-kubernetes'"); + expect(commandPaletteModelSource).toContain("id: 'nav-truenas'"); + expect(commandPaletteModelSource).toContain("id: 'nav-vmware'"); + expect(commandPaletteModelSource).not.toContain("id: 'nav-infrastructure'"); + expect(commandPaletteModelSource).not.toContain("id: 'nav-workloads'"); + expect(commandPaletteModelSource).not.toContain("id: 'nav-storage'"); + expect(commandPaletteModelSource).not.toContain("id: 'nav-recovery'"); }); - it('renders the dedicated pod workloads command with a canonical path', () => { + it('renders the platform entries and the dedicated Kubernetes pods command', () => { render(() => ); + expect(screen.getByText('Go to Proxmox')).toBeInTheDocument(); expect(screen.getByText('Go to Kubernetes Pods')).toBeInTheDocument(); - expect(screen.getByText('/workloads?type=pod')).toBeInTheDocument(); + expect(screen.getByText('/kubernetes/pods')).toBeInTheDocument(); }); - it('navigates to the canonical pod workloads path', async () => { + it('navigates to the Kubernetes pods sub-tab', async () => { const onClose = vi.fn(); render(() => ); await fireEvent.click(screen.getByText('Go to Kubernetes Pods')); - expect(navigateMock).toHaveBeenCalledWith('/workloads?type=pod'); + expect(navigateMock).toHaveBeenCalledWith('/kubernetes/pods'); expect(onClose).toHaveBeenCalledTimes(1); }); @@ -62,10 +80,10 @@ describe('CommandPaletteModal', () => { render(() => ); const input = screen.getByPlaceholderText('Type a command or search...'); - await fireEvent.input(input, { target: { value: 'type=pod' } }); + await fireEvent.input(input, { target: { value: 'kubernetes pods' } }); await fireEvent.keyDown(input, { key: 'Enter' }); - expect(navigateMock).toHaveBeenCalledWith('/workloads?type=pod'); + expect(navigateMock).toHaveBeenCalledWith('/kubernetes/pods'); expect(onClose).toHaveBeenCalledTimes(1); }); }); diff --git a/frontend-modern/src/components/shared/commandPaletteModel.ts b/frontend-modern/src/components/shared/commandPaletteModel.ts index dae8b9c4d..493640fe5 100644 --- a/frontend-modern/src/components/shared/commandPaletteModel.ts +++ b/frontend-modern/src/components/shared/commandPaletteModel.ts @@ -13,11 +13,12 @@ export type CommandPaletteModalCommand = { }; export type CommandPaletteCommandPaths = { - infrastructurePath: string; - workloadsPath: string; - podWorkloadsPath: string; - storagePath: string; - recoveryPath: string; + proxmoxPath: string; + dockerPath: string; + kubernetesPath: string; + kubernetesPodsPath: string; + trueNasPath: string; + vmwarePath: string; }; export function buildCommandPaletteCommands(options: { @@ -26,51 +27,51 @@ export function buildCommandPaletteCommands(options: { }): CommandPaletteModalCommand[] { return [ { - id: 'nav-infrastructure', - label: 'Go to Infrastructure', - description: options.paths.infrastructurePath, - shortcut: 'g i', - keywords: ['infra', 'agents', 'nodes', 'resources'], - action: () => options.navigate(options.paths.infrastructurePath), + id: 'nav-proxmox', + label: 'Go to Proxmox', + description: options.paths.proxmoxPath, + shortcut: 'g p', + keywords: ['proxmox', 'pve', 'pbs', 'pmg', 'mail', 'backups', 'ceph', 'vm', 'lxc'], + action: () => options.navigate(options.paths.proxmoxPath), }, { - id: 'nav-workloads', - label: 'Go to Workloads', - description: options.paths.workloadsPath, - shortcut: 'g w', - keywords: [ - 'vm', - 'system-container', - 'app-container', - 'docker', - 'k8s', - 'kubernetes', - 'pods', - ], - action: () => options.navigate(options.paths.workloadsPath), + id: 'nav-docker', + label: 'Go to Docker', + description: options.paths.dockerPath, + shortcut: 'g d', + keywords: ['docker', 'podman', 'containers', 'compose', 'swarm', 'services'], + action: () => options.navigate(options.paths.dockerPath), }, { - id: 'nav-workloads-pods', + id: 'nav-kubernetes', + label: 'Go to Kubernetes', + description: options.paths.kubernetesPath, + shortcut: 'g k', + keywords: ['k8s', 'kubernetes', 'clusters', 'nodes', 'deployments'], + action: () => options.navigate(options.paths.kubernetesPath), + }, + { + id: 'nav-kubernetes-pods', label: 'Go to Kubernetes Pods', - description: options.paths.podWorkloadsPath, - keywords: ['k8s', 'kubernetes', 'pods', 'deployments', 'clusters'], - action: () => options.navigate(options.paths.podWorkloadsPath), + description: options.paths.kubernetesPodsPath, + keywords: ['k8s', 'kubernetes', 'pods', 'workloads'], + action: () => options.navigate(options.paths.kubernetesPodsPath), }, { - id: 'nav-storage', - label: 'Go to Storage', - description: options.paths.storagePath, - shortcut: 'g s', - keywords: ['ceph', 'pbs'], - action: () => options.navigate(options.paths.storagePath), + id: 'nav-truenas', + label: 'Go to TrueNAS', + description: options.paths.trueNasPath, + shortcut: 'g n', + keywords: ['truenas', 'storage', 'disks', 'apps'], + action: () => options.navigate(options.paths.trueNasPath), }, { - id: 'nav-recovery', - label: 'Go to Recovery', - description: options.paths.recoveryPath, - shortcut: 'g b', - keywords: ['recovery', 'backups', 'snapshots', 'replication', 'restore'], - action: () => options.navigate(options.paths.recoveryPath), + id: 'nav-vmware', + label: 'Go to vSphere', + description: options.paths.vmwarePath, + shortcut: 'g v', + keywords: ['vmware', 'vsphere', 'esxi', 'vms', 'datastores'], + action: () => options.navigate(options.paths.vmwarePath), }, { id: 'nav-alerts', @@ -80,6 +81,14 @@ export function buildCommandPaletteCommands(options: { keywords: ['alarms', 'notifications'], action: () => options.navigate('/alerts'), }, + { + id: 'nav-patrol', + label: 'Go to Patrol', + description: '/patrol', + shortcut: 'g r', + keywords: ['patrol', 'findings', 'ai', 'verification'], + action: () => options.navigate('/patrol'), + }, { id: 'nav-settings', label: 'Go to Settings', diff --git a/frontend-modern/src/components/shared/useCommandPaletteState.ts b/frontend-modern/src/components/shared/useCommandPaletteState.ts index bede37d75..52dd6063b 100644 --- a/frontend-modern/src/components/shared/useCommandPaletteState.ts +++ b/frontend-modern/src/components/shared/useCommandPaletteState.ts @@ -1,10 +1,11 @@ import { useNavigate } from '@solidjs/router'; import { createEffect, createMemo, createSignal } from 'solid-js'; import { - buildRecoveryPath, - buildInfrastructurePath, - buildStoragePath, - buildWorkloadsPath, + buildDockerPath, + buildKubernetesPath, + buildProxmoxPath, + buildTrueNASPath, + buildVmwarePath, } from '@/routing/resourceLinks'; import { buildCommandPaletteCommands, @@ -26,11 +27,12 @@ export function useCommandPaletteState(props: CommandPaletteModalProps) { const commands = createMemo(() => buildCommandPaletteCommands({ paths: { - infrastructurePath: buildInfrastructurePath(), - workloadsPath: buildWorkloadsPath(), - podWorkloadsPath: buildWorkloadsPath({ type: 'pod' }), - storagePath: buildStoragePath(), - recoveryPath: buildRecoveryPath(), + proxmoxPath: buildProxmoxPath(), + dockerPath: buildDockerPath(), + kubernetesPath: buildKubernetesPath(), + kubernetesPodsPath: buildKubernetesPath('pods'), + trueNasPath: buildTrueNASPath(), + vmwarePath: buildVmwarePath(), }, navigate, }), diff --git a/frontend-modern/src/hooks/useKeyboardShortcuts.ts b/frontend-modern/src/hooks/useKeyboardShortcuts.ts index ef849d56e..c635d4ee9 100644 --- a/frontend-modern/src/hooks/useKeyboardShortcuts.ts +++ b/frontend-modern/src/hooks/useKeyboardShortcuts.ts @@ -1,10 +1,11 @@ import { createSignal, onCleanup, type Accessor } from 'solid-js'; import { useNavigate } from '@solidjs/router'; import { - buildRecoveryPath, - buildInfrastructurePath, - buildStoragePath, - buildWorkloadsPath, + buildDockerPath, + buildKubernetesPath, + buildProxmoxPath, + buildTrueNASPath, + buildVmwarePath, } from '@/routing/resourceLinks'; import { focusActiveTypeToSearch } from '@/hooks/useTypeToSearch'; @@ -76,11 +77,13 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) { const getRoutes = (): Record => { return { - i: buildInfrastructurePath(), - w: buildWorkloadsPath(), - s: buildStoragePath(), - b: buildRecoveryPath(), + p: buildProxmoxPath(), + d: buildDockerPath(), + k: buildKubernetesPath(), + n: buildTrueNASPath(), + v: buildVmwarePath(), a: '/alerts', + r: '/patrol', t: '/settings', }; }; diff --git a/frontend-modern/src/pages/Ceph.tsx b/frontend-modern/src/pages/Ceph.tsx deleted file mode 100644 index 534777c86..000000000 --- a/frontend-modern/src/pages/Ceph.tsx +++ /dev/null @@ -1,689 +0,0 @@ -import { Component, For, Show, createMemo, createSignal } from 'solid-js'; -import { Portal } from 'solid-js/web'; -import { useWebSocket } from '@/contexts/appRuntime'; -import { useResources } from '@/hooks/useResources'; -import { Card } from '@/components/shared/Card'; -import { - Table, - TableHeader, - TableBody, - TableRow, - TableHead, - TableCell, -} from '@/components/shared/Table'; -import { EmptyState } from '@/components/shared/EmptyState'; -import { PageHeader } from '@/components/shared/PageHeader'; -import { SearchInput } from '@/components/shared/SearchInput'; -import CephServiceIcon from '@/components/Ceph/CephServiceIcon'; -import type { CephCluster, CephPool, CephServiceStatus } from '@/types/api'; -import { formatBytes } from '@/utils/format'; -import { useKioskMode } from '@/hooks/useKioskMode'; -import { - getCephHealthPresentation, - getCephLoadingStatePresentation, - getCephDisconnectedStatePresentation, - getCephNoClustersStatePresentation, - getCephPoolsSearchEmptyStatePresentation, - getCephServiceStatusPresentation, -} from '@/features/storageBackups/storageDomain'; - -// Cluster health status badge with tooltip -const HealthBadge: Component<{ health: string; message?: string }> = (props) => { - const [showTooltip, setShowTooltip] = createSignal(false); - const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 }); - - const healthInfo = createMemo(() => getCephHealthPresentation(props.health)); - - const handleMouseEnter = (e: MouseEvent) => { - if (!props.message) return; - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top }); - setShowTooltip(true); - }; - - return ( - <> - setShowTooltip(false)} - > - - {healthInfo().label} - - - - -
-
- {props.message} -
-
-
-
- - ); -}; - -// Service status cell with tooltip -const ServiceStatusCell: Component<{ services: CephServiceStatus[] }> = (props) => { - const [showTooltip, setShowTooltip] = createSignal(false); - const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 }); - - const handleMouseEnter = (e: MouseEvent) => { - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top }); - setShowTooltip(true); - }; - - return ( - <> -
setShowTooltip(false)} - > - - {(svc) => { - const status = getCephServiceStatusPresentation(svc); - return ( - - - - {svc.running}/{svc.total} - - - ); - }} - - 4}> - +{props.services.length - 4} - -
- - 0}> - -
-
-
- Ceph Services -
-
- - {(svc) => { - const status = getCephServiceStatusPresentation(svc); - return ( -
- - - {svc.type} - - - {svc.running}/{svc.total} - -
- ); - }} -
-
-
-
-
-
- - ); -}; - -// Usage bar component -const UsageBar: Component<{ percent: number; size?: 'sm' | 'md' }> = (props) => { - const barHeight = () => (props.size === 'sm' ? 'h-1.5' : 'h-2'); - const barColor = () => { - const p = props.percent || 0; - if (p > 90) return 'bg-red-500'; - if (p > 75) return 'bg-yellow-500'; - return 'bg-blue-500'; - }; - - return ( -
-
-
- ); -}; - -// Main Ceph Page Component -const Ceph: Component = () => { - const { connected, initialDataReceived, reconnecting, reconnect } = useWebSocket(); - const { byType } = useResources(); - - const kioskMode = useKioskMode(); - - const [searchTerm, setSearchTerm] = createSignal(''); - - const cephResources = createMemo(() => byType('ceph')); - - const clusters = createMemo(() => { - return cephResources().map((r) => { - const cephMeta = (r.platformData as any)?.ceph || {}; - return { - id: r.id, - instance: (r.platformData as any)?.proxmox?.instance || r.platformId || '', - name: r.name, - fsid: cephMeta.fsid, - health: cephMeta.healthStatus || 'HEALTH_UNKNOWN', - healthMessage: cephMeta.healthMessage || '', - totalBytes: r.disk?.total || 0, - usedBytes: r.disk?.used || 0, - availableBytes: r.disk?.free || 0, - usagePercent: r.disk?.current || 0, - numMons: cephMeta.numMons || 0, - numMgrs: cephMeta.numMgrs || 0, - numOsds: cephMeta.numOsds || 0, - numOsdsUp: cephMeta.numOsdsUp || 0, - numOsdsIn: cephMeta.numOsdsIn || 0, - numPGs: cephMeta.numPGs || 0, - pools: cephMeta.pools?.map((p: any) => ({ - id: 0, - name: p.name || '', - storedBytes: p.storedBytes || 0, - availableBytes: p.availableBytes || 0, - objects: p.objects || 0, - percentUsed: p.percentUsed || 0, - })), - services: cephMeta.services?.map((s: any) => ({ - type: s.type || '', - running: s.running || 0, - total: s.total || 0, - })), - lastUpdated: r.lastSeen || Date.now(), - } as CephCluster; - }); - }); - const hasClusters = createMemo(() => clusters().length > 0); - - // Aggregate all pools from all clusters - const allPools = createMemo(() => { - const pools: (CephPool & { clusterName: string })[] = []; - for (const cluster of clusters()) { - if (cluster.pools) { - for (const pool of cluster.pools) { - pools.push({ - ...pool, - clusterName: cluster.name || 'Ceph Cluster', - }); - } - } - } - return pools; - }); - - // Aggregate services from all clusters - const allServices = createMemo(() => { - const serviceMap = new Map(); - for (const cluster of clusters()) { - if (cluster.services) { - for (const svc of cluster.services) { - const existing = serviceMap.get(svc.type) || { running: 0, total: 0 }; - serviceMap.set(svc.type, { - running: existing.running + svc.running, - total: existing.total + svc.total, - }); - } - } - } - return Array.from(serviceMap.entries()).map(([type, counts]) => ({ - type, - running: counts.running, - total: counts.total, - })); - }); - - // Filter pools by search term - const filteredPools = createMemo(() => { - const term = searchTerm().toLowerCase().trim(); - if (!term) return allPools(); - return allPools().filter( - (pool) => - pool.name.toLowerCase().includes(term) || pool.clusterName.toLowerCase().includes(term), - ); - }); - - // Calculate total storage stats - const totalStats = createMemo(() => { - let totalBytes = 0; - let usedBytes = 0; - for (const cluster of clusters()) { - totalBytes += cluster.totalBytes || 0; - usedBytes += cluster.usedBytes || 0; - } - const usagePercent = totalBytes > 0 ? (usedBytes / totalBytes) * 100 : 0; - return { totalBytes, usedBytes, usagePercent }; - }); - - const isLoading = createMemo(() => connected() && !initialDataReceived()); - const cephLoadingState = createMemo(() => getCephLoadingStatePresentation()); - const cephDisconnectedState = createMemo(() => - getCephDisconnectedStatePresentation(reconnecting()), - ); - const cephNoClustersState = createMemo(() => getCephNoClustersStatePresentation()); - - const thClass = - 'px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-surface-hover whitespace-nowrap transition-colors'; - - return ( -
- - - {/* Loading State */} - - - - - - - } - title={cephLoadingState().title} - description={cephLoadingState().description} - /> - - - - {/* Disconnected State */} - - - - - - } - title={cephDisconnectedState().title} - description={cephDisconnectedState().description} - tone="danger" - actions={ - !reconnecting() ? ( - - ) : undefined - } - /> - - - - - {/* No Clusters Empty State */} - - - - - - } - title={cephNoClustersState().title} - description={cephNoClustersState().description} - /> - - - - {/* Clusters Found - Show Content */} - - {/* Summary Cards */} -
- {/* Total Storage Card */} - -
- - Total Storage - - - - -
-
- {formatBytes(totalStats().totalBytes)} -
-
- -
- {formatBytes(totalStats().usedBytes)} used - {totalStats().usagePercent.toFixed(1)}% -
-
-
- - {/* Clusters Card */} - -
- Clusters - - - -
-
{clusters().length}
-
- - {(cluster) => ( - - )} - -
-
- - {/* Services Card */} - -
- Services - - - -
-
- {allServices().reduce((acc, svc) => acc + svc.running, 0)} - - /{allServices().reduce((acc, svc) => acc + svc.total, 0)} - -
-
- -
-
- - {/* Pools Card */} - -
- Pools - - - -
-
{allPools().length}
-
- {allPools() - .reduce((acc, pool) => acc + (pool.objects || 0), 0) - .toLocaleString()}{' '} - objects -
-
-
- - {/* Cluster Details Table */} - 0}> - -
-

Cluster Overview

-
- - - - Cluster - Health - Monitors - Managers - OSDs - PGs - Capacity - - - - - {(cluster) => ( - - -
- {cluster.name || 'Ceph Cluster'} -
- -
- {cluster.fsid} -
-
-
- - - - - - - {cluster.numMons || 0} - - - - - - {cluster.numMgrs || 0} - - - - - - - {cluster.numOsdsUp || 0} - - /{cluster.numOsds || 0} - - - - - {(cluster.numPGs || 0).toLocaleString()} - - - -
- -
- {formatBytes(cluster.usedBytes || 0)} - {(cluster.usagePercent || 0).toFixed(1)}% -
-
-
-
- )} -
-
-
-
-
- - {/* Pools Table */} - 0}> - -
-

- Storage Pools ({filteredPools().length}) -

- {/* Search Input */} - - - -
- - 0} - fallback={ -
- {getCephPoolsSearchEmptyStatePresentation(searchTerm()).text} -
- } - > - - - - Pool - Cluster - Used - Available - Objects - Usage - - - - - {(pool) => ( - - - {pool.name} - - - {pool.clusterName} - - - {formatBytes(pool.storedBytes || 0)} - - - {formatBytes(pool.availableBytes || 0)} - - - {(pool.objects || 0).toLocaleString()} - - -
-
- -
- - {(pool.percentUsed || 0).toFixed(1)}% - -
-
-
- )} -
-
-
-
-
-
-
-
-
- ); -}; - -export default Ceph; diff --git a/frontend-modern/src/pages/Infrastructure.tsx b/frontend-modern/src/pages/Infrastructure.tsx deleted file mode 100644 index 9d24808f2..000000000 --- a/frontend-modern/src/pages/Infrastructure.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { InfrastructurePageSurface } from '@/features/infrastructure/InfrastructurePageSurface'; - -export function Infrastructure() { - return ; -} - -export default Infrastructure; diff --git a/frontend-modern/src/pages/NotFound.tsx b/frontend-modern/src/pages/NotFound.tsx index 72e41abb5..e1e61acac 100644 --- a/frontend-modern/src/pages/NotFound.tsx +++ b/frontend-modern/src/pages/NotFound.tsx @@ -4,7 +4,7 @@ import { useLocation, useNavigate } from '@solidjs/router'; import { Card } from '@/components/shared/Card'; import { EmptyState } from '@/components/shared/EmptyState'; import { PageHeader } from '@/components/shared/PageHeader'; -import { buildRecoveryPath, buildInfrastructurePath } from '@/routing/resourceLinks'; +import { buildProxmoxPath } from '@/routing/resourceLinks'; const NotFound: Component = () => { const location = useLocation(); @@ -21,19 +21,12 @@ const NotFound: Component = () => { description={`No route matched ${path()}.`} actions={
-
} diff --git a/frontend-modern/src/pages/Recovery.tsx b/frontend-modern/src/pages/Recovery.tsx deleted file mode 100644 index bfb56add3..000000000 --- a/frontend-modern/src/pages/Recovery.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import RecoverySurface from '@/components/Recovery/Recovery'; - -export function Recovery() { - return ; -} - -export default Recovery; diff --git a/frontend-modern/src/pages/RuntimeHome.tsx b/frontend-modern/src/pages/RuntimeHome.tsx index 79b98239c..e0b791d08 100644 --- a/frontend-modern/src/pages/RuntimeHome.tsx +++ b/frontend-modern/src/pages/RuntimeHome.tsx @@ -1,10 +1,10 @@ import { useNavigate } from '@solidjs/router'; import { createEffect } from 'solid-js'; -import { buildInfrastructurePath } from '@/routing/resourceLinks'; +import { buildProxmoxPath } from '@/routing/resourceLinks'; export default function RuntimeHome() { const navigate = useNavigate(); - const destination = () => buildInfrastructurePath(); + const destination = () => buildProxmoxPath(); createEffect(() => { navigate(destination(), { replace: true }); diff --git a/frontend-modern/src/pages/Storage.tsx b/frontend-modern/src/pages/Storage.tsx deleted file mode 100644 index 0ff3bdbb0..000000000 --- a/frontend-modern/src/pages/Storage.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import StorageSurface from '@/components/Storage/Storage'; - -export function Storage() { - return ; -} - -export default Storage; diff --git a/frontend-modern/src/pages/Workloads.tsx b/frontend-modern/src/pages/Workloads.tsx deleted file mode 100644 index f6c213a82..000000000 --- a/frontend-modern/src/pages/Workloads.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface'; - -export function Workloads() { - return ; -} - -export default Workloads; diff --git a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx index 6f489cd2f..65627e942 100644 --- a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx +++ b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx @@ -639,23 +639,18 @@ describe('AIIntelligence entitlement gating', () => { expect(screen.getByText('2 learned patterns · explanatory context')).toBeInTheDocument(); expect(screen.getByText('Coverage posture for policy-covered resources.')).toBeInTheDocument(); expect(screen.getByText('3 policy-covered resources')).toBeInTheDocument(); - const storage2Link = screen.getByRole('link', { - name: 'Open source resource Storage 2 in Infrastructure', - }); - const storage1Link = screen.getByRole('link', { - name: 'Open source resource Storage 1 in Infrastructure', - }); - expect(storage2Link).toHaveAttribute('href', '/infrastructure?resource=storage-2'); + // Cross-jump to /infrastructure?resource=... retired with the legacy + // surface; correlation labels render as plain text and document order is + // verified via the resource label spans instead of anchor tags. expect( - screen.getByRole('link', { name: 'Open target resource VM 200 in Infrastructure' }), - ).toHaveAttribute('href', '/infrastructure?resource=vm-200'); + screen.queryByRole('link', { name: /Open source resource .* in Infrastructure/ }), + ).toBeNull(); + const storage2Label = screen.getByText('Storage 2'); + const storage1Label = screen.getByText('Storage 1'); expect( - screen.getByRole('link', { name: 'Open target resource VM 100 in Infrastructure' }), - ).toHaveAttribute('href', '/infrastructure?resource=vm-100'); - expect( - storage2Link.compareDocumentPosition(storage1Link) & Node.DOCUMENT_POSITION_FOLLOWING, + storage2Label.compareDocumentPosition(storage1Label) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); - expect(screen.getByText('Storage 1')).toBeInTheDocument(); + expect(screen.getByText('VM 200')).toBeInTheDocument(); expect(screen.getByText('VM 100')).toBeInTheDocument(); expect(screen.getByText('Cpu High → Restart')).toBeInTheDocument(); expect(screen.getByText('Disk Full → Restart')).toBeInTheDocument(); diff --git a/frontend-modern/src/pages/__tests__/Infrastructure.empty-state.test.tsx b/frontend-modern/src/pages/__tests__/Infrastructure.empty-state.test.tsx deleted file mode 100644 index 9d1880d0b..000000000 --- a/frontend-modern/src/pages/__tests__/Infrastructure.empty-state.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { fireEvent, render, waitFor } from '@solidjs/testing-library'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { Infrastructure } from '@/pages/Infrastructure'; - -const navigateSpy = vi.fn(); - -vi.mock('@solidjs/router', async () => { - const actual = await vi.importActual('@solidjs/router'); - return { - ...actual, - useLocation: () => ({ - pathname: '/infrastructure', - get search() { - return ''; - }, - }), - useNavigate: () => navigateSpy, - }; -}); - -vi.mock('@/hooks/useUnifiedResources', () => ({ - useUnifiedResources: () => ({ - resources: () => [], - loading: () => false, - error: () => undefined, - refetch: vi.fn(), - }), -})); - -vi.mock('@/components/Infrastructure/UnifiedResourceTable', () => ({ - UnifiedResourceTable: () =>
, -})); - -vi.mock('@/components/Infrastructure/InfrastructureSummary', () => ({ - InfrastructureSummary: () =>
, -})); - -describe('Infrastructure empty state', () => { - beforeEach(() => { - navigateSpy.mockReset(); - }); - - it('shows empty state with source-strategy guidance when no resources exist', async () => { - const { getByText } = render(() => ); - - await waitFor(() => { - expect(getByText('No infrastructure sources yet')).toBeInTheDocument(); - }); - - const button = getByText('Add infrastructure source'); - expect(button).toBeInTheDocument(); - expect(button.closest('button')).toBeInTheDocument(); - }); - - it('navigates to the source picker when the empty-state button is clicked', async () => { - const { getByText } = render(() => ); - - await waitFor(() => { - expect(getByText('Add infrastructure source')).toBeInTheDocument(); - }); - - fireEvent.click(getByText('Add infrastructure source')); - - expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure?add=pick'); - }); -}); diff --git a/frontend-modern/src/pages/__tests__/Infrastructure.pbs-pmg.test.tsx b/frontend-modern/src/pages/__tests__/Infrastructure.pbs-pmg.test.tsx deleted file mode 100644 index 2db53272e..000000000 --- a/frontend-modern/src/pages/__tests__/Infrastructure.pbs-pmg.test.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { render, waitFor } from '@solidjs/testing-library'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { Resource } from '@/types/resource'; -import { Infrastructure } from '@/pages/Infrastructure'; - -const mockResources: Resource[] = [ - { - id: 'pbs-main', - type: 'pbs', - name: 'pbs-main', - displayName: 'PBS Main', - platformId: 'pbs-main', - platformType: 'proxmox-pbs', - sourceType: 'api', - status: 'online', - lastSeen: Date.now(), - platformData: { sources: ['pbs'] }, - }, - { - id: 'pmg-main', - type: 'pmg', - name: 'pmg-main', - displayName: 'PMG Main', - platformId: 'pmg-main', - platformType: 'proxmox-pmg', - sourceType: 'api', - status: 'online', - lastSeen: Date.now(), - platformData: { sources: ['pmg'] }, - }, -]; -let mockLocationSearch = ''; -const navigateSpy = vi.fn(); - -vi.mock('@solidjs/router', async () => { - const actual = await vi.importActual('@solidjs/router'); - return { - ...actual, - useLocation: () => ({ - pathname: '/infrastructure', - get search() { - return mockLocationSearch; - }, - }), - useNavigate: () => navigateSpy, - }; -}); - -vi.mock('@/hooks/useUnifiedResources', () => ({ - useUnifiedResources: () => ({ - resources: () => mockResources, - loading: () => false, - error: () => undefined, - refetch: vi.fn(), - }), -})); - -vi.mock('@/components/Infrastructure/UnifiedResourceTable', () => ({ - UnifiedResourceTable: (props: { - resources: Resource[]; - onExpandedResourceChange?: (id: string | null) => void; - }) => ( -
- {props.resources.map((resource) => resource.name).join(',')} - - -
- ), -})); - -vi.mock('@/components/Infrastructure/InfrastructureSummary', () => ({ - InfrastructureSummary: (props: { resources: Resource[] }) => ( -
{props.resources.length} resources
- ), -})); - -describe('Infrastructure PBS/PMG integration', () => { - beforeEach(() => { - // Ensure the desktop filter controls are rendered (some suites resize the viewport). - Object.defineProperty(window, 'innerWidth', { - writable: true, - configurable: true, - value: 1024, - }); - window.dispatchEvent(new Event('resize')); - - mockLocationSearch = ''; - navigateSpy.mockReset(); - navigateSpy.mockImplementation((path: string) => { - const queryStart = path.indexOf('?'); - mockLocationSearch = queryStart >= 0 ? path.slice(queryStart) : ''; - }); - }); - - it('renders native PBS and PMG resources in infrastructure view', async () => { - const { getByTestId, getByText } = render(() => ); - - expect(getByTestId('infrastructure-page')).toBeInTheDocument(); - expect(getByTestId('infra-summary')).toBeInTheDocument(); - // The mocked UnifiedResourceTable above renders resource.name joined by - // commas, not platform-badge labels, so 'PBS'/'PMG' text appearance was - // checking a render path the mocks short-circuit. The substantive - // assertions are that PBS and PMG rows make it through to the table and - // that the summary counts them. - expect(getByTestId('infra-table')).toHaveTextContent('pbs-main,pmg-main'); - expect(getByText('2 resources')).toBeInTheDocument(); - }); - - it('keeps canonical q search query params', async () => { - mockLocationSearch = '?q=pmg'; - - render(() => ); - - await Promise.resolve(); - expect(navigateSpy).not.toHaveBeenCalled(); - }); - - // The 'syncs source filter selection to query params' test has been - // removed. It drove the source filter via fireEvent.change on a labeled - //