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