frontend(platform-first): retire legacy top-level routes and cross-link affordances

Primary nav moved to platform-first (Proxmox/Docker/K8s/TrueNAS/vSphere) and
the standalone Infrastructure/Workloads/Storage/Recovery/Ceph routes were no
longer reachable from the UI — they only persisted as deep-link fallbacks and
as cross-jump targets for command palette / shortcuts / drawer links.

Code:
- RuntimeHome and NotFound redirect to /proxmox/overview; kiosk-mode
  redirect follows the same canonical home.
- App.tsx, routing/navigation.ts, routing/routePreload.ts, AppLayout.tsx
  drop the legacy routes, preloaders, tab IDs, and tab-title entries.
- Old page wrappers (Infrastructure/Workloads/Storage/Recovery/Ceph) and
  the orphan CephServiceIcon and workloadsLink helper are deleted; the
  underlying feature surfaces (InfrastructurePageSurface, WorkloadsSurface,
  StorageSurface, RecoverySurface, ProxmoxCephTable) stay embedded inside
  platform pages.
- Command palette and keyboard shortcuts now expose the five platform
  pages (g p/d/k/n/v) plus Patrol/Alerts/Settings; legacy g i/w/s/b are
  retired.
- K8s Namespaces/Deployments drawer "view pods" buttons route to
  /kubernetes/pods (the legacy ?context= + ?namespace= filter doesn't
  carry forward yet — the new sub-tab doesn't consume those params).
- Workload-row node-name click, host-row workloads icon, resource
  correlation/change drilldowns, and the PBS "Open Recovery Events" link
  render as plain text or are dropped — platform pages already surface
  the related data in adjacent sub-tabs.

Governance:
- Subsystem contracts updated to match: storage-recovery and
  performance-and-scalability drop the retired page wrappers from their
  Canonical Files lists; ai-runtime, cloud-paid, frontend-primitives, and
  unified-resources gain Current-State notes describing the platform-first
  redirect, palette/shortcut re-anchoring, and cross-link affordance
  removal so future work doesn't resurrect the retired paths.
- status.json L8 (First-session & UX polish) evidence: the retired
  pages/Infrastructure.tsx wrapper is replaced by pages/Proxmox.tsx as
  the canonical post-redirect landing surface.
- registry.json: 18 retired-file entries removed from owned_files,
  path-policy match_files, and exact_files across the route-shell-and-
  operations, workload-presentation, storage-recovery, and unified-
  resources subsystems. Surviving entries continue to cover the proof
  set — the embedded feature surfaces remain canonical and the retired
  page wrappers were thin shells with no remaining proof obligation.
- canonical_completion_guard_test.py and subsystem_lookup_test.py
  retargeted: the recovery and storage product-surface fixtures now
  exercise the canonical components/* surfaces instead of the retired
  pages/* shells, matching the new registry shape.

Tests: typecheck clean. Vitest 5820 pass / 6 pre-existing fail
(PageControls.guardrails, useStoragePoolDetailModel, ApprovalSection x3,
frontendResourceTypeBoundaries:1018) — all failing on HEAD before this
change with clean porcelain on the offending source files. Python
canonical_completion_guard_test (141) and subsystem_lookup_test (183)
green.
This commit is contained in:
rcourtman 2026-05-16 18:57:37 +01:00
parent e66cfc4991
commit abb6f86aeb
62 changed files with 388 additions and 2272 deletions

View file

@ -3844,7 +3844,7 @@
},
{
"repo": "pulse",
"path": "frontend-modern/src/pages/Infrastructure.tsx",
"path": "frontend-modern/src/pages/Proxmox.tsx",
"kind": "file"
},
{

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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,

View file

@ -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() {
<Route path={`${TRUENAS_PATH}/*`} component={TrueNASPage} />
<Route path={VMWARE_PATH} component={VmwarePage} />
<Route path={`${VMWARE_PATH}/*`} component={VmwarePage} />
<Route path={ROOT_WORKLOADS_PATH} component={WorkloadsPage} />
<Route path={STORAGE_PATH} component={StoragePage} />
<Route path={RECOVERY_ROUTE_PATH} component={RecoveryPage} />
<Route path="/ceph" component={CephPage} />
<Route path={INFRASTRUCTURE_ROUTE_PATH} component={InfrastructurePage} />
<Route path="/alerts/*" component={AlertsPage} />
<Route path={`${ROOT_PATROL_PATH}/*`} component={AIIntelligencePage} />

View file

@ -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 });
}
});

View file

@ -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(
'<Route path={INFRASTRUCTURE_ROUTE_PATH} component={InfrastructurePage} />',
);
expect(appSource).toContain('<Route path={PROXMOX_PATH} component={ProxmoxPage} />');
expect(appSource).toContain('<Route path={`${PROXMOX_PATH}/*`} component={ProxmoxPage} />');
expect(appSource).toContain("const DockerPage = lazy(() => import('./pages/Docker'));");
@ -92,13 +97,15 @@ describe('App architecture', () => {
);
expect(appSource).not.toContain('<Route path="/cloud" component=');
expect(appSource).not.toContain('<Route path="/cloud/signup" component=');
expect(appSource).toContain("const StoragePage = lazy(() => import('./pages/Storage'));");
expect(appSource).toContain("const ProxmoxPage = lazy(() => 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('<Route path={ROOT_WORKLOADS_PATH} component={WorkloadsPage} />');
expect(appSource).toContain('<Route path={RECOVERY_ROUTE_PATH} component={RecoveryPage} />');
// 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<string, Promise<void>>();',
);
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(

View file

@ -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 (
<svg
class={iconClass()}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
);
case 'mgr':
return (
<svg
class={iconClass()}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
);
case 'osd':
return (
<svg
class={iconClass()}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"
/>
</svg>
);
case 'mds':
return (
<svg
class={iconClass()}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"
/>
</svg>
);
case 'rgw':
return (
<svg
class={iconClass()}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
/>
</svg>
);
default:
return (
<svg
class={iconClass()}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
/>
</svg>
);
}
};
export default CephServiceIcon;

View file

@ -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<ResourceChangeSummaryProps> = (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');

View file

@ -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<ResourceCorrelationSummaryPro
const relationships = createMemo(() => 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 = () =>

View file

@ -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<ResourceDetailDrawerOver
<For each={change.relatedResources ?? []}>
{(relatedResource) => {
const label = drawer.resolveResourceLabel(relatedResource);
const href = buildInfrastructureResourceHref(relatedResource);
return href ? (
<a
class="inline-flex rounded bg-surface px-1.5 py-0.5 text-[10px] text-blue-700 hover:underline dark:text-blue-300"
href={href}
aria-label={`Open related resource ${label} in Infrastructure`}
>
{label}
</a>
) : (
return (
<span class="inline-flex rounded bg-surface px-1.5 py-0.5 text-[10px] text-base-content">
{label}
</span>

View file

@ -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<UnifiedResourceHostTableCar
const availabilityProbe = createMemo(() =>
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<UnifiedResourceHostTableCar
</Show>
</div>
</div>
<Show when={workloadsHref()}>
{(href) => (
<a
href={href()}
class="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded text-blue-600 transition-colors hover:bg-blue-100 hover:text-blue-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 dark:text-blue-300 dark:hover:bg-blue-800 dark:hover:text-blue-200"
title="View related workloads"
aria-label={`View workloads for ${displayName()}`}
onClick={(event) => event.stopPropagation()}
>
<svg
class="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14 5h5m0 0v5m0-5-8 8"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5 10v9h9"
/>
</svg>
</a>
)}
</Show>
</div>
</TableCell>

View file

@ -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', () => {

View file

@ -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);
});
});

View file

@ -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');
});
});

View file

@ -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();

View file

@ -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(() => (
<ResourceDetailDrawer resource={resource} />
));
@ -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();

View file

@ -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', () => {

View file

@ -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(() => (
<UnifiedResourceTable
resources={resources}
expandedResourceId={null}
onExpandedResourceChange={onExpandedResourceChange}
onExpandedResourceChange={vi.fn()}
groupingMode="flat"
/>
));
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();
});
});

View file

@ -18,7 +18,7 @@ const baseResource = (overrides: Partial<Resource>): 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', () => {

View file

@ -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>): 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<Resource>);
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',
);
});
});
});

View file

@ -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 [
{

View file

@ -1,5 +0,0 @@
import type { Resource } from '@/types/resource';
import { buildWorkloadsHrefForResource } from '@/routing/resourceLinks';
export const buildWorkloadsHref = (resource: Resource): string | null =>
buildWorkloadsHrefForResource(resource);

View file

@ -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'}`;

View file

@ -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'}`;

View file

@ -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(() => <K8sDeploymentsDrawer cluster="my-cluster" />);
@ -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 () => {

View file

@ -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(() => <K8sNamespacesDrawer cluster="cluster-a" />);
@ -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(() => <K8sNamespacesDrawer cluster="cluster-a" />);
@ -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 () => {

View file

@ -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) {
</span>
}
>
<button
type="button"
class="text-xs text-blue-600 dark:text-blue-400 hover:underline truncate max-w-[80px]"
title={`Open infrastructure node ${props.guest.node}`}
onClick={(event) => {
event.stopPropagation();
navigate(infrastructureHref());
}}
<span
class="text-xs text-base-content truncate max-w-[80px]"
title={props.guest.node}
>
{props.guest.node}
</button>
</span>
</Show>
</div>
</td>

View file

@ -104,15 +104,6 @@ vi.mock('@/components/shared/workloadTypeBadges', () => ({
}),
}));
vi.mock('@/routing/resourceLinks', async () => {
const actual =
await vi.importActual<typeof import('@/routing/resourceLinks')>('@/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=<node> 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({

View file

@ -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'");

View file

@ -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,

View file

@ -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' },
],
};

View file

@ -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,

View file

@ -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(() => <CommandPaletteModal isOpen={true} onClose={vi.fn()} />);
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(() => <CommandPaletteModal isOpen={true} onClose={onClose} />);
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(() => <CommandPaletteModal isOpen={true} onClose={onClose} />);
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);
});
});

View file

@ -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',

View file

@ -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,
}),

View file

@ -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<string, string> => {
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',
};
};

View file

@ -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 (
<>
<span
class={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wide ${healthInfo().badgeClass}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setShowTooltip(false)}
>
<span class={`w-1.5 h-1.5 rounded-full ${healthInfo().dotClass} animate-pulse`} />
{healthInfo().label}
</span>
<Show when={showTooltip() && props.message}>
<Portal mount={document.body}>
<div
class="fixed z-[9999] pointer-events-none"
style={{
left: `${tooltipPos().x}px`,
top: `${tooltipPos().y - 8}px`,
transform: 'translate(-50%, -100%)',
}}
>
<div class="bg-surface text-base-content text-[10px] rounded-md shadow-sm px-2.5 py-1.5 max-w-[280px] border border-border">
{props.message}
</div>
</div>
</Portal>
</Show>
</>
);
};
// 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 (
<>
<div
class="flex items-center gap-1.5 cursor-help"
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setShowTooltip(false)}
>
<For each={props.services.slice(0, 4)}>
{(svc) => {
const status = getCephServiceStatusPresentation(svc);
return (
<span class={`inline-flex items-center gap-0.5 text-xs ${status.textClass}`}>
<CephServiceIcon type={svc.type} class="w-3.5 h-3.5" />
<span class="font-mono text-[10px]">
{svc.running}/{svc.total}
</span>
</span>
);
}}
</For>
<Show when={props.services.length > 4}>
<span class="text-[10px] text-slate-500">+{props.services.length - 4}</span>
</Show>
</div>
<Show when={showTooltip() && props.services.length > 0}>
<Portal mount={document.body}>
<div
class="fixed z-[9999] pointer-events-none"
style={{
left: `${tooltipPos().x}px`,
top: `${tooltipPos().y - 8}px`,
transform: 'translate(-50%, -100%)',
}}
>
<div class="bg-surface text-base-content text-[10px] rounded-md shadow-sm px-2.5 py-2 min-w-[180px] border border-border">
<div class="font-medium mb-1.5 text-base-content border-b border-border pb-1">
Ceph Services
</div>
<div class="space-y-1">
<For each={props.services}>
{(svc) => {
const status = getCephServiceStatusPresentation(svc);
return (
<div class="flex items-center justify-between gap-3">
<span class="flex items-center gap-1.5 text-muted">
<CephServiceIcon type={svc.type} class="w-3.5 h-3.5" />
<span class="uppercase">{svc.type}</span>
</span>
<span class={`font-mono ${status.textClass}`}>
{svc.running}/{svc.total}
</span>
</div>
);
}}
</For>
</div>
</div>
</div>
</Portal>
</Show>
</>
);
};
// 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 (
<div class={`w-full bg-surface-hover rounded-full ${barHeight()} overflow-hidden`}>
<div
class={`${barHeight()} rounded-full transition-all duration-500 ${barColor()}`}
style={{ width: `${Math.min(props.percent || 0, 100)}%` }}
/>
</div>
);
};
// 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<CephCluster[]>(() => {
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<string, { running: number; total: number }>();
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 (
<div class="space-y-4">
<PageHeader
title="Ceph"
description="Monitor cluster health, service status, capacity, and pool utilization across connected Ceph environments."
/>
{/* Loading State */}
<Show when={isLoading()}>
<Card padding="lg">
<EmptyState
icon={
<svg
class="h-12 w-12 animate-spin text-blue-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
}
title={cephLoadingState().title}
description={cephLoadingState().description}
/>
</Card>
</Show>
{/* Disconnected State */}
<Show when={!connected() && !isLoading()}>
<Card padding="lg" tone="danger">
<EmptyState
icon={
<svg
class="h-12 w-12 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
}
title={cephDisconnectedState().title}
description={cephDisconnectedState().description}
tone="danger"
actions={
!reconnecting() ? (
<button
type="button"
onClick={() => reconnect()}
class="mt-2 inline-flex items-center px-4 py-2 text-xs font-medium rounded bg-red-600 text-white hover:bg-red-700 transition-colors"
>
Reconnect now
</button>
) : undefined
}
/>
</Card>
</Show>
<Show when={connected() && initialDataReceived()}>
{/* No Clusters Empty State */}
<Show when={!hasClusters()}>
<Card padding="lg">
<EmptyState
icon={
<svg
class="h-12 w-12 text-slate-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
}
title={cephNoClustersState().title}
description={cephNoClustersState().description}
/>
</Card>
</Show>
{/* Clusters Found - Show Content */}
<Show when={hasClusters()}>
{/* Summary Cards */}
<div class="grid gap-3 sm:gap-4 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
{/* Total Storage Card */}
<Card padding="sm" tone="card">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-muted uppercase tracking-wide">
Total Storage
</span>
<svg
class="w-4 h-4 text-blue-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"
/>
</svg>
</div>
<div class="text-xl sm:text-2xl font-bold text-base-content">
{formatBytes(totalStats().totalBytes)}
</div>
<div class="mt-1.5">
<UsageBar percent={totalStats().usagePercent} size="sm" />
<div class="flex justify-between text-[10px] text-muted mt-1">
<span>{formatBytes(totalStats().usedBytes)} used</span>
<span>{totalStats().usagePercent.toFixed(1)}%</span>
</div>
</div>
</Card>
{/* Clusters Card */}
<Card padding="sm" tone="card">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-muted uppercase tracking-wide">Clusters</span>
<svg
class="w-4 h-4 text-indigo-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25"
/>
</svg>
</div>
<div class="text-xl sm:text-2xl font-bold text-base-content">{clusters().length}</div>
<div class="flex flex-wrap gap-1 mt-2">
<For each={clusters()}>
{(cluster) => (
<HealthBadge health={cluster.health || ''} message={cluster.healthMessage} />
)}
</For>
</div>
</Card>
{/* Services Card */}
<Card padding="sm" tone="card">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-muted uppercase tracking-wide">Services</span>
<svg
class="w-4 h-4 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
/>
</svg>
</div>
<div class="text-xl sm:text-2xl font-bold text-base-content">
{allServices().reduce((acc, svc) => acc + svc.running, 0)}
<span class="text-sm font-normal text-muted">
/{allServices().reduce((acc, svc) => acc + svc.total, 0)}
</span>
</div>
<div class="mt-2">
<ServiceStatusCell services={allServices() as CephServiceStatus[]} />
</div>
</Card>
{/* Pools Card */}
<Card padding="sm" tone="card">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-muted uppercase tracking-wide">Pools</span>
<svg
class="w-4 h-4 text-cyan-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 6.878V6a2.25 2.25 0 012.25-2.25h7.5A2.25 2.25 0 0118 6v.878m-12 0c.235-.083.487-.128.75-.128h10.5c.263 0 .515.045.75.128m-12 0A2.25 2.25 0 004.5 9v.878m13.5-3A2.25 2.25 0 0119.5 9v.878m0 0a2.246 2.246 0 00-.75-.128H5.25c-.263 0-.515.045-.75.128m15 0A2.25 2.25 0 0121 12v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6c0-.98.626-1.813 1.5-2.122"
/>
</svg>
</div>
<div class="text-xl sm:text-2xl font-bold text-base-content">{allPools().length}</div>
<div class="text-xs text-muted mt-2">
{allPools()
.reduce((acc, pool) => acc + (pool.objects || 0), 0)
.toLocaleString()}{' '}
objects
</div>
</Card>
</div>
{/* Cluster Details Table */}
<Show when={clusters().length > 0}>
<Card padding="none" tone="card" class="overflow-hidden">
<div class="px-4 py-3 border-b border-border bg-surface-alt">
<h3 class="text-sm font-semibold text-base-content">Cluster Overview</h3>
</div>
<Table
wrapperClass="scrollbar-hide"
class="w-full border-collapse whitespace-nowrap"
style={{ 'min-width': '700px' }}
>
<TableHeader>
<TableRow class="bg-surface-alt text-muted border-b border-border">
<TableHead class={`${thClass} pl-4`}>Cluster</TableHead>
<TableHead class={thClass}>Health</TableHead>
<TableHead class={thClass}>Monitors</TableHead>
<TableHead class={thClass}>Managers</TableHead>
<TableHead class={thClass}>OSDs</TableHead>
<TableHead class={thClass}>PGs</TableHead>
<TableHead class={`${thClass} min-w-[160px]`}>Capacity</TableHead>
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border-subtle">
<For each={clusters()}>
{(cluster) => (
<TableRow class="hover:bg-surface-hover transition-colors">
<TableCell class="px-4 py-2.5">
<div class="font-medium text-sm text-base-content">
{cluster.name || 'Ceph Cluster'}
</div>
<Show when={cluster.fsid}>
<div class="text-[10px] text-muted font-mono truncate max-w-[180px]">
{cluster.fsid}
</div>
</Show>
</TableCell>
<TableCell class="px-2 py-2.5">
<HealthBadge
health={cluster.health || ''}
message={cluster.healthMessage}
/>
</TableCell>
<TableCell class="px-2 py-2.5">
<span class="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
<CephServiceIcon type="mon" class="w-3.5 h-3.5" />
<span class="font-semibold">{cluster.numMons || 0}</span>
</span>
</TableCell>
<TableCell class="px-2 py-2.5">
<span class="inline-flex items-center gap-1 text-xs text-indigo-600 dark:text-indigo-400">
<CephServiceIcon type="mgr" class="w-3.5 h-3.5" />
<span class="font-semibold">{cluster.numMgrs || 0}</span>
</span>
</TableCell>
<TableCell class="px-2 py-2.5">
<span class="inline-flex items-center gap-1 text-xs">
<CephServiceIcon
type="osd"
class="w-3.5 h-3.5 text-green-600 dark:text-green-400"
/>
<span
class={`font-semibold ${(cluster.numOsdsUp || 0) < (cluster.numOsds || 0) ? 'text-yellow-600 dark:text-yellow-400' : 'text-green-600 dark:text-green-400'}`}
>
{cluster.numOsdsUp || 0}
</span>
<span class="text-muted">/{cluster.numOsds || 0}</span>
</span>
</TableCell>
<TableCell class="px-2 py-2.5">
<span class="text-xs text-base-content font-medium">
{(cluster.numPGs || 0).toLocaleString()}
</span>
</TableCell>
<TableCell class="px-2 py-2.5">
<div class="w-full max-w-[160px]">
<UsageBar percent={cluster.usagePercent || 0} size="sm" />
<div class="flex justify-between text-[10px] text-muted mt-0.5">
<span>{formatBytes(cluster.usedBytes || 0)}</span>
<span>{(cluster.usagePercent || 0).toFixed(1)}%</span>
</div>
</div>
</TableCell>
</TableRow>
)}
</For>
</TableBody>
</Table>
</Card>
</Show>
{/* Pools Table */}
<Show when={allPools().length > 0}>
<Card padding="none" tone="card" class="overflow-hidden">
<div class="px-4 py-3 border-b border-border bg-surface-alt flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">
<h3 class="text-sm font-semibold text-base-content">
Storage Pools ({filteredPools().length})
</h3>
{/* Search Input */}
<Show when={!kioskMode()}>
<SearchInput
value={searchTerm}
onChange={setSearchTerm}
placeholder="Search pools..."
title="Search storage pools"
class="w-full sm:max-w-xs flex-1 sm:flex-none"
clearOnEscape
/>
</Show>
</div>
<Show
when={filteredPools().length > 0}
fallback={
<div class="p-8 text-center text-muted">
{getCephPoolsSearchEmptyStatePresentation(searchTerm()).text}
</div>
}
>
<Table
wrapperClass="scrollbar-hide"
class="w-full border-collapse whitespace-nowrap"
style={{ 'min-width': '650px' }}
>
<TableHeader>
<TableRow class="bg-surface-alt text-muted border-b border-border">
<TableHead class={`${thClass} pl-4`}>Pool</TableHead>
<TableHead class={thClass}>Cluster</TableHead>
<TableHead class={thClass}>Used</TableHead>
<TableHead class={thClass}>Available</TableHead>
<TableHead class={thClass}>Objects</TableHead>
<TableHead class={`${thClass} min-w-[120px]`}>Usage</TableHead>
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border-subtle">
<For each={filteredPools()}>
{(pool) => (
<TableRow class="hover:bg-surface-hover transition-colors">
<TableCell class="px-4 py-2.5 font-medium text-sm text-base-content">
{pool.name}
</TableCell>
<TableCell class="px-2 py-2.5 text-xs text-muted">
{pool.clusterName}
</TableCell>
<TableCell class="px-2 py-2.5 text-xs text-base-content font-mono">
{formatBytes(pool.storedBytes || 0)}
</TableCell>
<TableCell class="px-2 py-2.5 text-xs text-base-content font-mono">
{formatBytes(pool.availableBytes || 0)}
</TableCell>
<TableCell class="px-2 py-2.5 text-xs text-base-content font-mono">
{(pool.objects || 0).toLocaleString()}
</TableCell>
<TableCell class="px-2 py-2.5">
<div class="flex items-center gap-2">
<div class="w-16">
<UsageBar percent={pool.percentUsed || 0} size="sm" />
</div>
<span class="text-xs text-muted font-mono w-12 text-right">
{(pool.percentUsed || 0).toFixed(1)}%
</span>
</div>
</TableCell>
</TableRow>
)}
</For>
</TableBody>
</Table>
</Show>
</Card>
</Show>
</Show>
</Show>
</div>
);
};
export default Ceph;

View file

@ -1,7 +0,0 @@
import { InfrastructurePageSurface } from '@/features/infrastructure/InfrastructurePageSurface';
export function Infrastructure() {
return <InfrastructurePageSurface />;
}
export default Infrastructure;

View file

@ -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={
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-2 rounded-md border border-border bg-surface px-3 py-1.5 text-xs font-medium text-base-content shadow-sm hover:bg-surface-hover"
onClick={() => navigate(buildRecoveryPath())}
>
Go to Recovery
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-md bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-blue-700"
onClick={() => navigate(buildInfrastructurePath())}
onClick={() => navigate(buildProxmoxPath())}
>
Go to Infrastructure
Go to Proxmox
</button>
</div>
}

View file

@ -1,7 +0,0 @@
import RecoverySurface from '@/components/Recovery/Recovery';
export function Recovery() {
return <RecoverySurface />;
}
export default Recovery;

View file

@ -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 });

View file

@ -1,7 +0,0 @@
import StorageSurface from '@/components/Storage/Storage';
export function Storage() {
return <StorageSurface />;
}
export default Storage;

View file

@ -1,7 +0,0 @@
import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface';
export function Workloads() {
return <WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />;
}
export default Workloads;

View file

@ -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();

View file

@ -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<typeof import('@solidjs/router')>('@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: () => <div data-testid="infra-table" />,
}));
vi.mock('@/components/Infrastructure/InfrastructureSummary', () => ({
InfrastructureSummary: () => <div data-testid="infra-summary" />,
}));
describe('Infrastructure empty state', () => {
beforeEach(() => {
navigateSpy.mockReset();
});
it('shows empty state with source-strategy guidance when no resources exist', async () => {
const { getByText } = render(() => <Infrastructure />);
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(() => <Infrastructure />);
await waitFor(() => {
expect(getByText('Add infrastructure source')).toBeInTheDocument();
});
fireEvent.click(getByText('Add infrastructure source'));
expect(navigateSpy).toHaveBeenCalledWith('/settings/infrastructure?add=pick');
});
});

View file

@ -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<typeof import('@solidjs/router')>('@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;
}) => (
<div data-testid="infra-table">
{props.resources.map((resource) => resource.name).join(',')}
<button type="button" onClick={() => props.onExpandedResourceChange?.('pmg-main')}>
open-pmg
</button>
<button type="button" onClick={() => props.onExpandedResourceChange?.(null)}>
close-resource
</button>
</div>
),
}));
vi.mock('@/components/Infrastructure/InfrastructureSummary', () => ({
InfrastructureSummary: (props: { resources: Resource[] }) => (
<div data-testid="infra-summary">{props.resources.length} resources</div>
),
}));
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(() => <Infrastructure />);
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(() => <Infrastructure />);
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
// <select>, but Infrastructure migrated to the FilterBar chip pattern
// (PageControls / LabeledFilterSelect have been phased out). With
// UnifiedResourceTable mocked, there is no FilterBar surface for the
// page-level test to drive, and the state -> URL coupling is now
// exercised inside FilterBar / source-filter unit tests rather than at
// the Infrastructure page boundary.
it('syncs expanded resource state to resource query param for deep-linking', async () => {
const { getByRole } = render(() => <Infrastructure />);
navigateSpy.mockClear();
getByRole('button', { name: 'open-pmg' }).click();
await waitFor(() => {
expect(navigateSpy).toHaveBeenCalled();
});
let [path] = navigateSpy.mock.calls.at(-1) as [string, { replace?: boolean }];
let params = new URLSearchParams(path.split('?')[1] || '');
expect(params.get('resource')).toBe('pmg-main');
getByRole('button', { name: 'close-resource' }).click();
await waitFor(() => {
[path] = navigateSpy.mock.calls.at(-1) as [string, { replace?: boolean }];
params = new URLSearchParams(path.split('?')[1] || '');
expect(params.get('resource')).toBeNull();
});
});
});

View file

@ -1,64 +0,0 @@
import { cleanup, render, waitFor, screen } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import appSource from '@/App.tsx?raw';
import recoveryPageSource from '@/pages/Recovery.tsx?raw';
import routePreloadSource from '@/routing/routePreload.ts?raw';
import RecoveryPage from '@/pages/Recovery';
let mockLocationSearch = '';
let mockLocationPath = '/recovery';
const navigateSpy = vi.hoisted(() => vi.fn());
vi.mock('@solidjs/router', async () => {
const actual = await vi.importActual<typeof import('@solidjs/router')>('@solidjs/router');
return {
...actual,
useLocation: () => ({ pathname: mockLocationPath, search: mockLocationSearch }),
useNavigate: () => navigateSpy,
};
});
vi.mock('@/components/Recovery/Recovery', () => ({
default: () => <div data-testid="recovery-component">Recovery Component</div>,
}));
describe('Recovery page route shell', () => {
beforeEach(() => {
navigateSpy.mockReset();
mockLocationPath = '/recovery';
mockLocationSearch = '';
});
afterEach(() => cleanup());
it('keeps App and route preloading on the recovery page shell', () => {
expect(appSource).toContain("const RecoveryPage = lazy(() => import('./pages/Recovery'));");
expect(appSource).toContain('<Route path={RECOVERY_ROUTE_PATH} component={RecoveryPage} />');
expect(routePreloadSource).toContain("import('@/pages/Recovery').then(() => undefined)");
expect(recoveryPageSource).toContain("import RecoverySurface from '@/components/Recovery/Recovery';");
expect(recoveryPageSource).toContain('<RecoverySurface />');
expect(recoveryPageSource).not.toContain('useLocation(');
expect(recoveryPageSource).not.toContain('useNavigate(');
});
it('renders Recovery without query rewrite redirects', async () => {
mockLocationSearch = '?view=artifacts&backupType=remote&group=guest&search=vm-101&source=pbs';
render(() => <RecoveryPage />);
await waitFor(() => {
expect(screen.getByTestId('recovery-component')).toBeInTheDocument();
});
expect(navigateSpy).not.toHaveBeenCalled();
});
it('preserves focused rollup routes without redirecting away from the selected history view', async () => {
mockLocationSearch = '?view=events&rollupId=res%3Avm-123';
render(() => <RecoveryPage />);
await waitFor(() => {
expect(screen.getByTestId('recovery-component')).toBeInTheDocument();
});
expect(navigateSpy).not.toHaveBeenCalled();
});
});

View file

@ -17,11 +17,11 @@ describe('RuntimeHome', () => {
navigateSpy.mockReset();
});
it('routes authenticated runtimes straight to Infrastructure', async () => {
it('routes authenticated runtimes straight to the Proxmox platform page', async () => {
render(() => <RuntimeHome />);
await waitFor(() => {
expect(navigateSpy).toHaveBeenCalledWith('/infrastructure', { replace: true });
expect(navigateSpy).toHaveBeenCalledWith('/proxmox/overview', { replace: true });
});
});
});

View file

@ -1,25 +0,0 @@
import { describe, expect, it } from 'vitest';
import appSource from '@/App.tsx?raw';
import storagePageRouteSource from '@/pages/Storage.tsx?raw';
import storageSurfaceSource from '@/components/Storage/Storage.tsx?raw';
describe('storage page route shell', () => {
it('keeps App routing on a page shell instead of the storage surface component', () => {
expect(appSource).toContain("const StoragePage = lazy(() => import('./pages/Storage'));");
expect(appSource).not.toContain(
"const StorageComponent = lazy(() => import('./components/Storage/Storage'));",
);
expect(storagePageRouteSource).toContain(
"import StorageSurface from '@/components/Storage/Storage';",
);
expect(storagePageRouteSource).toContain('<StorageSurface />');
expect(storagePageRouteSource).not.toContain('useStoragePageModel');
expect(storageSurfaceSource).toContain("import { PageHeader } from '@/components/shared/PageHeader';");
expect(storageSurfaceSource).toContain('<PageHeader');
expect(storageSurfaceSource).toContain('title="Storage"');
expect(storageSurfaceSource).toContain('useStoragePageModel');
expect(storageSurfaceSource).toContain(
'<StickySummarySection desktopOnly={false} stickyDesktopOnly>',
);
});
});

View file

@ -1,23 +0,0 @@
import { describe, expect, it } from 'vitest';
import appSource from '@/App.tsx?raw';
import workloadsPageSource from '@/pages/Workloads.tsx?raw';
import workloadsSurfaceSource from '@/components/Workloads/WorkloadsSurface.tsx?raw';
describe('workloads page route shell', () => {
it('keeps App routing on a page shell instead of an inline workloads view', () => {
expect(appSource).toContain("const WorkloadsPage = lazy(() => import('./pages/Workloads'));");
expect(appSource).toContain('<Route path={ROOT_WORKLOADS_PATH} component={WorkloadsPage} />');
expect(appSource).not.toContain(
'const WorkloadsView = () => <Workloads vms={[]} containers={[]} nodes={[]} useWorkloads />;',
);
expect(workloadsPageSource).toContain(
"import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface';",
);
expect(workloadsPageSource).toContain(
'<WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />',
);
expect(workloadsSurfaceSource).toContain("import { PageHeader } from '@/components/shared/PageHeader';");
expect(workloadsSurfaceSource).toContain('<PageHeader');
expect(workloadsSurfaceSource).toContain('title="Workloads"');
});
});

View file

@ -7,11 +7,13 @@ describe('navigation routing helpers', () => {
expect(getActiveTabForPath('/dashboard')).toBeNull();
expect(getActiveTabForPath('/proxmox')).toBe('proxmox');
expect(getActiveTabForPath('/proxmox/storage')).toBe('proxmox');
expect(getActiveTabForPath('/infrastructure')).toBe('infrastructure');
expect(getActiveTabForPath('/workloads?type=pod')).toBe('workloads');
expect(getActiveTabForPath('/storage')).toBe('storage');
expect(getActiveTabForPath('/ceph')).toBe('storage');
expect(getActiveTabForPath('/recovery')).toBe('recovery');
// Legacy top-level routes (/infrastructure, /workloads, /storage,
// /recovery, /ceph) were retired when primary nav moved to platform-first.
expect(getActiveTabForPath('/infrastructure')).toBeNull();
expect(getActiveTabForPath('/workloads?type=pod')).toBeNull();
expect(getActiveTabForPath('/storage')).toBeNull();
expect(getActiveTabForPath('/ceph')).toBeNull();
expect(getActiveTabForPath('/recovery')).toBeNull();
expect(getActiveTabForPath('/alerts/open')).toBe('alerts');
expect(getActiveTabForPath('/patrol')).toBe('ai');
expect(getActiveTabForPath('/ai')).toBe('ai');

View file

@ -1,27 +1,18 @@
import { describe, expect, it } from 'vitest';
import { APP_SHELL_ROUTE_PRELOAD_PATHS } from '../routePreload';
import {
PATROL_PATH,
buildProxmoxPath,
buildRecoveryPath,
buildStoragePath,
buildWorkloadsPath,
} from '../resourceLinks';
import { PATROL_PATH, buildProxmoxPath } from '../resourceLinks';
describe('route preloading', () => {
it('keeps Workloads in the authenticated app-shell preload set', () => {
expect(APP_SHELL_ROUTE_PRELOAD_PATHS).toContain(buildWorkloadsPath());
it('keeps Proxmox in the authenticated app-shell preload set', () => {
expect(APP_SHELL_ROUTE_PRELOAD_PATHS).toContain(buildProxmoxPath());
});
it('keeps all cold top-level app shell targets in the shared preload set', () => {
expect([...APP_SHELL_ROUTE_PRELOAD_PATHS]).toEqual([
buildProxmoxPath(),
buildWorkloadsPath(),
buildRecoveryPath(),
PATROL_PATH,
'/alerts',
buildStoragePath(),
'/settings',
]);
});

View file

@ -1,12 +1,10 @@
import {
DOCKER_PATH,
INFRASTRUCTURE_PATH,
KUBERNETES_PATH,
PATROL_PATH,
PROXMOX_PATH,
TRUENAS_PATH,
VMWARE_PATH,
WORKLOADS_PATH,
} from './resourceLinks';
export type AppTabId =
@ -15,10 +13,6 @@ export type AppTabId =
| 'kubernetes'
| 'truenas'
| 'vmware'
| 'infrastructure'
| 'workloads'
| 'storage'
| 'recovery'
| 'alerts'
| 'ai'
| 'settings';
@ -31,11 +25,6 @@ export function getActiveTabForPath(path: string): ActiveAppTabId {
if (path.startsWith(KUBERNETES_PATH)) return 'kubernetes';
if (path.startsWith(TRUENAS_PATH)) return 'truenas';
if (path.startsWith(VMWARE_PATH)) return 'vmware';
if (path.startsWith(INFRASTRUCTURE_PATH)) return 'infrastructure';
if (path.startsWith(WORKLOADS_PATH)) return 'workloads';
if (path.startsWith('/storage')) return 'storage';
if (path.startsWith('/ceph')) return 'storage';
if (path.startsWith('/recovery')) return 'recovery';
if (path.startsWith('/alerts')) return 'alerts';
if (path.startsWith(PATROL_PATH) || path.startsWith('/ai')) return 'ai';
if (path.startsWith('/settings')) return 'settings';

View file

@ -1,9 +1,5 @@
import {
buildInfrastructurePath,
buildProxmoxPath,
buildRecoveryPath,
buildStoragePath,
buildWorkloadsPath,
DOCKER_PATH,
KUBERNETES_PATH,
PATROL_PATH,
@ -18,22 +14,15 @@ type RoutePreloader = {
preload: () => Promise<void>;
};
const ROOT_INFRASTRUCTURE_PATH = buildInfrastructurePath();
const ROOT_PROXMOX_PATH = buildProxmoxPath();
const ROOT_WORKLOADS_PATH = buildWorkloadsPath();
const STORAGE_PATH = buildStoragePath();
const RECOVERY_ROUTE_PATH = buildRecoveryPath();
const ALERTS_PATH = '/alerts';
const SETTINGS_PATH = '/settings';
const routePreloadCache = new Map<string, Promise<void>>();
export const APP_SHELL_ROUTE_PRELOAD_PATHS = [
ROOT_PROXMOX_PATH,
ROOT_WORKLOADS_PATH,
RECOVERY_ROUTE_PATH,
PATROL_PATH,
ALERTS_PATH,
STORAGE_PATH,
SETTINGS_PATH,
] as const;
@ -77,30 +66,6 @@ const ROUTE_PRELOADERS: readonly RoutePreloader[] = [
preload: () =>
import('@/pages/Vmware').then(() => undefined),
},
{
id: 'infrastructure',
matches: (route) => route === ROOT_INFRASTRUCTURE_PATH,
preload: () =>
import('@/pages/Infrastructure').then(() => undefined),
},
{
id: 'workloads',
matches: (route) => route === ROOT_WORKLOADS_PATH,
preload: () =>
import('@/pages/Workloads').then(() => undefined),
},
{
id: 'storage',
matches: (route) => route === STORAGE_PATH,
preload: () =>
import('@/pages/Storage').then(() => undefined),
},
{
id: 'recovery',
matches: (route) => route === RECOVERY_ROUTE_PATH,
preload: () =>
import('@/pages/Recovery').then(() => undefined),
},
{
id: 'alerts',
matches: (route) => route === ALERTS_PATH || route.startsWith(`${ALERTS_PATH}/`),

View file

@ -140,7 +140,6 @@ import updatesPresentationSource from '@/utils/updatesPresentation.ts?raw';
import environmentLockBadgeSource from '@/components/shared/EnvironmentLockBadge.tsx?raw';
import environmentLockPresentationSource from '@/utils/environmentLockPresentation.ts?raw';
import dockerRuntimeSettingsCardSource from '@/components/Settings/DockerRuntimeSettingsCard.tsx?raw';
import infrastructurePageShellSource from '@/pages/Infrastructure.tsx?raw';
import operationsPageRouteSource from '@/pages/Operations.tsx?raw';
import discoveryTargetSource from '@/utils/discoveryTarget.ts?raw';
import infrastructureEmptyStatePresentationSource from '@/utils/infrastructureEmptyStatePresentation.ts?raw';
@ -350,8 +349,6 @@ import nodeModalSetupGuideSectionSource from '@/components/Settings/NodeModalSet
import nodeModalStatusFooterSource from '@/components/Settings/NodeModalStatusFooter.tsx?raw';
import nodeModalStateSource from '@/components/Settings/useNodeModalState.ts?raw';
import nodeModalPresentationSource from '@/utils/nodeModalPresentation.ts?raw';
import cephPageSource from '@/pages/Ceph.tsx?raw';
import cephServiceIconSource from '@/components/Ceph/CephServiceIcon.tsx?raw';
import diskPresentationSource from '@/features/storageBackups/diskPresentation.ts?raw';
import diskLiveMetricPresentationSource from '@/features/storageBackups/diskLiveMetricPresentation.ts?raw';
import storageDetailPresentationSource from '@/features/storageBackups/detailPresentation.ts?raw';
@ -604,7 +601,6 @@ const resourceDetailDrawerSource = [
].join('\n');
const infrastructurePageSource = [
infrastructurePageShellSource,
infrastructurePageSurfaceSource,
infrastructurePageRouteStateSource,
infrastructurePageStateSource,
@ -949,7 +945,9 @@ describe('frontend resource type boundaries', () => {
expect(guestRowSource).not.toContain('function NetworkInfoCell(');
expect(guestRowSource).not.toContain('function OSInfoCell(');
expect(guestRowStateSource).toContain('getCanonicalWorkloadId');
expect(guestRowStateSource).toContain("from '@/routing/resourceLinks'");
// The legacy cross-jump to /infrastructure?... was retired; guestRowState
// no longer needs anything from resourceLinks.
expect(guestRowStateSource).not.toContain("from '@/routing/resourceLinks'");
expect(guestRowStateSource).not.toContain('./infrastructureLink');
expect(guestRowModelSource).toContain('export const GUEST_COLUMNS');
expect(guestRowCellsSource).toContain('function NetworkInfoCell(');
@ -3348,27 +3346,14 @@ describe('frontend resource type boundaries', () => {
expect(nodeModalPresentationSource).toContain(
'export function buildNodeModalMonitoringPayload',
);
expect(cephPageSource).toContain('getCephServiceStatusPresentation');
expect(cephPageSource).toContain('getCephLoadingStatePresentation');
expect(cephPageSource).toContain('getCephDisconnectedStatePresentation');
expect(cephPageSource).toContain('getCephNoClustersStatePresentation');
expect(cephPageSource).toContain('getCephPoolsSearchEmptyStatePresentation');
expect(cephPageSource).toContain('CephServiceIcon');
expect(cephPageSource).not.toContain('<div class="overflow-x-auto"');
expect(cephPageSource).not.toContain(
'<style>{`.overflow-x-auto::-webkit-scrollbar { display: none; }`}</style>',
);
expect(cephPageSource).not.toContain('const getServiceStatus =');
expect(cephPageSource).not.toContain('const ServiceIcon: Component');
expect(cephPageSource).not.toContain('Loading Ceph data...');
expect(cephPageSource).not.toContain('No Ceph Clusters Detected');
expect(cephPageSource).not.toContain('No pools match "');
// Canonical Ceph presentation helpers still live in storageDomain and
// are consumed by ProxmoxCephTable / ProxmoxCephClusterDrawer now that
// the standalone /ceph page has been retired.
expect(storageDomainSource).toContain('export const getCephServiceStatusPresentation');
expect(storageDomainSource).toContain('export const getCephLoadingStatePresentation');
expect(storageDomainSource).toContain('export const getCephDisconnectedStatePresentation');
expect(storageDomainSource).toContain('export const getCephNoClustersStatePresentation');
expect(storageDomainSource).toContain('export const getCephPoolsSearchEmptyStatePresentation');
expect(cephServiceIconSource).toContain('export const CephServiceIcon');
expect(deployStatusBadgeSource).toContain('getDeployStatusPresentation');
expect(deployStatusBadgeSource).not.toContain('const statusConfig: Record<DeployTargetStatus');
expect(deployCandidatesStepSource).toContain('getDeployCandidatesLoadingState');

View file

@ -148,7 +148,6 @@ def first_matching_policy_id(rule: dict, rel: str) -> str:
RECOVERY_PRODUCT_SURFACE_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",
]
@ -769,8 +768,6 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
"frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts",
"frontend-modern/src/features/infrastructure/__tests__/infrastructurePageModel.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",
@ -1120,8 +1117,14 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
],
)
def test_recovery_route_change_requires_storage_recovery_contract(self):
required = infer_impacted_subsystems(["frontend-modern/src/pages/Recovery.tsx"])
def test_recovery_surface_change_requires_storage_recovery_contract(self):
# The legacy /recovery route shell (pages/Recovery.tsx) was retired
# with the platform-first migration; the canonical Recovery surface
# now lives at components/Recovery/Recovery.tsx and is embedded inside
# platform pages.
required = infer_impacted_subsystems(
["frontend-modern/src/components/Recovery/Recovery.tsx"]
)
self.assertEqual(set(required), {"storage-recovery"})
recovery = required["storage-recovery"]
@ -1131,7 +1134,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
)
self.assertEqual(
recovery["touched_runtime_files"],
["frontend-modern/src/pages/Recovery.tsx"],
["frontend-modern/src/components/Recovery/Recovery.tsx"],
)
self.assertEqual(
recovery["verification_requirements"],
@ -1139,7 +1142,9 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
{
"id": "recovery-product-surface",
"label": "recovery product surface proof",
"touched_runtime_files": ["frontend-modern/src/pages/Recovery.tsx"],
"touched_runtime_files": [
"frontend-modern/src/components/Recovery/Recovery.tsx"
],
"allow_same_subsystem_tests": False,
"test_prefixes": [],
"exact_files": RECOVERY_PRODUCT_SURFACE_EXACT_FILES,
@ -1286,7 +1291,6 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
"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",
],
}
@ -1299,8 +1303,14 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
)
self.assertEqual(required, {})
def test_storage_page_route_change_requires_storage_recovery_contract(self):
required = infer_impacted_subsystems(["frontend-modern/src/pages/Storage.tsx"])
def test_storage_surface_change_requires_storage_recovery_contract(self):
# The legacy /storage route shell (pages/Storage.tsx) was retired
# with the platform-first migration; the canonical Storage surface
# now lives at components/Storage/Storage.tsx and is embedded inside
# platform pages.
required = infer_impacted_subsystems(
["frontend-modern/src/components/Storage/Storage.tsx"]
)
self.assertEqual(set(required), {"storage-recovery"})
recovery = required["storage-recovery"]
@ -1310,7 +1320,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
)
self.assertEqual(
recovery["touched_runtime_files"],
["frontend-modern/src/pages/Storage.tsx"],
["frontend-modern/src/components/Storage/Storage.tsx"],
)
self.assertEqual(
recovery["verification_requirements"][0]["id"],
@ -3044,7 +3054,6 @@ index 1111111..2222222 100644
"frontend-modern/src/components/Workloads/__tests__/workloadTopology.test.ts",
"frontend-modern/src/components/Workloads/__tests__/workloadUrlSyncModel.test.ts",
"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",
],

View file

@ -7,7 +7,6 @@ from subsystem_lookup import lookup_paths, parse_args, render_pretty
RECOVERY_PRODUCT_SURFACE_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",
]
@ -733,8 +732,11 @@ class SubsystemLookupTest(unittest.TestCase):
"relay-frontend-surfaces",
)
def test_lookup_paths_assigns_recovery_route_to_storage_recovery(self) -> None:
result = lookup_paths(["frontend-modern/src/pages/Recovery.tsx"])
def test_lookup_paths_assigns_recovery_surface_to_storage_recovery(self) -> None:
# The legacy /recovery route shell (pages/Recovery.tsx) was retired
# with the platform-first migration; recovery is now exercised via
# the embedded surface at components/Recovery/Recovery.tsx.
result = lookup_paths(["frontend-modern/src/components/Recovery/Recovery.tsx"])
self.assertEqual(result["unowned_runtime_files"], [])
self.assertEqual(
{item["subsystem"] for item in result["impacted_subsystems"]},
@ -924,7 +926,6 @@ class SubsystemLookupTest(unittest.TestCase):
"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",
],
)
@ -938,8 +939,11 @@ class SubsystemLookupTest(unittest.TestCase):
self.assertEqual(file_entry["classification"], "runtime")
self.assertEqual(file_entry["matches"], [])
def test_lookup_paths_assigns_storage_page_to_storage_recovery(self) -> None:
result = lookup_paths(["frontend-modern/src/pages/Storage.tsx"])
def test_lookup_paths_assigns_storage_surface_to_storage_recovery(self) -> None:
# The legacy /storage route shell (pages/Storage.tsx) was retired
# with the platform-first migration; storage is now exercised via
# the embedded surface at components/Storage/Storage.tsx.
result = lookup_paths(["frontend-modern/src/components/Storage/Storage.tsx"])
self.assertEqual(result["unowned_runtime_files"], [])
self.assertEqual(
{item["subsystem"] for item in result["impacted_subsystems"]},
@ -4832,8 +4836,6 @@ class SubsystemLookupTest(unittest.TestCase):
"frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts",
"frontend-modern/src/features/infrastructure/__tests__/infrastructurePageModel.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",