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
+1 -1
View File
@@ -3844,7 +3844,7 @@
}, },
{ {
"repo": "pulse", "repo": "pulse",
"path": "frontend-modern/src/pages/Infrastructure.tsx", "path": "frontend-modern/src/pages/Proxmox.tsx",
"kind": "file" "kind": "file"
}, },
{ {
@@ -615,6 +615,18 @@ runtime cost control, and shared AI transport surfaces.
## Current State ## 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 The route-backed Proxmox platform tab is app-shell navigation only. Adding the
tab through `frontend-modern/src/App.tsx` and tab through `frontend-modern/src/App.tsx` and
`frontend-modern/src/AppLayout.tsx` must not fork Assistant or Patrol shell `frontend-modern/src/AppLayout.tsx` must not fork Assistant or Patrol shell
@@ -642,6 +642,18 @@ or other self-hosted uncapped continuity plans.
## Current State ## 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 The Proxmox platform tab in the authenticated app shell is ordinary product
navigation. It must stay separate from hosted acquisition, monitored-system navigation. It must stay separate from hosted acquisition, monitored-system
pressure, billing prompts, and Pulse Account handoffs: self-hosted operators pressure, billing prompts, and Pulse Account handoffs: self-hosted operators
@@ -1354,6 +1354,24 @@ AI runtime.
## Current State ## 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 The shared table chrome now allows `TableCardHeader` to expose a right-aligned
action slot, currently used by the Workloads/Proxmox metric display control. action slot, currently used by the Workloads/Proxmox metric display control.
That slot belongs to the table header band and must not reintroduce nested That slot belongs to the table header band and must not reintroduce nested
@@ -127,7 +127,7 @@ regression protection.
102. `frontend-modern/src/useAppRuntimeState.ts` 102. `frontend-modern/src/useAppRuntimeState.ts`
103. `frontend-modern/src/components/Storage/StorageSummary.tsx` 103. `frontend-modern/src/components/Storage/StorageSummary.tsx`
104. `frontend-modern/src/utils/storageSummaryCache.ts` 104. `frontend-modern/src/utils/storageSummaryCache.ts`
105. `frontend-modern/src/pages/Workloads.tsx` 105. `frontend-modern/src/components/Workloads/WorkloadsSurface.tsx`
## Shared Boundaries ## Shared Boundaries
@@ -2968,12 +2968,12 @@
"scripts/dev-deploy-agent.sh", "scripts/dev-deploy-agent.sh",
"scripts/dev-launchd-setup.sh", "scripts/dev-launchd-setup.sh",
"scripts/dev-launchd-wrapper.sh", "scripts/dev-launchd-wrapper.sh",
"scripts/hot-dev-bg.sh", "scripts/hot-dev-bg.sh",
"scripts/hot-dev.sh", "scripts/hot-dev.sh",
"scripts/lib/hot-dev-auth.sh", "scripts/lib/hot-dev-auth.sh",
"scripts/lib/hot-dev-runtime.sh", "scripts/lib/hot-dev-runtime.sh",
"scripts/toggle-mock.sh", "scripts/toggle-mock.sh",
"tests/integration/playwright.config.ts", "tests/integration/playwright.config.ts",
"tests/integration/QUICK_START.md", "tests/integration/QUICK_START.md",
"tests/integration/README.md", "tests/integration/README.md",
"tests/integration/scripts/managed-dev-runtime.mjs", "tests/integration/scripts/managed-dev-runtime.mjs",
@@ -2983,11 +2983,11 @@
"allow_same_subsystem_tests": false, "allow_same_subsystem_tests": false,
"test_prefixes": [], "test_prefixes": [],
"exact_files": [ "exact_files": [
"scripts/release_control/ssh_host_key_policy_test.py", "scripts/release_control/ssh_host_key_policy_test.py",
"scripts/tests/test-hot-dev-auth.sh", "scripts/tests/test-hot-dev-auth.sh",
"scripts/tests/test-hot-dev-bg.sh", "scripts/tests/test-hot-dev-bg.sh",
"scripts/tests/test-hot-dev-runtime.sh", "scripts/tests/test-hot-dev-runtime.sh",
"scripts/tests/test-toggle-mock.sh", "scripts/tests/test-toggle-mock.sh",
"tests/integration/tests/16-dev-runtime-recovery.spec.ts" "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__/settingsArchitecture.test.ts",
"frontend-modern/src/components/Settings/__tests__/SystemLogsPanel.test.tsx", "frontend-modern/src/components/Settings/__tests__/SystemLogsPanel.test.tsx",
"frontend-modern/src/pages/__tests__/Operations.helpers.test.ts", "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/routing/__tests__/routePreload.test.ts",
"frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts", "frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts",
"frontend-modern/src/utils/__tests__/reportableResourceTypes.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/workloadTopology.ts",
"frontend-modern/src/components/Workloads/workloadUrlSyncModel.ts", "frontend-modern/src/components/Workloads/workloadUrlSyncModel.ts",
"frontend-modern/src/hooks/useWorkloads.ts", "frontend-modern/src/hooks/useWorkloads.ts",
"frontend-modern/src/pages/Workloads.tsx",
"frontend-modern/src/routing/routePreload.ts", "frontend-modern/src/routing/routePreload.ts",
"frontend-modern/src/useAppRuntimeState.ts", "frontend-modern/src/useAppRuntimeState.ts",
"frontend-modern/src/utils/thresholdSliderPresentation.ts", "frontend-modern/src/utils/thresholdSliderPresentation.ts",
@@ -4458,7 +4455,6 @@
"frontend-modern/src/components/Workloads/workloadTopology.ts", "frontend-modern/src/components/Workloads/workloadTopology.ts",
"frontend-modern/src/components/Workloads/workloadUrlSyncModel.ts", "frontend-modern/src/components/Workloads/workloadUrlSyncModel.ts",
"frontend-modern/src/hooks/useWorkloads.ts", "frontend-modern/src/hooks/useWorkloads.ts",
"frontend-modern/src/pages/Workloads.tsx",
"frontend-modern/src/utils/thresholdSliderPresentation.ts", "frontend-modern/src/utils/thresholdSliderPresentation.ts",
"frontend-modern/src/utils/workloadsSummaryCache.ts" "frontend-modern/src/utils/workloadsSummaryCache.ts"
], ],
@@ -4495,7 +4491,6 @@
"frontend-modern/src/components/Workloads/ThresholdSlider.test.tsx", "frontend-modern/src/components/Workloads/ThresholdSlider.test.tsx",
"frontend-modern/src/components/Workloads/WorkloadsSummary.test.tsx", "frontend-modern/src/components/Workloads/WorkloadsSummary.test.tsx",
"frontend-modern/src/hooks/__tests__/useWorkloads.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__/thresholdSliderPresentation.test.ts",
"frontend-modern/src/utils/__tests__/workloadsSummaryCache.test.ts" "frontend-modern/src/utils/__tests__/workloadsSummaryCache.test.ts"
] ]
@@ -4851,9 +4846,6 @@
"frontend-modern/src/hooks/useRecoveryPointsFacets.ts", "frontend-modern/src/hooks/useRecoveryPointsFacets.ts",
"frontend-modern/src/hooks/useRecoveryPointsSeries.ts", "frontend-modern/src/hooks/useRecoveryPointsSeries.ts",
"frontend-modern/src/hooks/useRecoveryRollups.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/types/recovery.ts",
"frontend-modern/src/utils/recoveryActionPresentation.ts", "frontend-modern/src/utils/recoveryActionPresentation.ts",
"frontend-modern/src/utils/recoveryArtifactModePresentation.ts", "frontend-modern/src/utils/recoveryArtifactModePresentation.ts",
@@ -4875,7 +4867,6 @@
"exact_files": [ "exact_files": [
"frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx", "frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx",
"frontend-modern/src/components/Storage/code_standards.test.ts", "frontend-modern/src/components/Storage/code_standards.test.ts",
"frontend-modern/src/pages/__tests__/Recovery.test.tsx",
"internal/recovery/recovery_test.go" "internal/recovery/recovery_test.go"
], ],
"require_explicit_path_policy_coverage": true, "require_explicit_path_policy_coverage": true,
@@ -4911,7 +4902,6 @@
"frontend-modern/src/hooks/useRecoveryPointsFacets.ts", "frontend-modern/src/hooks/useRecoveryPointsFacets.ts",
"frontend-modern/src/hooks/useRecoveryPointsSeries.ts", "frontend-modern/src/hooks/useRecoveryPointsSeries.ts",
"frontend-modern/src/hooks/useRecoveryRollups.ts", "frontend-modern/src/hooks/useRecoveryRollups.ts",
"frontend-modern/src/pages/Recovery.tsx",
"frontend-modern/src/types/recovery.ts", "frontend-modern/src/types/recovery.ts",
"frontend-modern/src/utils/recoveryActionPresentation.ts", "frontend-modern/src/utils/recoveryActionPresentation.ts",
"frontend-modern/src/utils/recoveryArtifactModePresentation.ts", "frontend-modern/src/utils/recoveryArtifactModePresentation.ts",
@@ -4931,7 +4921,6 @@
"test_prefixes": [], "test_prefixes": [],
"exact_files": [ "exact_files": [
"frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx", "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", "frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts",
"tests/integration/tests/17-recovery-layout.spec.ts" "tests/integration/tests/17-recovery-layout.spec.ts"
] ]
@@ -4943,10 +4932,7 @@
"frontend-modern/src/components/Storage/", "frontend-modern/src/components/Storage/",
"frontend-modern/src/features/storageBackups/" "frontend-modern/src/features/storageBackups/"
], ],
"match_files": [ "match_files": [],
"frontend-modern/src/pages/Ceph.tsx",
"frontend-modern/src/pages/Storage.tsx"
],
"allow_same_subsystem_tests": false, "allow_same_subsystem_tests": false,
"test_prefixes": [], "test_prefixes": [],
"exact_files": [ "exact_files": [
@@ -4964,7 +4950,6 @@
"frontend-modern/src/features/storageBackups/__tests__/storageModelCore.test.ts", "frontend-modern/src/features/storageBackups/__tests__/storageModelCore.test.ts",
"frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts", "frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts",
"frontend-modern/src/features/storageBackups/__tests__/storagePoolsTablePresentation.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" "frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts"
] ]
} }
@@ -5025,7 +5010,6 @@
"frontend-modern/src/features/infrastructure/useInfrastructurePageRouteState.ts", "frontend-modern/src/features/infrastructure/useInfrastructurePageRouteState.ts",
"frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts", "frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts",
"frontend-modern/src/hooks/useUnifiedResources.ts", "frontend-modern/src/hooks/useUnifiedResources.ts",
"frontend-modern/src/pages/Infrastructure.tsx",
"frontend-modern/src/routing/resourceLinks.ts", "frontend-modern/src/routing/resourceLinks.ts",
"frontend-modern/src/types/resource.ts", "frontend-modern/src/types/resource.ts",
"frontend-modern/src/utils/actionAuditPresentation.ts", "frontend-modern/src/utils/actionAuditPresentation.ts",
@@ -5149,7 +5133,6 @@
"frontend-modern/src/features/infrastructure/useInfrastructurePageRouteState.ts", "frontend-modern/src/features/infrastructure/useInfrastructurePageRouteState.ts",
"frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts", "frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts",
"frontend-modern/src/hooks/useUnifiedResources.ts", "frontend-modern/src/hooks/useUnifiedResources.ts",
"frontend-modern/src/pages/Infrastructure.tsx",
"frontend-modern/src/routing/resourceLinks.ts", "frontend-modern/src/routing/resourceLinks.ts",
"frontend-modern/src/types/resource.ts", "frontend-modern/src/types/resource.ts",
"frontend-modern/src/utils/resourceIdentity.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__/infrastructurePageModel.test.ts",
"frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts", "frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts",
"frontend-modern/src/hooks/__tests__/useUnifiedResources.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/routing/__tests__/resourceLinks.test.ts",
"frontend-modern/src/stores/__tests__/websocket-unified.test.ts", "frontend-modern/src/stores/__tests__/websocket-unified.test.ts",
"frontend-modern/src/types/__tests__/resource.test.ts", "frontend-modern/src/types/__tests__/resource.test.ts",
@@ -39,28 +39,25 @@ state.
10. `frontend-modern/src/components/Recovery/RecoveryHistoryItemFilter.tsx` 10. `frontend-modern/src/components/Recovery/RecoveryHistoryItemFilter.tsx`
11. `frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx` 11. `frontend-modern/src/components/Recovery/RecoveryPointDetails.tsx`
12. `frontend-modern/src/components/Recovery/useRecoveryHistorySectionState.ts` 12. `frontend-modern/src/components/Recovery/useRecoveryHistorySectionState.ts`
13. `frontend-modern/src/pages/Storage.tsx` 13. `frontend-modern/src/components/Storage/Storage.tsx`
14. `frontend-modern/src/pages/Ceph.tsx` 14. `frontend-modern/src/features/storageBackups/storageModelCore.ts`
15. `frontend-modern/src/components/Storage/Storage.tsx` 15. `frontend-modern/src/utils/storageSources.ts`
16. `frontend-modern/src/features/storageBackups/storageModelCore.ts` 16. `frontend-modern/src/hooks/useRecoveryPoints.ts`
17. `frontend-modern/src/utils/storageSources.ts` 17. `frontend-modern/src/hooks/useRecoveryRollups.ts`
18. `frontend-modern/src/hooks/useRecoveryPoints.ts` 18. `frontend-modern/src/hooks/useRecoveryPointsFacets.ts`
19. `frontend-modern/src/hooks/useRecoveryRollups.ts` 19. `frontend-modern/src/hooks/useRecoveryPointsSeries.ts`
20. `frontend-modern/src/hooks/useRecoveryPointsFacets.ts` 20. `frontend-modern/src/routing/resourceLinks.ts`
21. `frontend-modern/src/hooks/useRecoveryPointsSeries.ts` 21. `frontend-modern/src/types/recovery.ts`
22. `frontend-modern/src/pages/Recovery.tsx` 22. `frontend-modern/src/utils/recoveryDatePresentation.ts`
23. `frontend-modern/src/routing/resourceLinks.ts` 23. `frontend-modern/src/utils/recoveryEmptyStatePresentation.ts`
24. `frontend-modern/src/types/recovery.ts` 24. `frontend-modern/src/utils/recoveryTablePresentation.ts`
25. `frontend-modern/src/utils/recoveryDatePresentation.ts` 25. `frontend-modern/src/utils/recoveryTimelinePresentation.ts`
26. `frontend-modern/src/utils/recoveryEmptyStatePresentation.ts` 26. `frontend-modern/src/utils/recoveryItemTypePresentation.ts`
27. `frontend-modern/src/utils/recoveryTablePresentation.ts` 27. `frontend-modern/src/utils/textPresentation.ts`
28. `frontend-modern/src/utils/recoveryTimelinePresentation.ts` 28. `frontend-modern/src/components/Storage/StorageSummary.tsx`
29. `frontend-modern/src/utils/recoveryItemTypePresentation.ts` 29. `frontend-modern/src/utils/storageSummaryCache.ts`
30. `frontend-modern/src/utils/textPresentation.ts` 30. `frontend-modern/src/components/Storage/useStorageSummaryCharts.ts`
31. `frontend-modern/src/components/Storage/StorageSummary.tsx` 31. `frontend-modern/src/features/storageBackups/storageCapacityDeltaPresentation.ts`
32. `frontend-modern/src/utils/storageSummaryCache.ts`
33. `frontend-modern/src/components/Storage/useStorageSummaryCharts.ts`
34. `frontend-modern/src/features/storageBackups/storageCapacityDeltaPresentation.ts`
## Shared Boundaries ## 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, A selected timeline day is an active event-history filter: table totals,
footer ranges, empty states, and search/date matching must describe the footer ranges, empty states, and search/date matching must describe the
visible filtered recovery points, not the unfiltered API page metadata. 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 The retired dashboard route must not reintroduce storage or recovery
widgets as compatibility panels. Storage capacity, storage health, widgets as compatibility panels. Storage capacity, storage health,
protected-item, and recovery-outcome readiness claims belong on the Storage protected-item, and recovery-outcome readiness claims belong on the Storage
@@ -701,6 +701,33 @@ AI-only summary payloads, or page-local heuristics.
## Current State ## 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 The Proxmox platform page is a route-level consumer of canonical unified
resources, not a new resource source. It filters the existing resource snapshot resources, not a new resource source. It filters the existing resource snapshot
to Proxmox VE, Backup Server, Mail Gateway, storage, disk, and workload rows, to Proxmox VE, Backup Server, Mail Gateway, storage, disk, and workload rows,
-18
View File
@@ -33,10 +33,6 @@ import {
PROXMOX_PATH, PROXMOX_PATH,
TRUENAS_PATH, TRUENAS_PATH,
VMWARE_PATH, VMWARE_PATH,
buildRecoveryPath,
buildInfrastructurePath,
buildStoragePath,
buildWorkloadsPath,
} from './routing/resourceLinks'; } from './routing/resourceLinks';
import { APP_SHELL_ROUTE_PRELOAD_PATHS, preloadRouteModule } from '@/routing/routePreload'; import { APP_SHELL_ROUTE_PRELOAD_PATHS, preloadRouteModule } from '@/routing/routePreload';
import { AppLayout } from '@/AppLayout'; import { AppLayout } from '@/AppLayout';
@@ -54,20 +50,15 @@ function isPublicRoutePath(pathname: string): boolean {
return pathname === '/pricing' || pathname === '/preview/setup-complete'; 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(() => const AlertsPage = lazy(() =>
import('./pages/Alerts').then((module) => ({ default: module.Alerts })), import('./pages/Alerts').then((module) => ({ default: module.Alerts })),
); );
const SettingsPage = lazy(() => import('./components/Settings/Settings')); const SettingsPage = lazy(() => import('./components/Settings/Settings'));
const InfrastructurePage = lazy(() => import('./pages/Infrastructure'));
const ProxmoxPage = lazy(() => import('./pages/Proxmox')); const ProxmoxPage = lazy(() => import('./pages/Proxmox'));
const DockerPage = lazy(() => import('./pages/Docker')); const DockerPage = lazy(() => import('./pages/Docker'));
const KubernetesPage = lazy(() => import('./pages/Kubernetes')); const KubernetesPage = lazy(() => import('./pages/Kubernetes'));
const TrueNASPage = lazy(() => import('./pages/TrueNAS')); const TrueNASPage = lazy(() => import('./pages/TrueNAS'));
const VmwarePage = lazy(() => import('./pages/Vmware')); const VmwarePage = lazy(() => import('./pages/Vmware'));
const WorkloadsPage = lazy(() => import('./pages/Workloads'));
const AIIntelligencePage = lazy(() => const AIIntelligencePage = lazy(() =>
import('./pages/AIIntelligence').then((module) => ({ default: module.AIIntelligence })), import('./pages/AIIntelligence').then((module) => ({ default: module.AIIntelligence })),
); );
@@ -79,10 +70,6 @@ const SetupCompletionPreviewPage = lazy(() =>
default: module.SetupCompletionPreview, 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; const ROOT_PATROL_PATH = PATROL_PATH;
async function preloadAppShellRoutes() { async function preloadAppShellRoutes() {
@@ -481,11 +468,6 @@ function App() {
<Route path={`${TRUENAS_PATH}/*`} component={TrueNASPage} /> <Route path={`${TRUENAS_PATH}/*`} component={TrueNASPage} />
<Route path={VMWARE_PATH} component={VmwarePage} /> <Route path={VMWARE_PATH} component={VmwarePage} />
<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="/alerts/*" component={AlertsPage} />
<Route path={`${ROOT_PATROL_PATH}/*`} component={AIIntelligencePage} /> <Route path={`${ROOT_PATROL_PATH}/*`} component={AIIntelligencePage} />
+2 -8
View File
@@ -31,7 +31,6 @@ import { getActiveTabForPath } from '@/routing/navigation';
import { preloadRouteModule } from '@/routing/routePreload'; import { preloadRouteModule } from '@/routing/routePreload';
import { import {
buildDockerPath, buildDockerPath,
buildInfrastructurePath,
buildKubernetesPath, buildKubernetesPath,
buildProxmoxPath, buildProxmoxPath,
buildTrueNASPath, buildTrueNASPath,
@@ -45,7 +44,6 @@ import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentat
import { AI_CHAT_LAUNCHER_ARIA_LABEL, getAIChatLauncherTitle } from '@/utils/aiChatPresentation'; import { AI_CHAT_LAUNCHER_ARIA_LABEL, getAIChatLauncherTitle } from '@/utils/aiChatPresentation';
import type { AppConnectionStatus } from '@/useAppRuntimeState'; import type { AppConnectionStatus } from '@/useAppRuntimeState';
const ROOT_INFRASTRUCTURE_PATH = buildInfrastructurePath();
const ROOT_PROXMOX_PATH = buildProxmoxPath(); const ROOT_PROXMOX_PATH = buildProxmoxPath();
const ROOT_DOCKER_PATH = buildDockerPath(); const ROOT_DOCKER_PATH = buildDockerPath();
const ROOT_KUBERNETES_PATH = buildKubernetesPath(); const ROOT_KUBERNETES_PATH = buildKubernetesPath();
@@ -204,10 +202,6 @@ export function AppLayout(props: AppLayoutProps) {
kubernetes: 'Kubernetes', kubernetes: 'Kubernetes',
truenas: 'TrueNAS', truenas: 'TrueNAS',
vmware: 'vSphere', vmware: 'vSphere',
infrastructure: 'Infrastructure',
workloads: 'Workloads',
storage: 'Storage',
recovery: 'Recovery',
alerts: 'Alerts', alerts: 'Alerts',
ai: 'Patrol', ai: 'Patrol',
settings: 'Settings', settings: 'Settings',
@@ -260,8 +254,8 @@ export function AppLayout(props: AppLayoutProps) {
!normalizedPath.startsWith('/alerts/overview/') && !normalizedPath.startsWith('/alerts/overview/') &&
!normalizedPath.startsWith('/alerts/history/'); !normalizedPath.startsWith('/alerts/history/');
if ((isBlocked || isAlertConfigTab) && normalizedPath !== ROOT_INFRASTRUCTURE_PATH) { if ((isBlocked || isAlertConfigTab) && normalizedPath !== ROOT_PROXMOX_PATH) {
navigate(ROOT_INFRASTRUCTURE_PATH, { replace: true }); navigate(ROOT_PROXMOX_PATH, { replace: true });
} }
}); });
@@ -28,7 +28,6 @@ describe('App architecture', () => {
expect(appSource).toContain('setAppScrollShellRef'); expect(appSource).toContain('setAppScrollShellRef');
expect(appSource).toContain('readPendingAppShellRestoreTop'); expect(appSource).toContain('readPendingAppShellRestoreTop');
expect(appSource).toContain('clearPendingAppShellRestoreTop'); 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 ProxmoxPage = lazy(() => import('./pages/Proxmox'));");
expect(appSource).toContain('const ROOT_PATROL_PATH = PATROL_PATH;'); expect(appSource).toContain('const ROOT_PATROL_PATH = PATROL_PATH;');
expect(appSource).toContain( expect(appSource).toContain(
@@ -36,18 +35,24 @@ describe('App architecture', () => {
); );
expect(routePreloadSource).toContain('export const APP_SHELL_ROUTE_PRELOAD_PATHS = ['); expect(routePreloadSource).toContain('export const APP_SHELL_ROUTE_PRELOAD_PATHS = [');
expect(routePreloadSource).toContain('ROOT_PROXMOX_PATH,'); expect(routePreloadSource).toContain('ROOT_PROXMOX_PATH,');
expect(routePreloadSource).toContain('ROOT_WORKLOADS_PATH,');
expect(routePreloadSource).toContain('RECOVERY_ROUTE_PATH,');
expect(routePreloadSource).toContain('PATROL_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(appSource).toContain('await preloadRouteModule(route);');
expect(appRuntimeStateSource).not.toContain('preloadLazyRoutes'); expect(appRuntimeStateSource).not.toContain('preloadLazyRoutes');
expect(appRuntimeStateSource).not.toContain("import('@/pages/Alerts')"); expect(appRuntimeStateSource).not.toContain("import('@/pages/Alerts')");
expect(appRuntimeStateSource).not.toContain("import('@/components/Settings/Settings')"); expect(appRuntimeStateSource).not.toContain("import('@/components/Settings/Settings')");
expect(appSource).toContain('const timeoutId = window.setTimeout(() => {'); expect(appSource).toContain('const timeoutId = window.setTimeout(() => {');
expect(appSource).toContain('void preloadAppShellRoutes();'); 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('<Route path={`${PROXMOX_PATH}/*`} component={ProxmoxPage} />'); expect(appSource).toContain('<Route path={`${PROXMOX_PATH}/*`} component={ProxmoxPage} />');
expect(appSource).toContain("const DockerPage = lazy(() => import('./pages/Docker'));"); 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" component=');
expect(appSource).not.toContain('<Route path="/cloud/signup" 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 ProxmoxPage = lazy(() => import('./pages/Proxmox'));");
expect(appSource).toContain("const OperationsPage = lazy(() => import('./pages/Operations'));"); expect(appSource).toContain("const OperationsPage = lazy(() => import('./pages/Operations'));");
expect(appSource).toContain("const WorkloadsPage = lazy(() => import('./pages/Workloads'));"); // Legacy page wrappers were deleted when primary nav moved to
expect(appSource).toContain("const RecoveryPage = lazy(() => import('./pages/Recovery'));"); // platform-first; their tables are reused inside platform pages directly.
expect(appSource).toContain('<Route path={ROOT_WORKLOADS_PATH} component={WorkloadsPage} />'); expect(appSource).not.toContain("import('./pages/Storage')");
expect(appSource).toContain('<Route path={RECOVERY_ROUTE_PATH} component={RecoveryPage} />'); 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( expect(appSource).not.toContain(
"const StorageComponent = lazy(() => import('./components/Storage/Storage'));", "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('const ROUTE_PRELOADERS: readonly RoutePreloader[] = [');
expect(routePreloadSource).toContain('export const APP_SHELL_ROUTE_PRELOAD_PATHS = ['); expect(routePreloadSource).toContain('export const APP_SHELL_ROUTE_PRELOAD_PATHS = [');
expect(routePreloadSource).toContain("id: 'proxmox',"); expect(routePreloadSource).toContain("id: 'proxmox',");
expect(routePreloadSource).toContain("id: 'recovery',");
expect(routePreloadSource).toContain("id: 'patrol',"); expect(routePreloadSource).toContain("id: 'patrol',");
expect(routePreloadSource).toContain( expect(routePreloadSource).toContain(
'const routePreloadCache = new Map<string, Promise<void>>();', 'const routePreloadCache = new Map<string, Promise<void>>();',
); );
expect(routePreloadSource).toContain("import('@/pages/Infrastructure')");
expect(routePreloadSource).toContain("import('@/pages/Proxmox')"); expect(routePreloadSource).toContain("import('@/pages/Proxmox')");
expect(routePreloadSource).toContain("import('@/pages/Workloads')"); expect(routePreloadSource).not.toContain("import('@/pages/Infrastructure')");
expect(routePreloadSource).toContain("import('@/pages/Recovery')"); 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('@/components/Workloads/WorkloadsSurface')");
expect(routePreloadSource).not.toContain("import('@/pages/RecoveryRoute')"); expect(routePreloadSource).not.toContain("import('@/pages/RecoveryRoute')");
expect(appRuntimeContextSource).toContain( expect(appRuntimeContextSource).toContain(
@@ -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;
@@ -1,7 +1,6 @@
import { type Component, For, Show, createMemo } from 'solid-js'; import { type Component, For, Show, createMemo } from 'solid-js';
import type { ResourceChange } from '@/types/resource'; import type { ResourceChange } from '@/types/resource';
import { formatRelativeTime } from '@/utils/format'; import { formatRelativeTime } from '@/utils/format';
import { buildInfrastructureResourceHref } from '@/routing/resourceLinks';
import { import {
getResourceChangeKindPresentation, getResourceChangeKindPresentation,
getResourceChangeSourceAdapterPresentation, getResourceChangeSourceAdapterPresentation,
@@ -28,7 +27,7 @@ export const ResourceChangeSummary: Component<ResourceChangeSummaryProps> = (pro
const visibleChanges = createMemo(() => sortedChanges().slice(0, maxChanges())); const visibleChanges = createMemo(() => sortedChanges().slice(0, maxChanges()));
const hasChanges = () => visibleChanges().length > 0; const hasChanges = () => visibleChanges().length > 0;
const compact = () => props.compact ?? false; const compact = () => props.compact ?? false;
const buildResourceHref = props.buildResourceHref ?? buildInfrastructureResourceHref; const buildResourceHref = props.buildResourceHref ?? (() => null);
const resolveResourceLabel = props.resolveResourceLabel; const resolveResourceLabel = props.resolveResourceLabel;
const itemPadding = () => (compact() ? 'p-2.5' : 'p-3'); const itemPadding = () => (compact() ? 'p-2.5' : 'p-3');
const itemText = () => (compact() ? 'text-[11px]' : 'text-xs'); const itemText = () => (compact() ? 'text-[11px]' : 'text-xs');
@@ -2,7 +2,6 @@ import { For, Show, createMemo, type Component } from 'solid-js';
import type { ResourceCorrelation } from '@/types/aiIntelligence'; import type { ResourceCorrelation } from '@/types/aiIntelligence';
import type { ResourceRelationship } from '@/types/resource'; import type { ResourceRelationship } from '@/types/resource';
import { formatRelativeTime } from '@/utils/format'; import { formatRelativeTime } from '@/utils/format';
import { buildInfrastructureResourceHref } from '@/routing/resourceLinks';
import { import {
formatResourceCorrelationEndpoint, formatResourceCorrelationEndpoint,
formatResourceCorrelationHeadline, formatResourceCorrelationHeadline,
@@ -36,7 +35,7 @@ export const ResourceCorrelationSummary: Component<ResourceCorrelationSummaryPro
const relationships = createMemo(() => sortResourceRelationships(props.relationships ?? [])); const relationships = createMemo(() => sortResourceRelationships(props.relationships ?? []));
const dependencies = () => props.dependencies ?? []; const dependencies = () => props.dependencies ?? [];
const dependents = () => props.dependents ?? []; const dependents = () => props.dependents ?? [];
const buildResourceHref = props.buildResourceHref ?? buildInfrastructureResourceHref; const buildResourceHref = props.buildResourceHref ?? (() => null);
const resolveResourceLabel = props.resolveResourceLabel; const resolveResourceLabel = props.resolveResourceLabel;
const maxCorrelations = () => props.maxCorrelations ?? 3; const maxCorrelations = () => props.maxCorrelations ?? 3;
const hasContent = () => const hasContent = () =>
@@ -41,7 +41,6 @@ import {
import { formatConfidenceLabel } from '@/utils/confidencePresentation'; import { formatConfidenceLabel } from '@/utils/confidencePresentation';
import { formatIdentifierLabel } from '@/utils/textPresentation'; import { formatIdentifierLabel } from '@/utils/textPresentation';
import { shouldShowResourcePlatformId } from '@/utils/resourceIdentity'; import { shouldShowResourcePlatformId } from '@/utils/resourceIdentity';
import { buildInfrastructureResourceHref } from '@/routing/resourceLinks';
import { getDiscoveryLoadingState } from '@/utils/discoveryPresentation'; import { getDiscoveryLoadingState } from '@/utils/discoveryPresentation';
import { import {
RESOURCE_ANALYSIS_LABEL, RESOURCE_ANALYSIS_LABEL,
@@ -541,16 +540,7 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
<For each={change.relatedResources ?? []}> <For each={change.relatedResources ?? []}>
{(relatedResource) => { {(relatedResource) => {
const label = drawer.resolveResourceLabel(relatedResource); const label = drawer.resolveResourceLabel(relatedResource);
const href = buildInfrastructureResourceHref(relatedResource); return (
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>
) : (
<span class="inline-flex rounded bg-surface px-1.5 py-0.5 text-[10px] text-base-content"> <span class="inline-flex rounded bg-surface px-1.5 py-0.5 text-[10px] text-base-content">
{label} {label}
</span> </span>
@@ -41,7 +41,6 @@ import {
import { getAvailabilityProbePresentation } from '@/utils/availabilityProbePresentation'; import { getAvailabilityProbePresentation } from '@/utils/availabilityProbePresentation';
import { ResourceDetailDrawer } from './ResourceDetailDrawer'; import { ResourceDetailDrawer } from './ResourceDetailDrawer';
import { getResourceHealthIssuePresentation } from './resourceHealthPresentation'; import { getResourceHealthIssuePresentation } from './resourceHealthPresentation';
import { buildWorkloadsHref } from './workloadsLink';
import { ClusterDeployBanner } from './ClusterDeployBanner'; import { ClusterDeployBanner } from './ClusterDeployBanner';
import { ResourceFacetSummary } from './ResourceFacetSummary'; import { ResourceFacetSummary } from './ResourceFacetSummary';
import { UnifiedResourceSourceBadgeCell } from './UnifiedResourceSourceBadgeCell'; import { UnifiedResourceSourceBadgeCell } from './UnifiedResourceSourceBadgeCell';
@@ -330,7 +329,6 @@ export const UnifiedResourceHostTableCard: Component<UnifiedResourceHostTableCar
const availabilityProbe = createMemo(() => const availabilityProbe = createMemo(() =>
getAvailabilityProbePresentation(resource), getAvailabilityProbePresentation(resource),
); );
const workloadsHref = createMemo(() => buildWorkloadsHref(resource));
const resourceRowInteraction = createSummaryInteractiveRowPreviewHandlers({ const resourceRowInteraction = createSummaryInteractiveRowPreviewHandlers({
onPreview: () => tableProps.onHoverChange?.(resource.id), onPreview: () => tableProps.onHoverChange?.(resource.id),
onPreviewClear: () => tableProps.onHoverChange?.(null), onPreviewClear: () => tableProps.onHoverChange?.(null),
@@ -418,36 +416,6 @@ export const UnifiedResourceHostTableCard: Component<UnifiedResourceHostTableCar
</Show> </Show>
</div> </div>
</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> </div>
</TableCell> </TableCell>
@@ -49,16 +49,15 @@ describe('ResourceChangeSummary', () => {
expect(screen.getByText('Restart: running → restarting')).toBeInTheDocument(); expect(screen.getByText('Restart: running → restarting')).toBeInTheDocument();
expect(screen.getByText('Platform event')).toBeInTheDocument(); expect(screen.getByText('Platform event')).toBeInTheDocument();
expect(screen.getByText('Docker adapter')).toBeInTheDocument(); expect(screen.getByText('Docker adapter')).toBeInTheDocument();
expect( // Cross-jump links to /infrastructure?resource=... were retired with the
screen.getByRole('link', { name: 'Open resource storage-2 in Infrastructure' }), // legacy Infrastructure surface; the change feed now renders resource
).toHaveAttribute('href', '/infrastructure?resource=storage-2'); // labels as plain text by default.
expect( expect(screen.queryAllByRole('link')).toHaveLength(0);
screen.getByRole('link', { name: 'Open related resource VM 200 in Infrastructure' }), expect(screen.getByText('storage-2')).toBeInTheDocument();
).toHaveAttribute('href', '/infrastructure?resource=vm-200');
expect(screen.getByText('VM 200')).toBeInTheDocument(); expect(screen.getByText('VM 200')).toBeInTheDocument();
expect(screen.getByText('By agent:ops-helper')).toBeInTheDocument(); expect(screen.getByText('By agent:ops-helper')).toBeInTheDocument();
expect(screen.getByText('Restart after maintenance')).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', () => { it('renders the empty state when no recent changes are available', () => {
@@ -30,12 +30,11 @@ describe('ResourceCorrelationSummary', () => {
expect(screen.getByText('Learned correlations')).toBeInTheDocument(); expect(screen.getByText('Learned correlations')).toBeInTheDocument();
expect(screen.getByText('5 total')).toBeInTheDocument(); expect(screen.getByText('5 total')).toBeInTheDocument();
expect( // Cross-jump links to the legacy /infrastructure surface were retired;
screen.getByRole('link', { name: 'Open source resource Storage 1 in Infrastructure' }), // labels render as plain text by default in the platform-first layout.
).toHaveAttribute('href', '/infrastructure?resource=storage-1'); expect(screen.queryAllByRole('link')).toHaveLength(0);
expect( expect(screen.getByText('Storage 1')).toBeInTheDocument();
screen.getByRole('link', { name: 'Open target resource Host 1 in Infrastructure' }), expect(screen.getByText('Host 1')).toBeInTheDocument();
).toHaveAttribute('href', '/infrastructure?resource=host-1');
expect(screen.getByText('Disk Full → Restart')).toBeInTheDocument(); expect(screen.getByText('Disk Full → Restart')).toBeInTheDocument();
expect(screen.getByText(/2 occurrences · avg delay 2m · 88% confidence/)).toBeInTheDocument(); expect(screen.getByText(/2 occurrences · avg delay 2m · 88% confidence/)).toBeInTheDocument();
expect(screen.getByText('Disk pressure often precedes restarts')).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('Depends on')).toBeInTheDocument();
expect(screen.getByText('Used by')).toBeInTheDocument(); expect(screen.getByText('Used by')).toBeInTheDocument();
expect(screen.getByText('Correlations')).toBeInTheDocument(); expect(screen.getByText('Correlations')).toBeInTheDocument();
expect( // Cross-jump links to /infrastructure were retired; labels render as
screen.getByRole('link', { name: 'Open source resource PVE 1 in Infrastructure' }), // plain text by default in the platform-first layout.
).toHaveAttribute('href', '/infrastructure?resource=node%3Apve-1'); expect(screen.queryAllByRole('link')).toHaveLength(0);
expect( expect(screen.getByText('PVE 1')).toBeInTheDocument();
screen.getByRole('link', { name: 'Open target resource VM Child in Infrastructure' }), expect(screen.getAllByText('VM Child').length).toBeGreaterThan(0);
).toHaveAttribute('href', '/infrastructure?resource=vm-child'); expect(screen.getByText('Storage 1 alias')).toBeInTheDocument();
expect(screen.getByText('Runs On')).toBeInTheDocument(); expect(screen.getByText('Runs On')).toBeInTheDocument();
expect(screen.getByText(/100% confidence · Proxmox Adapter · last seen/)).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); expect(screen.getAllByText(/last seen/i).length).toBeGreaterThanOrEqual(2);
}); });
}); });
@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource'; import type { Resource } from '@/types/resource';
import { toDiscoveryConfig } from '@/components/Infrastructure/resourceDetailDiscoveryModel'; import { toDiscoveryConfig } from '@/components/Infrastructure/resourceDetailDiscoveryModel';
import { buildWorkloadsHref } from '@/components/Infrastructure/workloadsLink';
const baseResource = (): Resource => ({ const baseResource = (): Resource => ({
id: 'host-abcd', 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');
});
});
@@ -526,11 +526,10 @@ describe('ResourceDetailDrawer change history section', () => {
const relationshipMap = within(screen.getByTestId('resource-relationship-map-section')); const relationshipMap = within(screen.getByTestId('resource-relationship-map-section'));
expect(screen.getByText('Relationship map')).toBeInTheDocument(); expect(screen.getByText('Relationship map')).toBeInTheDocument();
expect(relationshipMap.getByText('Canonical relationships')).toBeInTheDocument(); expect(relationshipMap.getByText('Canonical relationships')).toBeInTheDocument();
expect( // Legacy /infrastructure?resource=... cross-jumps were retired; the
relationshipMap.getByRole('link', { // relationship map renders resource labels as plain text now.
name: 'Open source resource PVE Node 1 in Infrastructure', expect(relationshipMap.queryAllByRole('link')).toHaveLength(0);
}), expect(relationshipMap.getByText('PVE Node 1')).toBeInTheDocument();
).toHaveAttribute('href', '/infrastructure?resource=node%3Apve-1');
expect(relationshipMap.getByText('Runs On')).toBeInTheDocument(); expect(relationshipMap.getByText('Runs On')).toBeInTheDocument();
expect(screen.getByText('Depends on')).toBeInTheDocument(); expect(screen.getByText('Depends on')).toBeInTheDocument();
expect(screen.getByText('Used by')).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('Recent activity')).toBeNull();
expect(panel.queryByText('Filterable event history for this resource.')).toBeNull(); expect(panel.queryByText('Filterable event history for this resource.')).toBeNull();
expect(panel.queryByText('Event log')).toBeNull(); expect(panel.queryByText('Event log')).toBeNull();
expect( // Cross-jump to /infrastructure?resource=... retired; related resources
panel.getByRole('link', { name: 'Open related resource PVE Node 1 in Infrastructure' }), // render as plain text now.
).toHaveAttribute('href', '/infrastructure?resource=node%3Apve-1'); expect(panel.queryAllByRole('link')).toHaveLength(0);
expect(panel.getByText('PVE Node 1')).toBeInTheDocument(); expect(panel.getByText('PVE Node 1')).toBeInTheDocument();
expect(panel.getByText('Routine restart requested')).toBeInTheDocument(); expect(panel.getByText('Routine restart requested')).toBeInTheDocument();
expect(panel.getByText('Confidence')).toBeInTheDocument(); expect(panel.getByText('Confidence')).toBeInTheDocument();
@@ -651,7 +651,7 @@ describe('ResourceDetailDrawer runtime and identity cards', () => {
it('surfaces canonical analysis context for the resource overview', async () => { it('surfaces canonical analysis context for the resource overview', async () => {
const resource = baseResource({}); const resource = baseResource({});
const { getByRole, getByTestId, getByText, queryByText } = render(() => ( const { getByRole, getByTestId, getByText, queryByRole, queryByText } = render(() => (
<ResourceDetailDrawer resource={resource} /> <ResourceDetailDrawer resource={resource} />
)); ));
@@ -678,16 +678,18 @@ describe('ResourceDetailDrawer runtime and identity cards', () => {
await waitFor(() => { await waitFor(() => {
expect(getByText('Storage 1')).toBeInTheDocument(); expect(getByText('Storage 1')).toBeInTheDocument();
}); });
// Cross-jump to /infrastructure?resource=... retired with the legacy
// surface; dependency / dependent labels render as plain text now.
expect( expect(
getByRole('link', { queryByRole('link', {
name: 'Open dependency resource storage-1 in Infrastructure', name: 'Open dependency resource storage-1 in Infrastructure',
}), }),
).toHaveAttribute('href', expect.stringContaining('/infrastructure?resource=storage-1')); ).toBeNull();
expect( expect(
getByRole('link', { queryByRole('link', {
name: 'Open dependent resource vm-child in Infrastructure', name: 'Open dependent resource vm-child in Infrastructure',
}), }),
).toHaveAttribute('href', expect.stringContaining('/infrastructure?resource=vm-child')); ).toBeNull();
expect(getByText('Storage 1')).toBeInTheDocument(); expect(getByText('Storage 1')).toBeInTheDocument();
expect(getByText('Host 1')).toBeInTheDocument(); expect(getByText('Host 1')).toBeInTheDocument();
expect(getByText('Disk Full → Restart')).toBeInTheDocument(); expect(getByText('Disk Full → Restart')).toBeInTheDocument();
@@ -110,10 +110,9 @@ describe('ResourceDetailDrawer service cards', () => {
expect(getByText('Datastores')).toBeInTheDocument(); expect(getByText('Datastores')).toBeInTheDocument();
expect(getByText('Jobs')).toBeInTheDocument(); expect(getByText('Jobs')).toBeInTheDocument();
expect(getByText('Types')).toBeInTheDocument(); expect(getByText('Types')).toBeInTheDocument();
expect(getByRole('link', { name: /open recovery events/i })).toHaveAttribute( // The legacy "Open Recovery Events" cross-jump was retired with the
'href', // standalone Recovery surface; PBS detail no longer surfaces it.
'/recovery?view=events&platform=proxmox-pbs&mode=remote', expect(queryByText(/open recovery events/i)).toBeNull();
);
}); });
it('surfaces active PBS tasks before and inside job detail', () => { it('surfaces active PBS tasks before and inside job detail', () => {
@@ -88,8 +88,7 @@ describe('UnifiedResourceTable workloads links', () => {
).toBe(true); ).toBe(true);
}); });
it('renders workloads links for supported resource types and prevents row toggle on link click', async () => { it('no longer renders host-row workloads cross-jump links in the platform-first layout', () => {
const onExpandedResourceChange = vi.fn();
const resources: Resource[] = [ const resources: Resource[] = [
baseResource({ baseResource({
id: 'node-1', 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 <UnifiedResourceTable
resources={resources} resources={resources}
expandedResourceId={null} expandedResourceId={null}
onExpandedResourceChange={onExpandedResourceChange} onExpandedResourceChange={vi.fn()}
groupingMode="flat" groupingMode="flat"
/> />
)); ));
const links = getAllByRole('link', { name: /view workloads/i }); // Legacy cross-jump from a host row to /workloads?... was retired with
expect(links).toHaveLength(2); // the standalone Workloads page; the platform pages expose workloads as
const hrefs = links // adjacent sub-tabs instead.
.map((link) => link.getAttribute('href')) expect(queryAllByRole('link', { name: /view workloads/i })).toHaveLength(0);
.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();
}); });
it('renders PBS and PMG resources in a dedicated service table with service-native columns', async () => { 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(); 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 }); const pmgLink = getByRole('link', { name: /open pmg thresholds/i });
expect(pmgLink).toHaveTextContent('Thresholds'); expect(pmgLink).toHaveTextContent('Thresholds');
expect(pmgLink).toHaveAttribute('href', '/alerts/thresholds/mail-gateway'); expect(pmgLink).toHaveAttribute('href', '/alerts/thresholds/mail-gateway');
pbsLink.addEventListener('click', (event) => event.preventDefault()); pmgLink.addEventListener('click', (event) => event.preventDefault());
await fireEvent.click(pbsLink); await fireEvent.click(pmgLink);
expect(onExpandedResourceChange).not.toHaveBeenCalled(); expect(onExpandedResourceChange).not.toHaveBeenCalled();
}); });
}); });
@@ -18,7 +18,7 @@ const baseResource = (overrides: Partial<Resource>): Resource => ({
}); });
describe('buildServiceDetailLinks', () => { 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( const links = buildServiceDetailLinks(
baseResource({ baseResource({
id: 'pbs-main', id: 'pbs-main',
@@ -29,14 +29,7 @@ describe('buildServiceDetailLinks', () => {
}), }),
); );
expect(links).toEqual([ 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',
},
]);
}); });
it('returns PMG drill-down link to mail gateway thresholds', () => { it('returns PMG drill-down link to mail gateway thresholds', () => {
@@ -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',
);
});
});
});
@@ -1,5 +1,5 @@
import type { Resource } from '@/types/resource'; 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'; import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity';
export type ServiceDetailLink = { export type ServiceDetailLink = {
@@ -12,17 +12,6 @@ export type ServiceDetailLink = {
export const buildServiceDetailLinks = (resource: Resource): ServiceDetailLink[] => { export const buildServiceDetailLinks = (resource: Resource): ServiceDetailLink[] => {
const label = getPreferredInfrastructureDisplayName(resource); 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') { if (resource.type === 'pmg') {
return [ return [
{ {
@@ -1,5 +0,0 @@
import type { Resource } from '@/types/resource';
import { buildWorkloadsHrefForResource } from '@/routing/resourceLinks';
export const buildWorkloadsHref = (resource: Resource): string | null =>
buildWorkloadsHrefForResource(resource);
@@ -15,7 +15,7 @@ import {
TableCell, TableCell,
} from '@/components/shared/Table'; } from '@/components/shared/Table';
import { EmptyState } from '@/components/shared/EmptyState'; import { EmptyState } from '@/components/shared/EmptyState';
import { buildWorkloadsPath } from '@/routing/resourceLinks'; import { buildKubernetesPath } from '@/routing/resourceLinks';
import { import {
getK8sDeploymentsDrawerPresentation, getK8sDeploymentsDrawerPresentation,
getK8sDeploymentsEmptyState, getK8sDeploymentsEmptyState,
@@ -160,16 +160,9 @@ export const K8sDeploymentsDrawer: Component<{
}); });
}); });
const openPods = (ns?: string) => { const openPods = (_ns?: string) => {
const cluster = clusterName(); if (!clusterName()) return;
if (!cluster) return; navigate(buildKubernetesPath('pods'));
navigate(
buildWorkloadsPath({
type: 'pod',
context: cluster,
namespace: asTrimmedString(ns) || null,
}),
);
}; };
const headingId = () => `k8s-deployments-drawer-heading-${clusterName() || 'cluster'}`; const headingId = () => `k8s-deployments-drawer-heading-${clusterName() || 'cluster'}`;
@@ -14,7 +14,7 @@ import {
} from '@/components/shared/Table'; } from '@/components/shared/Table';
import { EmptyState } from '@/components/shared/EmptyState'; import { EmptyState } from '@/components/shared/EmptyState';
import { StatusDot } from '@/components/shared/StatusDot'; import { StatusDot } from '@/components/shared/StatusDot';
import { buildWorkloadsPath } from '@/routing/resourceLinks'; import { buildKubernetesPath } from '@/routing/resourceLinks';
import { import {
getK8sNamespacesDrawerPresentation, getK8sNamespacesDrawerPresentation,
getK8sNamespacesEmptyState, getK8sNamespacesEmptyState,
@@ -79,16 +79,9 @@ export const K8sNamespacesDrawer: Component<{
return rows().filter((row) => row.namespace.toLowerCase().includes(term)); return rows().filter((row) => row.namespace.toLowerCase().includes(term));
}); });
const openPods = (namespace: string | null) => { const openPods = (_namespace: string | null) => {
const cluster = clusterName(); if (!clusterName()) return;
if (!cluster) return; navigate(buildKubernetesPath('pods'));
navigate(
buildWorkloadsPath({
type: 'pod',
context: cluster,
namespace: asTrimmedString(namespace) ?? null,
}),
);
}; };
const headingId = () => `k8s-namespaces-drawer-heading-${clusterName() || 'cluster'}`; const headingId = () => `k8s-namespaces-drawer-heading-${clusterName() || 'cluster'}`;
@@ -741,7 +741,7 @@ describe('K8sDeploymentsDrawer', () => {
}); });
describe('navigation', () => { 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()]); mockApiResponse([makeDeployment()]);
render(() => <K8sDeploymentsDrawer cluster="my-cluster" />); render(() => <K8sDeploymentsDrawer cluster="my-cluster" />);
@@ -753,13 +753,10 @@ describe('K8sDeploymentsDrawer', () => {
fireEvent.click(openPodsBtn); fireEvent.click(openPodsBtn);
expect(navigateMock).toHaveBeenCalledTimes(1); expect(navigateMock).toHaveBeenCalledTimes(1);
const path = navigateMock.mock.calls[0][0] as string; expect(navigateMock).toHaveBeenCalledWith('/kubernetes/pods');
expect(path).toContain('/workloads');
expect(path).toContain('type=pod');
expect(path).toContain('context=my-cluster');
}); });
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([ mockApiResponse([
makeDeployment({ id: '1', name: 'dep-a', kubernetes: { namespace: 'production' } }), makeDeployment({ id: '1', name: 'dep-a', kubernetes: { namespace: 'production' } }),
makeDeployment({ id: '2', name: 'dep-b', kubernetes: { namespace: 'staging' } }), makeDeployment({ id: '2', name: 'dep-b', kubernetes: { namespace: 'staging' } }),
@@ -770,7 +767,9 @@ describe('K8sDeploymentsDrawer', () => {
expect(screen.getByText('dep-a')).toBeInTheDocument(); 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'); const select = screen.getByLabelText('Namespace');
fireEvent.change(select, { target: { value: 'production' } }); fireEvent.change(select, { target: { value: 'production' } });
@@ -778,11 +777,10 @@ describe('K8sDeploymentsDrawer', () => {
fireEvent.click(openPodsBtn); fireEvent.click(openPodsBtn);
expect(navigateMock).toHaveBeenCalledTimes(1); expect(navigateMock).toHaveBeenCalledTimes(1);
const path = navigateMock.mock.calls[0][0] as string; expect(navigateMock).toHaveBeenCalledWith('/kubernetes/pods');
expect(path).toContain('namespace=production');
}); });
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([ mockApiResponse([
makeDeployment({ id: '1', name: 'dep-a', kubernetes: { namespace: 'kube-system' } }), makeDeployment({ id: '1', name: 'dep-a', kubernetes: { namespace: 'kube-system' } }),
]); ]);
@@ -796,8 +794,7 @@ describe('K8sDeploymentsDrawer', () => {
fireEvent.click(viewPodsBtn); fireEvent.click(viewPodsBtn);
expect(navigateMock).toHaveBeenCalledTimes(1); expect(navigateMock).toHaveBeenCalledTimes(1);
const path = navigateMock.mock.calls[0][0] as string; expect(navigateMock).toHaveBeenCalledWith('/kubernetes/pods');
expect(path).toContain('namespace=kube-system');
}); });
it('does not navigate when cluster is empty', async () => { it('does not navigate when cluster is empty', async () => {
@@ -33,7 +33,7 @@ describe('K8sNamespacesDrawer', () => {
cleanup(); 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' }])); apiFetchJSONMock.mockResolvedValueOnce(makeResponse([{ namespace: 'default' }]));
render(() => <K8sNamespacesDrawer cluster="cluster-a" />); render(() => <K8sNamespacesDrawer cluster="cluster-a" />);
@@ -44,10 +44,10 @@ describe('K8sNamespacesDrawer', () => {
await fireEvent.click(screen.getByRole('button', { name: 'Open All Pods' })); 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' }])); apiFetchJSONMock.mockResolvedValueOnce(makeResponse([{ namespace: 'kube-system' }]));
render(() => <K8sNamespacesDrawer cluster="cluster-a" />); render(() => <K8sNamespacesDrawer cluster="cluster-a" />);
@@ -58,9 +58,7 @@ describe('K8sNamespacesDrawer', () => {
await fireEvent.click(screen.getByRole('button', { name: 'Open Pods' })); await fireEvent.click(screen.getByRole('button', { name: 'Open Pods' }));
expect(navigateMock).toHaveBeenCalledWith( expect(navigateMock).toHaveBeenCalledWith('/kubernetes/pods');
'/workloads?type=pod&context=cluster-a&namespace=kube-system',
);
}); });
it('does not navigate when cluster is empty', async () => { it('does not navigate when cluster is empty', async () => {
@@ -1,5 +1,4 @@
import { createMemo, Show } from 'solid-js'; import { createMemo, Show } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import type { VM } from '@/types/api'; import type { VM } from '@/types/api';
import type { WorkloadGuest } from '@/types/workloads'; import type { WorkloadGuest } from '@/types/workloads';
import { formatUptime, formatSpeed, getShortImageName } from '@/utils/format'; import { formatUptime, formatSpeed, getShortImageName } from '@/utils/format';
@@ -41,7 +40,6 @@ export type { GuestRowProps, WorkloadIOEmphasis } from './guestRowModel';
import { getGuestColumnStyle } from './guestRowModel'; import { getGuestColumnStyle } from './guestRowModel';
export function GuestRow(props: GuestRowProps) { export function GuestRow(props: GuestRowProps) {
const navigate = useNavigate();
const { const {
agentVersion, agentVersion,
appContainerRuntimeLabel, appContainerRuntimeLabel,
@@ -66,7 +64,6 @@ export function GuestRow(props: GuestRowProps) {
hasOsInfo, hasOsInfo,
infoTooltip, infoTooltip,
infoValue, infoValue,
infrastructureHref,
ipAddresses, ipAddresses,
isColVisible, isColVisible,
isMobile, isMobile,
@@ -437,17 +434,12 @@ export function GuestRow(props: GuestRowProps) {
</span> </span>
} }
> >
<button <span
type="button" class="text-xs text-base-content truncate max-w-[80px]"
class="text-xs text-blue-600 dark:text-blue-400 hover:underline truncate max-w-[80px]" title={props.guest.node}
title={`Open infrastructure node ${props.guest.node}`}
onClick={(event) => {
event.stopPropagation();
navigate(infrastructureHref());
}}
> >
{props.guest.node} {props.guest.node}
</button> </span>
</Show> </Show>
</div> </div>
</td> </td>
@@ -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', () => ({ vi.mock('../workloadTopology', () => ({
getWorkloadAlertResourceIdCandidates: (guest: WorkloadGuest) => [guest.id], getWorkloadAlertResourceIdCandidates: (guest: WorkloadGuest) => [guest.id],
getWorkloadAlertThresholdScope: (guest: WorkloadGuest) => getWorkloadAlertThresholdScope: (guest: WorkloadGuest) =>
@@ -543,23 +534,26 @@ describe('GuestRow', () => {
}); });
describe('node column', () => { describe('node column', () => {
it('renders node name as a link button', () => { it('renders node name as plain text in the platform-first layout', () => {
renderGuestRow({ renderGuestRow({
guest: makeGuest({ node: 'pve1' }), guest: makeGuest({ node: 'pve1' }),
visibleColumnIds: ['name', 'node'], visibleColumnIds: ['name', 'node'],
}); });
const nodeButton = screen.getByText('pve1'); const nodeLabel = screen.getByText('pve1');
expect(nodeButton.tagName).toBe('BUTTON'); // 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({ renderGuestRow({
guest: makeGuest({ node: 'pve1' }), guest: makeGuest({ node: 'pve1' }),
visibleColumnIds: ['name', 'node'], visibleColumnIds: ['name', 'node'],
}); });
const nodeButton = screen.getByText('pve1'); const nodeLabel = screen.getByText('pve1');
fireEvent.click(nodeButton); fireEvent.click(nodeLabel);
expect(mockNavigate).toHaveBeenCalledWith('/infrastructure/node1'); expect(mockNavigate).not.toHaveBeenCalled();
}); });
}); });
@@ -1159,19 +1153,6 @@ describe('info merged column', () => {
}); });
describe('event propagation', () => { 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', () => { it('name custom URL click does not trigger row onClick', () => {
const rowClick = vi.fn(); const rowClick = vi.fn();
const { container } = renderGuestRow({ const { container } = renderGuestRow({
@@ -1058,7 +1058,9 @@ describe('Workloads performance contract', () => {
expect(guestRowModelSource).toContain('export const VIEW_MODE_COLUMNS'); expect(guestRowModelSource).toContain('export const VIEW_MODE_COLUMNS');
expect(guestRowStateSource).toContain('getCanonicalWorkloadId'); expect(guestRowStateSource).toContain('getCanonicalWorkloadId');
expect(guestRowStateSource).toContain('buildMetricKey'); 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).toContain("from './workloadTopology'");
expect(guestRowStateSource).not.toContain('./infrastructureLink'); expect(guestRowStateSource).not.toContain('./infrastructureLink');
expect(guestRowModelSource).not.toContain("id: 'link'"); expect(guestRowModelSource).not.toContain("id: 'link'");
@@ -14,7 +14,6 @@ import {
isDockerManagedAppContainer, isDockerManagedAppContainer,
resolveWorkloadType, resolveWorkloadType,
} from '@/utils/workloads'; } from '@/utils/workloads';
import { buildInfrastructureHrefForWorkload } from '@/routing/resourceLinks';
import { getWorkloadTypeBadge } from '@/components/shared/workloadTypeBadges'; import { getWorkloadTypeBadge } from '@/components/shared/workloadTypeBadges';
import { import {
@@ -35,7 +34,6 @@ export function useGuestRowState(props: GuestRowProps) {
const alertsActivation = useAlertsActivation(); const alertsActivation = useAlertsActivation();
const guestId = createMemo(() => getCanonicalWorkloadId(props.guest)); const guestId = createMemo(() => getCanonicalWorkloadId(props.guest));
const infrastructureHref = createMemo(() => buildInfrastructureHrefForWorkload(props.guest));
const visibleColumnIdSet = createMemo(() => const visibleColumnIdSet = createMemo(() =>
props.visibleColumnIds ? new Set(props.visibleColumnIds) : null, props.visibleColumnIds ? new Set(props.visibleColumnIds) : null,
@@ -297,7 +295,6 @@ export function useGuestRowState(props: GuestRowProps) {
hasOsInfo, hasOsInfo,
infoTooltip, infoTooltip,
infoValue, infoValue,
infrastructureHref,
ipAddresses, ipAddresses,
isColVisible, isColVisible,
isMobile, isMobile,
@@ -14,11 +14,13 @@ interface KeyboardShortcutsModalProps {
const UNIFIED_NAV_SHORTCUTS: ShortcutGroup = { const UNIFIED_NAV_SHORTCUTS: ShortcutGroup = {
title: 'Navigation', title: 'Navigation',
items: [ items: [
{ keys: 'g then i', description: 'Go to Infrastructure' }, { keys: 'g then p', description: 'Go to Proxmox' },
{ keys: 'g then w', description: 'Go to Workloads' }, { keys: 'g then d', description: 'Go to Docker' },
{ keys: 'g then s', description: 'Go to Storage' }, { keys: 'g then k', description: 'Go to Kubernetes' },
{ keys: 'g then b', description: 'Go to Recovery' }, { 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 a', description: 'Go to Alerts' },
{ keys: 'g then r', description: 'Go to Patrol' },
{ keys: 'g then t', description: 'Go to Settings' }, { keys: 'g then t', description: 'Go to Settings' },
], ],
}; };
@@ -157,7 +157,6 @@ import deployConfirmStepSource from '@/components/Infrastructure/deploy/ConfirmS
import deployDeployingStepSource from '@/components/Infrastructure/deploy/DeployingStep.tsx?raw'; import deployDeployingStepSource from '@/components/Infrastructure/deploy/DeployingStep.tsx?raw';
import deployPreflightStepSource from '@/components/Infrastructure/deploy/PreflightStep.tsx?raw'; import deployPreflightStepSource from '@/components/Infrastructure/deploy/PreflightStep.tsx?raw';
import deployResultsStepSource from '@/components/Infrastructure/deploy/ResultsStep.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 pmgMailGatewaySource from '@/components/PMG/MailGateway.tsx?raw';
import pmgInstancePanelSource from '@/components/PMG/PMGInstancePanel.tsx?raw'; import pmgInstancePanelSource from '@/components/PMG/PMGInstancePanel.tsx?raw';
import resourceDetailDrawerOverviewSource from '@/components/Infrastructure/ResourceDetailDrawerOverviewTab.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('useNavigate');
expect(commandPaletteStateSource).toContain('createSignal'); expect(commandPaletteStateSource).toContain('createSignal');
expect(commandPaletteStateSource).toContain('buildInfrastructurePath'); expect(commandPaletteStateSource).toContain('buildProxmoxPath');
expect(commandPaletteStateSource).toContain('export function useCommandPaletteState'); 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('buildCommandPaletteCommands');
expect(commandPaletteModelSource).toContain('normalizeCommandPaletteQuery'); expect(commandPaletteModelSource).toContain('normalizeCommandPaletteQuery');
@@ -560,7 +565,6 @@ describe('shared primitive guardrails', () => {
infrastructureSourceManagerSource, infrastructureSourceManagerSource,
configuredNodeTablesSource, configuredNodeTablesSource,
aiCostDashboardSource, aiCostDashboardSource,
cephPageSource,
deployCandidatesStepSource, deployCandidatesStepSource,
deployConfirmStepSource, deployConfirmStepSource,
deployDeployingStepSource, deployDeployingStepSource,
@@ -32,28 +32,46 @@ describe('CommandPaletteModal', () => {
expect(commandPaletteStateSource).toContain('useNavigate'); expect(commandPaletteStateSource).toContain('useNavigate');
expect(commandPaletteStateSource).toContain('createSignal'); expect(commandPaletteStateSource).toContain('createSignal');
expect(commandPaletteStateSource).toContain('buildInfrastructurePath'); expect(commandPaletteStateSource).toContain('buildProxmoxPath');
expect(commandPaletteStateSource).toContain('export function useCommandPaletteState'); 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('buildCommandPaletteCommands');
expect(commandPaletteModelSource).toContain('normalizeCommandPaletteQuery'); expect(commandPaletteModelSource).toContain('normalizeCommandPaletteQuery');
expect(commandPaletteModelSource).toContain('filterCommandPaletteCommands'); 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()} />); 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('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(); const onClose = vi.fn();
render(() => <CommandPaletteModal isOpen={true} onClose={onClose} />); render(() => <CommandPaletteModal isOpen={true} onClose={onClose} />);
await fireEvent.click(screen.getByText('Go to Kubernetes Pods')); await fireEvent.click(screen.getByText('Go to Kubernetes Pods'));
expect(navigateMock).toHaveBeenCalledWith('/workloads?type=pod'); expect(navigateMock).toHaveBeenCalledWith('/kubernetes/pods');
expect(onClose).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1);
}); });
@@ -62,10 +80,10 @@ describe('CommandPaletteModal', () => {
render(() => <CommandPaletteModal isOpen={true} onClose={onClose} />); render(() => <CommandPaletteModal isOpen={true} onClose={onClose} />);
const input = screen.getByPlaceholderText('Type a command or search...'); 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' }); await fireEvent.keyDown(input, { key: 'Enter' });
expect(navigateMock).toHaveBeenCalledWith('/workloads?type=pod'); expect(navigateMock).toHaveBeenCalledWith('/kubernetes/pods');
expect(onClose).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -13,11 +13,12 @@ export type CommandPaletteModalCommand = {
}; };
export type CommandPaletteCommandPaths = { export type CommandPaletteCommandPaths = {
infrastructurePath: string; proxmoxPath: string;
workloadsPath: string; dockerPath: string;
podWorkloadsPath: string; kubernetesPath: string;
storagePath: string; kubernetesPodsPath: string;
recoveryPath: string; trueNasPath: string;
vmwarePath: string;
}; };
export function buildCommandPaletteCommands(options: { export function buildCommandPaletteCommands(options: {
@@ -26,51 +27,51 @@ export function buildCommandPaletteCommands(options: {
}): CommandPaletteModalCommand[] { }): CommandPaletteModalCommand[] {
return [ return [
{ {
id: 'nav-infrastructure', id: 'nav-proxmox',
label: 'Go to Infrastructure', label: 'Go to Proxmox',
description: options.paths.infrastructurePath, description: options.paths.proxmoxPath,
shortcut: 'g i', shortcut: 'g p',
keywords: ['infra', 'agents', 'nodes', 'resources'], keywords: ['proxmox', 'pve', 'pbs', 'pmg', 'mail', 'backups', 'ceph', 'vm', 'lxc'],
action: () => options.navigate(options.paths.infrastructurePath), action: () => options.navigate(options.paths.proxmoxPath),
}, },
{ {
id: 'nav-workloads', id: 'nav-docker',
label: 'Go to Workloads', label: 'Go to Docker',
description: options.paths.workloadsPath, description: options.paths.dockerPath,
shortcut: 'g w', shortcut: 'g d',
keywords: [ keywords: ['docker', 'podman', 'containers', 'compose', 'swarm', 'services'],
'vm', action: () => options.navigate(options.paths.dockerPath),
'system-container',
'app-container',
'docker',
'k8s',
'kubernetes',
'pods',
],
action: () => options.navigate(options.paths.workloadsPath),
}, },
{ {
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', label: 'Go to Kubernetes Pods',
description: options.paths.podWorkloadsPath, description: options.paths.kubernetesPodsPath,
keywords: ['k8s', 'kubernetes', 'pods', 'deployments', 'clusters'], keywords: ['k8s', 'kubernetes', 'pods', 'workloads'],
action: () => options.navigate(options.paths.podWorkloadsPath), action: () => options.navigate(options.paths.kubernetesPodsPath),
}, },
{ {
id: 'nav-storage', id: 'nav-truenas',
label: 'Go to Storage', label: 'Go to TrueNAS',
description: options.paths.storagePath, description: options.paths.trueNasPath,
shortcut: 'g s', shortcut: 'g n',
keywords: ['ceph', 'pbs'], keywords: ['truenas', 'storage', 'disks', 'apps'],
action: () => options.navigate(options.paths.storagePath), action: () => options.navigate(options.paths.trueNasPath),
}, },
{ {
id: 'nav-recovery', id: 'nav-vmware',
label: 'Go to Recovery', label: 'Go to vSphere',
description: options.paths.recoveryPath, description: options.paths.vmwarePath,
shortcut: 'g b', shortcut: 'g v',
keywords: ['recovery', 'backups', 'snapshots', 'replication', 'restore'], keywords: ['vmware', 'vsphere', 'esxi', 'vms', 'datastores'],
action: () => options.navigate(options.paths.recoveryPath), action: () => options.navigate(options.paths.vmwarePath),
}, },
{ {
id: 'nav-alerts', id: 'nav-alerts',
@@ -80,6 +81,14 @@ export function buildCommandPaletteCommands(options: {
keywords: ['alarms', 'notifications'], keywords: ['alarms', 'notifications'],
action: () => options.navigate('/alerts'), 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', id: 'nav-settings',
label: 'Go to Settings', label: 'Go to Settings',
@@ -1,10 +1,11 @@
import { useNavigate } from '@solidjs/router'; import { useNavigate } from '@solidjs/router';
import { createEffect, createMemo, createSignal } from 'solid-js'; import { createEffect, createMemo, createSignal } from 'solid-js';
import { import {
buildRecoveryPath, buildDockerPath,
buildInfrastructurePath, buildKubernetesPath,
buildStoragePath, buildProxmoxPath,
buildWorkloadsPath, buildTrueNASPath,
buildVmwarePath,
} from '@/routing/resourceLinks'; } from '@/routing/resourceLinks';
import { import {
buildCommandPaletteCommands, buildCommandPaletteCommands,
@@ -26,11 +27,12 @@ export function useCommandPaletteState(props: CommandPaletteModalProps) {
const commands = createMemo(() => const commands = createMemo(() =>
buildCommandPaletteCommands({ buildCommandPaletteCommands({
paths: { paths: {
infrastructurePath: buildInfrastructurePath(), proxmoxPath: buildProxmoxPath(),
workloadsPath: buildWorkloadsPath(), dockerPath: buildDockerPath(),
podWorkloadsPath: buildWorkloadsPath({ type: 'pod' }), kubernetesPath: buildKubernetesPath(),
storagePath: buildStoragePath(), kubernetesPodsPath: buildKubernetesPath('pods'),
recoveryPath: buildRecoveryPath(), trueNasPath: buildTrueNASPath(),
vmwarePath: buildVmwarePath(),
}, },
navigate, navigate,
}), }),
@@ -1,10 +1,11 @@
import { createSignal, onCleanup, type Accessor } from 'solid-js'; import { createSignal, onCleanup, type Accessor } from 'solid-js';
import { useNavigate } from '@solidjs/router'; import { useNavigate } from '@solidjs/router';
import { import {
buildRecoveryPath, buildDockerPath,
buildInfrastructurePath, buildKubernetesPath,
buildStoragePath, buildProxmoxPath,
buildWorkloadsPath, buildTrueNASPath,
buildVmwarePath,
} from '@/routing/resourceLinks'; } from '@/routing/resourceLinks';
import { focusActiveTypeToSearch } from '@/hooks/useTypeToSearch'; import { focusActiveTypeToSearch } from '@/hooks/useTypeToSearch';
@@ -76,11 +77,13 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
const getRoutes = (): Record<string, string> => { const getRoutes = (): Record<string, string> => {
return { return {
i: buildInfrastructurePath(), p: buildProxmoxPath(),
w: buildWorkloadsPath(), d: buildDockerPath(),
s: buildStoragePath(), k: buildKubernetesPath(),
b: buildRecoveryPath(), n: buildTrueNASPath(),
v: buildVmwarePath(),
a: '/alerts', a: '/alerts',
r: '/patrol',
t: '/settings', t: '/settings',
}; };
}; };
-689
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;
@@ -1,7 +0,0 @@
import { InfrastructurePageSurface } from '@/features/infrastructure/InfrastructurePageSurface';
export function Infrastructure() {
return <InfrastructurePageSurface />;
}
export default Infrastructure;
+3 -10
View File
@@ -4,7 +4,7 @@ import { useLocation, useNavigate } from '@solidjs/router';
import { Card } from '@/components/shared/Card'; import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState'; import { EmptyState } from '@/components/shared/EmptyState';
import { PageHeader } from '@/components/shared/PageHeader'; import { PageHeader } from '@/components/shared/PageHeader';
import { buildRecoveryPath, buildInfrastructurePath } from '@/routing/resourceLinks'; import { buildProxmoxPath } from '@/routing/resourceLinks';
const NotFound: Component = () => { const NotFound: Component = () => {
const location = useLocation(); const location = useLocation();
@@ -21,19 +21,12 @@ const NotFound: Component = () => {
description={`No route matched ${path()}.`} description={`No route matched ${path()}.`}
actions={ actions={
<div class="flex flex-wrap items-center gap-2"> <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 <button
type="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" 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> </button>
</div> </div>
} }
-7
View File
@@ -1,7 +0,0 @@
import RecoverySurface from '@/components/Recovery/Recovery';
export function Recovery() {
return <RecoverySurface />;
}
export default Recovery;
+2 -2
View File
@@ -1,10 +1,10 @@
import { useNavigate } from '@solidjs/router'; import { useNavigate } from '@solidjs/router';
import { createEffect } from 'solid-js'; import { createEffect } from 'solid-js';
import { buildInfrastructurePath } from '@/routing/resourceLinks'; import { buildProxmoxPath } from '@/routing/resourceLinks';
export default function RuntimeHome() { export default function RuntimeHome() {
const navigate = useNavigate(); const navigate = useNavigate();
const destination = () => buildInfrastructurePath(); const destination = () => buildProxmoxPath();
createEffect(() => { createEffect(() => {
navigate(destination(), { replace: true }); navigate(destination(), { replace: true });
-7
View File
@@ -1,7 +0,0 @@
import StorageSurface from '@/components/Storage/Storage';
export function Storage() {
return <StorageSurface />;
}
export default Storage;
-7
View File
@@ -1,7 +0,0 @@
import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface';
export function Workloads() {
return <WorkloadsSurface vms={[]} containers={[]} nodes={[]} useWorkloads />;
}
export default Workloads;
@@ -639,23 +639,18 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.getByText('2 learned patterns · explanatory context')).toBeInTheDocument(); expect(screen.getByText('2 learned patterns · explanatory context')).toBeInTheDocument();
expect(screen.getByText('Coverage posture for policy-covered resources.')).toBeInTheDocument(); expect(screen.getByText('Coverage posture for policy-covered resources.')).toBeInTheDocument();
expect(screen.getByText('3 policy-covered resources')).toBeInTheDocument(); expect(screen.getByText('3 policy-covered resources')).toBeInTheDocument();
const storage2Link = screen.getByRole('link', { // Cross-jump to /infrastructure?resource=... retired with the legacy
name: 'Open source resource Storage 2 in Infrastructure', // surface; correlation labels render as plain text and document order is
}); // verified via the resource label spans instead of anchor tags.
const storage1Link = screen.getByRole('link', {
name: 'Open source resource Storage 1 in Infrastructure',
});
expect(storage2Link).toHaveAttribute('href', '/infrastructure?resource=storage-2');
expect( expect(
screen.getByRole('link', { name: 'Open target resource VM 200 in Infrastructure' }), screen.queryByRole('link', { name: /Open source resource .* in Infrastructure/ }),
).toHaveAttribute('href', '/infrastructure?resource=vm-200'); ).toBeNull();
const storage2Label = screen.getByText('Storage 2');
const storage1Label = screen.getByText('Storage 1');
expect( expect(
screen.getByRole('link', { name: 'Open target resource VM 100 in Infrastructure' }), storage2Label.compareDocumentPosition(storage1Label) & Node.DOCUMENT_POSITION_FOLLOWING,
).toHaveAttribute('href', '/infrastructure?resource=vm-100');
expect(
storage2Link.compareDocumentPosition(storage1Link) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy(); ).toBeTruthy();
expect(screen.getByText('Storage 1')).toBeInTheDocument(); expect(screen.getByText('VM 200')).toBeInTheDocument();
expect(screen.getByText('VM 100')).toBeInTheDocument(); expect(screen.getByText('VM 100')).toBeInTheDocument();
expect(screen.getByText('Cpu High → Restart')).toBeInTheDocument(); expect(screen.getByText('Cpu High → Restart')).toBeInTheDocument();
expect(screen.getByText('Disk Full → Restart')).toBeInTheDocument(); expect(screen.getByText('Disk Full → Restart')).toBeInTheDocument();
@@ -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');
});
});
@@ -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();
});
});
});
@@ -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();
});
});
@@ -17,11 +17,11 @@ describe('RuntimeHome', () => {
navigateSpy.mockReset(); navigateSpy.mockReset();
}); });
it('routes authenticated runtimes straight to Infrastructure', async () => { it('routes authenticated runtimes straight to the Proxmox platform page', async () => {
render(() => <RuntimeHome />); render(() => <RuntimeHome />);
await waitFor(() => { await waitFor(() => {
expect(navigateSpy).toHaveBeenCalledWith('/infrastructure', { replace: true }); expect(navigateSpy).toHaveBeenCalledWith('/proxmox/overview', { replace: true });
}); });
}); });
}); });
@@ -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>',
);
});
});
@@ -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"');
});
});
@@ -7,11 +7,13 @@ describe('navigation routing helpers', () => {
expect(getActiveTabForPath('/dashboard')).toBeNull(); expect(getActiveTabForPath('/dashboard')).toBeNull();
expect(getActiveTabForPath('/proxmox')).toBe('proxmox'); expect(getActiveTabForPath('/proxmox')).toBe('proxmox');
expect(getActiveTabForPath('/proxmox/storage')).toBe('proxmox'); expect(getActiveTabForPath('/proxmox/storage')).toBe('proxmox');
expect(getActiveTabForPath('/infrastructure')).toBe('infrastructure'); // Legacy top-level routes (/infrastructure, /workloads, /storage,
expect(getActiveTabForPath('/workloads?type=pod')).toBe('workloads'); // /recovery, /ceph) were retired when primary nav moved to platform-first.
expect(getActiveTabForPath('/storage')).toBe('storage'); expect(getActiveTabForPath('/infrastructure')).toBeNull();
expect(getActiveTabForPath('/ceph')).toBe('storage'); expect(getActiveTabForPath('/workloads?type=pod')).toBeNull();
expect(getActiveTabForPath('/recovery')).toBe('recovery'); expect(getActiveTabForPath('/storage')).toBeNull();
expect(getActiveTabForPath('/ceph')).toBeNull();
expect(getActiveTabForPath('/recovery')).toBeNull();
expect(getActiveTabForPath('/alerts/open')).toBe('alerts'); expect(getActiveTabForPath('/alerts/open')).toBe('alerts');
expect(getActiveTabForPath('/patrol')).toBe('ai'); expect(getActiveTabForPath('/patrol')).toBe('ai');
expect(getActiveTabForPath('/ai')).toBe('ai'); expect(getActiveTabForPath('/ai')).toBe('ai');
@@ -1,27 +1,18 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { APP_SHELL_ROUTE_PRELOAD_PATHS } from '../routePreload'; import { APP_SHELL_ROUTE_PRELOAD_PATHS } from '../routePreload';
import { import { PATROL_PATH, buildProxmoxPath } from '../resourceLinks';
PATROL_PATH,
buildProxmoxPath,
buildRecoveryPath,
buildStoragePath,
buildWorkloadsPath,
} from '../resourceLinks';
describe('route preloading', () => { describe('route preloading', () => {
it('keeps Workloads in the authenticated app-shell preload set', () => { it('keeps Proxmox in the authenticated app-shell preload set', () => {
expect(APP_SHELL_ROUTE_PRELOAD_PATHS).toContain(buildWorkloadsPath()); expect(APP_SHELL_ROUTE_PRELOAD_PATHS).toContain(buildProxmoxPath());
}); });
it('keeps all cold top-level app shell targets in the shared preload set', () => { it('keeps all cold top-level app shell targets in the shared preload set', () => {
expect([...APP_SHELL_ROUTE_PRELOAD_PATHS]).toEqual([ expect([...APP_SHELL_ROUTE_PRELOAD_PATHS]).toEqual([
buildProxmoxPath(), buildProxmoxPath(),
buildWorkloadsPath(),
buildRecoveryPath(),
PATROL_PATH, PATROL_PATH,
'/alerts', '/alerts',
buildStoragePath(),
'/settings', '/settings',
]); ]);
}); });
-11
View File
@@ -1,12 +1,10 @@
import { import {
DOCKER_PATH, DOCKER_PATH,
INFRASTRUCTURE_PATH,
KUBERNETES_PATH, KUBERNETES_PATH,
PATROL_PATH, PATROL_PATH,
PROXMOX_PATH, PROXMOX_PATH,
TRUENAS_PATH, TRUENAS_PATH,
VMWARE_PATH, VMWARE_PATH,
WORKLOADS_PATH,
} from './resourceLinks'; } from './resourceLinks';
export type AppTabId = export type AppTabId =
@@ -15,10 +13,6 @@ export type AppTabId =
| 'kubernetes' | 'kubernetes'
| 'truenas' | 'truenas'
| 'vmware' | 'vmware'
| 'infrastructure'
| 'workloads'
| 'storage'
| 'recovery'
| 'alerts' | 'alerts'
| 'ai' | 'ai'
| 'settings'; | 'settings';
@@ -31,11 +25,6 @@ export function getActiveTabForPath(path: string): ActiveAppTabId {
if (path.startsWith(KUBERNETES_PATH)) return 'kubernetes'; if (path.startsWith(KUBERNETES_PATH)) return 'kubernetes';
if (path.startsWith(TRUENAS_PATH)) return 'truenas'; if (path.startsWith(TRUENAS_PATH)) return 'truenas';
if (path.startsWith(VMWARE_PATH)) return 'vmware'; 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('/alerts')) return 'alerts';
if (path.startsWith(PATROL_PATH) || path.startsWith('/ai')) return 'ai'; if (path.startsWith(PATROL_PATH) || path.startsWith('/ai')) return 'ai';
if (path.startsWith('/settings')) return 'settings'; if (path.startsWith('/settings')) return 'settings';
@@ -1,9 +1,5 @@
import { import {
buildInfrastructurePath,
buildProxmoxPath, buildProxmoxPath,
buildRecoveryPath,
buildStoragePath,
buildWorkloadsPath,
DOCKER_PATH, DOCKER_PATH,
KUBERNETES_PATH, KUBERNETES_PATH,
PATROL_PATH, PATROL_PATH,
@@ -18,22 +14,15 @@ type RoutePreloader = {
preload: () => Promise<void>; preload: () => Promise<void>;
}; };
const ROOT_INFRASTRUCTURE_PATH = buildInfrastructurePath();
const ROOT_PROXMOX_PATH = buildProxmoxPath(); const ROOT_PROXMOX_PATH = buildProxmoxPath();
const ROOT_WORKLOADS_PATH = buildWorkloadsPath();
const STORAGE_PATH = buildStoragePath();
const RECOVERY_ROUTE_PATH = buildRecoveryPath();
const ALERTS_PATH = '/alerts'; const ALERTS_PATH = '/alerts';
const SETTINGS_PATH = '/settings'; const SETTINGS_PATH = '/settings';
const routePreloadCache = new Map<string, Promise<void>>(); const routePreloadCache = new Map<string, Promise<void>>();
export const APP_SHELL_ROUTE_PRELOAD_PATHS = [ export const APP_SHELL_ROUTE_PRELOAD_PATHS = [
ROOT_PROXMOX_PATH, ROOT_PROXMOX_PATH,
ROOT_WORKLOADS_PATH,
RECOVERY_ROUTE_PATH,
PATROL_PATH, PATROL_PATH,
ALERTS_PATH, ALERTS_PATH,
STORAGE_PATH,
SETTINGS_PATH, SETTINGS_PATH,
] as const; ] as const;
@@ -77,30 +66,6 @@ const ROUTE_PRELOADERS: readonly RoutePreloader[] = [
preload: () => preload: () =>
import('@/pages/Vmware').then(() => undefined), 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', id: 'alerts',
matches: (route) => route === ALERTS_PATH || route.startsWith(`${ALERTS_PATH}/`), matches: (route) => route === ALERTS_PATH || route.startsWith(`${ALERTS_PATH}/`),
@@ -140,7 +140,6 @@ import updatesPresentationSource from '@/utils/updatesPresentation.ts?raw';
import environmentLockBadgeSource from '@/components/shared/EnvironmentLockBadge.tsx?raw'; import environmentLockBadgeSource from '@/components/shared/EnvironmentLockBadge.tsx?raw';
import environmentLockPresentationSource from '@/utils/environmentLockPresentation.ts?raw'; import environmentLockPresentationSource from '@/utils/environmentLockPresentation.ts?raw';
import dockerRuntimeSettingsCardSource from '@/components/Settings/DockerRuntimeSettingsCard.tsx?raw'; import dockerRuntimeSettingsCardSource from '@/components/Settings/DockerRuntimeSettingsCard.tsx?raw';
import infrastructurePageShellSource from '@/pages/Infrastructure.tsx?raw';
import operationsPageRouteSource from '@/pages/Operations.tsx?raw'; import operationsPageRouteSource from '@/pages/Operations.tsx?raw';
import discoveryTargetSource from '@/utils/discoveryTarget.ts?raw'; import discoveryTargetSource from '@/utils/discoveryTarget.ts?raw';
import infrastructureEmptyStatePresentationSource from '@/utils/infrastructureEmptyStatePresentation.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 nodeModalStatusFooterSource from '@/components/Settings/NodeModalStatusFooter.tsx?raw';
import nodeModalStateSource from '@/components/Settings/useNodeModalState.ts?raw'; import nodeModalStateSource from '@/components/Settings/useNodeModalState.ts?raw';
import nodeModalPresentationSource from '@/utils/nodeModalPresentation.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 diskPresentationSource from '@/features/storageBackups/diskPresentation.ts?raw';
import diskLiveMetricPresentationSource from '@/features/storageBackups/diskLiveMetricPresentation.ts?raw'; import diskLiveMetricPresentationSource from '@/features/storageBackups/diskLiveMetricPresentation.ts?raw';
import storageDetailPresentationSource from '@/features/storageBackups/detailPresentation.ts?raw'; import storageDetailPresentationSource from '@/features/storageBackups/detailPresentation.ts?raw';
@@ -604,7 +601,6 @@ const resourceDetailDrawerSource = [
].join('\n'); ].join('\n');
const infrastructurePageSource = [ const infrastructurePageSource = [
infrastructurePageShellSource,
infrastructurePageSurfaceSource, infrastructurePageSurfaceSource,
infrastructurePageRouteStateSource, infrastructurePageRouteStateSource,
infrastructurePageStateSource, infrastructurePageStateSource,
@@ -949,7 +945,9 @@ describe('frontend resource type boundaries', () => {
expect(guestRowSource).not.toContain('function NetworkInfoCell('); expect(guestRowSource).not.toContain('function NetworkInfoCell(');
expect(guestRowSource).not.toContain('function OSInfoCell('); expect(guestRowSource).not.toContain('function OSInfoCell(');
expect(guestRowStateSource).toContain('getCanonicalWorkloadId'); 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(guestRowStateSource).not.toContain('./infrastructureLink');
expect(guestRowModelSource).toContain('export const GUEST_COLUMNS'); expect(guestRowModelSource).toContain('export const GUEST_COLUMNS');
expect(guestRowCellsSource).toContain('function NetworkInfoCell('); expect(guestRowCellsSource).toContain('function NetworkInfoCell(');
@@ -3348,27 +3346,14 @@ describe('frontend resource type boundaries', () => {
expect(nodeModalPresentationSource).toContain( expect(nodeModalPresentationSource).toContain(
'export function buildNodeModalMonitoringPayload', 'export function buildNodeModalMonitoringPayload',
); );
expect(cephPageSource).toContain('getCephServiceStatusPresentation'); // Canonical Ceph presentation helpers still live in storageDomain and
expect(cephPageSource).toContain('getCephLoadingStatePresentation'); // are consumed by ProxmoxCephTable / ProxmoxCephClusterDrawer now that
expect(cephPageSource).toContain('getCephDisconnectedStatePresentation'); // the standalone /ceph page has been retired.
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 "');
expect(storageDomainSource).toContain('export const getCephServiceStatusPresentation'); expect(storageDomainSource).toContain('export const getCephServiceStatusPresentation');
expect(storageDomainSource).toContain('export const getCephLoadingStatePresentation'); expect(storageDomainSource).toContain('export const getCephLoadingStatePresentation');
expect(storageDomainSource).toContain('export const getCephDisconnectedStatePresentation'); expect(storageDomainSource).toContain('export const getCephDisconnectedStatePresentation');
expect(storageDomainSource).toContain('export const getCephNoClustersStatePresentation'); expect(storageDomainSource).toContain('export const getCephNoClustersStatePresentation');
expect(storageDomainSource).toContain('export const getCephPoolsSearchEmptyStatePresentation'); expect(storageDomainSource).toContain('export const getCephPoolsSearchEmptyStatePresentation');
expect(cephServiceIconSource).toContain('export const CephServiceIcon');
expect(deployStatusBadgeSource).toContain('getDeployStatusPresentation'); expect(deployStatusBadgeSource).toContain('getDeployStatusPresentation');
expect(deployStatusBadgeSource).not.toContain('const statusConfig: Record<DeployTargetStatus'); expect(deployStatusBadgeSource).not.toContain('const statusConfig: Record<DeployTargetStatus');
expect(deployCandidatesStepSource).toContain('getDeployCandidatesLoadingState'); expect(deployCandidatesStepSource).toContain('getDeployCandidatesLoadingState');
@@ -148,7 +148,6 @@ def first_matching_policy_id(rule: dict, rel: str) -> str:
RECOVERY_PRODUCT_SURFACE_EXACT_FILES = [ RECOVERY_PRODUCT_SURFACE_EXACT_FILES = [
"frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx", "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", "frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts",
"tests/integration/tests/17-recovery-layout.spec.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__/InfrastructurePageSurface.guardrails.test.ts",
"frontend-modern/src/features/infrastructure/__tests__/infrastructurePageModel.test.ts", "frontend-modern/src/features/infrastructure/__tests__/infrastructurePageModel.test.ts",
"frontend-modern/src/hooks/__tests__/useUnifiedResources.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/routing/__tests__/resourceLinks.test.ts",
"frontend-modern/src/stores/__tests__/websocket-unified.test.ts", "frontend-modern/src/stores/__tests__/websocket-unified.test.ts",
"frontend-modern/src/types/__tests__/resource.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): def test_recovery_surface_change_requires_storage_recovery_contract(self):
required = infer_impacted_subsystems(["frontend-modern/src/pages/Recovery.tsx"]) # 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"}) self.assertEqual(set(required), {"storage-recovery"})
recovery = required["storage-recovery"] recovery = required["storage-recovery"]
@@ -1131,7 +1134,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
) )
self.assertEqual( self.assertEqual(
recovery["touched_runtime_files"], recovery["touched_runtime_files"],
["frontend-modern/src/pages/Recovery.tsx"], ["frontend-modern/src/components/Recovery/Recovery.tsx"],
) )
self.assertEqual( self.assertEqual(
recovery["verification_requirements"], recovery["verification_requirements"],
@@ -1139,7 +1142,9 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
{ {
"id": "recovery-product-surface", "id": "recovery-product-surface",
"label": "recovery product surface proof", "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, "allow_same_subsystem_tests": False,
"test_prefixes": [], "test_prefixes": [],
"exact_files": RECOVERY_PRODUCT_SURFACE_EXACT_FILES, "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__/storageModelCore.test.ts",
"frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts", "frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts",
"frontend-modern/src/features/storageBackups/__tests__/storagePoolsTablePresentation.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", "frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts",
], ],
} }
@@ -1299,8 +1303,14 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
) )
self.assertEqual(required, {}) self.assertEqual(required, {})
def test_storage_page_route_change_requires_storage_recovery_contract(self): def test_storage_surface_change_requires_storage_recovery_contract(self):
required = infer_impacted_subsystems(["frontend-modern/src/pages/Storage.tsx"]) # 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"}) self.assertEqual(set(required), {"storage-recovery"})
recovery = required["storage-recovery"] recovery = required["storage-recovery"]
@@ -1310,7 +1320,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
) )
self.assertEqual( self.assertEqual(
recovery["touched_runtime_files"], recovery["touched_runtime_files"],
["frontend-modern/src/pages/Storage.tsx"], ["frontend-modern/src/components/Storage/Storage.tsx"],
) )
self.assertEqual( self.assertEqual(
recovery["verification_requirements"][0]["id"], 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__/workloadTopology.test.ts",
"frontend-modern/src/components/Workloads/__tests__/workloadUrlSyncModel.test.ts", "frontend-modern/src/components/Workloads/__tests__/workloadUrlSyncModel.test.ts",
"frontend-modern/src/hooks/__tests__/useWorkloads.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__/thresholdSliderPresentation.test.ts",
"frontend-modern/src/utils/__tests__/workloadsSummaryCache.test.ts", "frontend-modern/src/utils/__tests__/workloadsSummaryCache.test.ts",
], ],
@@ -7,7 +7,6 @@ from subsystem_lookup import lookup_paths, parse_args, render_pretty
RECOVERY_PRODUCT_SURFACE_EXACT_FILES = [ RECOVERY_PRODUCT_SURFACE_EXACT_FILES = [
"frontend-modern/src/components/Recovery/__tests__/Recovery.test.tsx", "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", "frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts",
"tests/integration/tests/17-recovery-layout.spec.ts", "tests/integration/tests/17-recovery-layout.spec.ts",
] ]
@@ -733,8 +732,11 @@ class SubsystemLookupTest(unittest.TestCase):
"relay-frontend-surfaces", "relay-frontend-surfaces",
) )
def test_lookup_paths_assigns_recovery_route_to_storage_recovery(self) -> None: def test_lookup_paths_assigns_recovery_surface_to_storage_recovery(self) -> None:
result = lookup_paths(["frontend-modern/src/pages/Recovery.tsx"]) # 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(result["unowned_runtime_files"], [])
self.assertEqual( self.assertEqual(
{item["subsystem"] for item in result["impacted_subsystems"]}, {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__/storageModelCore.test.ts",
"frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts", "frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts",
"frontend-modern/src/features/storageBackups/__tests__/storagePoolsTablePresentation.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", "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["classification"], "runtime")
self.assertEqual(file_entry["matches"], []) self.assertEqual(file_entry["matches"], [])
def test_lookup_paths_assigns_storage_page_to_storage_recovery(self) -> None: def test_lookup_paths_assigns_storage_surface_to_storage_recovery(self) -> None:
result = lookup_paths(["frontend-modern/src/pages/Storage.tsx"]) # 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(result["unowned_runtime_files"], [])
self.assertEqual( self.assertEqual(
{item["subsystem"] for item in result["impacted_subsystems"]}, {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__/InfrastructurePageSurface.guardrails.test.ts",
"frontend-modern/src/features/infrastructure/__tests__/infrastructurePageModel.test.ts", "frontend-modern/src/features/infrastructure/__tests__/infrastructurePageModel.test.ts",
"frontend-modern/src/hooks/__tests__/useUnifiedResources.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/routing/__tests__/resourceLinks.test.ts",
"frontend-modern/src/stores/__tests__/websocket-unified.test.ts", "frontend-modern/src/stores/__tests__/websocket-unified.test.ts",
"frontend-modern/src/types/__tests__/resource.test.ts", "frontend-modern/src/types/__tests__/resource.test.ts",