From ba4a6c46d9f5a660cdea2d91cac33d9e538bf429 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 19 Aug 2026 17:53:37 +0100 Subject: [PATCH] fix(frontend): fold estate totals into platform controls --- .../subsystems/frontend-primitives.md | 36 +- .../subsystems/performance-and-scalability.md | 4 + .../v6/internal/subsystems/registry.json | 5 +- .../internal/subsystems/storage-recovery.md | 16 +- .../internal/subsystems/unified-resources.md | 16 +- frontend-modern/browser-verification.json | 69 ++-- .../scripts/shared-template-registry.json | 14 +- .../components/Workloads/WorkloadsFilter.tsx | 49 +++ .../__tests__/WorkloadsFilter.test.tsx | 62 ++++ .../components/Workloads/useWorkloadsState.ts | 3 + .../Workloads/workloadsFilterModel.ts | 12 + .../components/shared/FilterBar/FilterBar.tsx | 1 + .../components/shared/FilterButtonGroup.tsx | 17 +- .../SharedPrimitives.guardrails.test.ts | 20 +- .../shared/filterButtonGroupModel.ts | 1 + .../src/features/docker/DockerPageSurface.tsx | 2 - .../kubernetes/KubernetesPageSurface.tsx | 2 - .../platformPage/PlatformEstateOverview.tsx | 148 -------- .../__tests__/PlatformEstateOverview.test.tsx | 88 ----- .../platformEstateOverviewModel.test.ts | 133 ++----- .../platformOverviewLayout.guardrails.test.ts | 6 +- .../__tests__/sharedPlatformPage.test.ts | 16 +- .../platformEstateOverviewModel.ts | 346 +----------------- .../platformPage/sharedPlatformPage.tsx | 42 ++- .../features/proxmox/ProxmoxNodesTable.tsx | 44 ++- .../features/proxmox/ProxmoxPageSurface.tsx | 41 +-- .../ProxmoxPageSurface.contract.test.tsx | 30 +- .../standalone/StandalonePageSurface.tsx | 2 - .../features/truenas/TrueNASPageSurface.tsx | 2 - .../src/features/vmware/VmwarePageSurface.tsx | 3 +- 30 files changed, 397 insertions(+), 833 deletions(-) delete mode 100644 frontend-modern/src/features/platformPage/PlatformEstateOverview.tsx delete mode 100644 frontend-modern/src/features/platformPage/__tests__/PlatformEstateOverview.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 618200dd8..0e8ac8a66 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -217,8 +217,7 @@ puts the same machine on two surfaces that do not share an identity. 131a. `frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx` 131b. `frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx` 131c. `frontend-modern/src/features/platformPage/PlatformOutdatedSensorSetupNotice.tsx` - 131d. `frontend-modern/src/features/platformPage/PlatformEstateOverview.tsx` - 131e. `frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts` + 131d. `frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts` 132. `frontend-modern/src/utils/platformSupportManifest.generated.ts` 133. `frontend-modern/src/utils/platformSupportManifest.ts` 134. `frontend-modern/src/utils/sourcePlatformOptions.ts` @@ -2058,20 +2057,21 @@ default` instead of fusing provider and badge text such as grouped-by-host containers view) sort within groups while grouping itself stays orthogonal to sort state. -18. Keep estate orientation on one canonical platform primitive. Proxmox, - Docker / Podman, Kubernetes, TrueNAS, VMware vSphere, and Standalone - Machines overview surfaces must compose - `PlatformEstateOverview.tsx` and pass their already-loaded, platform-scoped - canonical resource set into `platformEstateOverviewModel.ts`. The model - owns metric definitions, actionable-status classification, spotlight - priority, bounded spotlight count, and platform workflow destinations; a - platform page may not recreate those rules or retain a provider-only - summary. The renderer owns the compact responsive metric frame and - spotlight presentation. Its single `platformEstateOverviewVisible` - preference is global and browser-persisted, so an operator's Hide or Show - choice follows them between platform pages and reloads without initiating - another resource fetch. Table-local filters and counts remain separate - consumer concerns and must not be promoted into a competing overview. +18. Keep estate orientation inside the canonical controls operators already + scan. Large-estate workload totals must flow from the unfiltered shared + workload inventory into `WorkloadsFilter.tsx`, through the canonical + `FilterBar` / `FilterButtonGroup` option-count contract, so type and status + buttons expose their totals without another fetch or a competing metric + panel. Provider topology belongs in the existing table header; Proxmox + derives cluster and standalone-node context through + `platformEstateOverviewModel.ts` and supplies it to `ProxmoxNodesTable.tsx` + rather than creating a page-level summary. The existing + `platformEstateOverviewVisible` preference now governs these inline totals + from the shared View menu, preserving the global browser-persisted choice + across platform workload surfaces. Docker / Podman, Kubernetes, TrueNAS, + VMware vSphere, and Standalone Machines must continue using their shared + `PlatformTableToolbar` counters and table headers; none may add a parallel + estate card grid or provider-only spotlight surface. ## Forbidden Paths @@ -2098,8 +2098,8 @@ default` instead of fusing provider and badge text such as Retired aliases must fail route eligibility instead of being kept as compatibility redirects. 8. Platform pages implementing local estate metric cards, operational - spotlight classifiers, or separate visibility preferences instead of - composing the canonical platform estate overview. + spotlight panels, or separate visibility preferences instead of projecting + totals into the canonical filter and table-header controls. ## Completion Obligations diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index d5da6aeca..879a96a60 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -715,6 +715,10 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant. reads the unfiltered guest set so narrowing the table by search or status never removes the column. 18. Extend workload filter active-count, reset semantics, and mobile toolbar state through `frontend-modern/src/components/Workloads/workloadsFilterModel.ts` (defaults, `countActiveWorkloadsFilters`, `hasActiveWorkloadsFilters`) rather than rebuilding filter-local state inside `frontend-modern/src/components/Workloads/WorkloadsFilter.tsx`. Workloads filter presentation now composes the shared `FilterBar` (`frontend-modern/src/components/shared/FilterBar/FilterBar.tsx`) with a per-page `FilterDef[]` catalog rather than the legacy `PageControls` structured control deck. High-frequency Type and Status filters stay in that catalog but render as inline compact segmented controls (`inline: true`), while longer or dynamic scope filters continue through the "+ Filter" menu and chip popovers. The Add filter control inherits FilterBar's compact accessible-only label by default instead of paying for a page-local labelled-field shell. Durable presentation controls pass only their panel content through `FilterBar.viewOptions`; the shared FilterBar owns the single View trigger and popover. Contextual actions use `leadingControls`, while frequently changed analytical orientation such as the active trend range uses `trailingControls`. + Large-estate option counts must reuse one memoized stats pass over the + unfiltered workload snapshot from `useWorkloadsState`; type and status + options read that bounded projection and must not rescan resources during + render or issue a separate summary request. Workload filter option semantics stay workload-owned, but FilterBar chip presentation is frontend-primitives-owned: status and runtime leading dots must use `filterChipStatusDot` rather than workload-local span factories. diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index d00cd53c5..f986f0fe8 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -4727,7 +4727,6 @@ "frontend-modern/src/components/Toast/Toast.tsx", "frontend-modern/src/components/Workloads/nodeDrawerModel.ts", "frontend-modern/src/features/docker/dockerHostDrawerModel.ts", - "frontend-modern/src/features/platformPage/PlatformEstateOverview.tsx", "frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts", "frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx", "frontend-modern/src/features/platformPage/PlatformOutdatedSensorSetupNotice.tsx", @@ -5242,7 +5241,6 @@ "match_files": [ "frontend-modern/scripts/shared-template-audit.mjs", "frontend-modern/scripts/shared-template-registry.json", - "frontend-modern/src/features/platformPage/PlatformEstateOverview.tsx", "frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts", "frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx", "frontend-modern/src/features/platformPage/PlatformOutdatedSensorSetupNotice.tsx", @@ -5253,7 +5251,7 @@ "test_prefixes": [], "exact_files": [ "frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts", - "frontend-modern/src/features/platformPage/__tests__/PlatformEstateOverview.test.tsx", + "frontend-modern/src/components/Workloads/__tests__/WorkloadsFilter.test.tsx", "frontend-modern/src/features/platformPage/__tests__/platformEstateOverviewModel.test.ts", "frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts" ] @@ -7218,6 +7216,7 @@ "frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx", "frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx", "frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx", + "frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx", "tests/integration/tests/90-operational-trust-protection-posture.spec.ts" ] }, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 1bdf5220e..e0b5404f4 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -403,14 +403,14 @@ Patrol coverage, schedule, finding, and approval state belongs on Patrol-owned surfaces or explicit Patrol affordances; Proxmox overview must not treat it as backup coverage, restore readiness, PBS verification/protection proof, or a replacement for the Proxmox Backups tab and workload Backup column. -The Proxmox estate-at-a-glance metrics and operational spotlights are likewise -an adjacent frontend-primitives and unified-resource projection, not backup or -recovery evidence. `ProxmoxPageSurface` must compose the canonical shared -platform estate overview with its already-loaded Proxmox resource set rather -than deriving a Proxmox-only summary. A spotlight may route into the owned -Storage, Backups, Ceph, or Mail workflows, but it must not imply protection, -verification, or restore readiness beyond the evidence held by those -workflow-owned surfaces. +The Proxmox inline estate totals are likewise an adjacent frontend-primitives +and unified-resource projection, not backup or recovery evidence. +`ProxmoxPageSurface` must flow the already-loaded workload inventory into the +shared workload filter counts and derive cluster/standalone topology through +`platformEstateOverviewModel.ts` for the existing Nodes header rather than +creating a Proxmox-only metric or spotlight panel. Those counts must not imply +protection, verification, or restore readiness beyond the evidence held by the +workflow-owned Storage, Backups, Ceph, and Mail surfaces. Proxmox backup inventory loading and load-failure chrome is likewise a frontend-primitives dependency. `ProxmoxBackupsTable` owns the backup API queries, recovery model, filters, coverage split, and backup-specific error diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 7aae64d08..6f4aba8f8 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -280,15 +280,13 @@ about the same disk cannot diverge. ## Shared Boundaries Platform estate orientation consumes the same canonical resource projection as -the platform's tables. Docker / Podman, Kubernetes, TrueNAS, VMware vSphere, -Standalone Machines, and the storage/recovery-owned Proxmox surface pass their -already-loaded scoped resource set into the frontend-primitives-owned -`platformEstateOverviewModel.ts`; pages must not issue a summary fetch, infer -membership from a second platform heuristic, or locally reinterpret resource -status, incidents, alerts, or storage pressure. Unified resources owns the -identity, type, platform membership, status, and evidence fields supplied to -that shared model. Frontend primitives owns how those fields become the four -orientation metrics and bounded operational spotlights. +the platform's tables. Pages must not issue a second summary fetch or create a +parallel metric-card projection. Unified resources owns identity, type, +platform membership, and status; frontend primitives owns how the unfiltered +workload inventory becomes option counts in the shared filter controls. The +storage/recovery-owned Proxmox surface additionally passes its already-loaded +resource set through `platformEstateOverviewModel.ts` for cluster and +standalone-node topology in the existing Nodes table header. Kubernetes workload presentation is API-native, not generic inventory. Pods, Deployments, controllers, and autoscalers render under the `/kubernetes/workloads` diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 2584a2179..6a210b7db 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,29 +1,43 @@ { "version": 1, - "base_sha": "2da68787550ca774b0e202591ab542d8e35a688e", - "verified_at": "2026-08-19T16:23:24Z", + "base_sha": "85ddd9f7f98389c05165527f62696c6f344074ee", + "verified_at": "2026-08-19T16:51:42Z", "result": "passed", "changed_paths": [ + "frontend-modern/src/components/Workloads/WorkloadsFilter.tsx", + "frontend-modern/src/components/Workloads/useWorkloadsState.ts", + "frontend-modern/src/components/Workloads/workloadsFilterModel.ts", + "frontend-modern/src/components/shared/FilterBar/FilterBar.tsx", + "frontend-modern/src/components/shared/FilterButtonGroup.tsx", + "frontend-modern/src/components/shared/filterButtonGroupModel.ts", "frontend-modern/src/features/docker/DockerPageSurface.tsx", "frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx", - "frontend-modern/src/features/platformPage/PlatformAttentionSummary.tsx", "frontend-modern/src/features/platformPage/PlatformEstateOverview.tsx", "frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts", + "frontend-modern/src/features/platformPage/sharedPlatformPage.tsx", + "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx", "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx", "frontend-modern/src/features/standalone/StandalonePageSurface.tsx", "frontend-modern/src/features/truenas/TrueNASPageSurface.tsx", "frontend-modern/src/features/vmware/VmwarePageSurface.tsx" ], "content_sha256": { - "frontend-modern/src/features/docker/DockerPageSurface.tsx": "5c8eb2147a5442ead798e9e1a3caeaad17413ef8d95f6742276c174deeeaa62d", - "frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx": "3a66c9d684ec6265dda01232228521d77ddc2ff8e35b6958629486c8c99728a9", - "frontend-modern/src/features/platformPage/PlatformAttentionSummary.tsx": "deleted", - "frontend-modern/src/features/platformPage/PlatformEstateOverview.tsx": "ecf80c58f89b646bc3bde4328634c0718bd5a8a47bf9cd8069d69f6c62ce2ded", - "frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts": "eb11afc71a9a1a593c5c7f69890b0893f230b824fa3380f2d51b58338327606a", - "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "c18e370e5c4067cefe7fc46e0ed15d8660b79f04c2dd6b9ecc552d2714007263", - "frontend-modern/src/features/standalone/StandalonePageSurface.tsx": "38f2a514a7c8641156acff7fba1cf366c893922532e82a23105648748d44ab3d", - "frontend-modern/src/features/truenas/TrueNASPageSurface.tsx": "c842d9c12e4cc6eaffc79b777cae9d0ffae5d68ac50b77985fbeba77e69d3e2d", - "frontend-modern/src/features/vmware/VmwarePageSurface.tsx": "d46924006f3b394b89edab2437248402c62ccebaa22266de03358dd69dac6d4a" + "frontend-modern/src/components/Workloads/WorkloadsFilter.tsx": "643e50ea4aa6ed89589bb77ce48fc6344ba4a5ed6b2939bba6b4d8e047b78bfd", + "frontend-modern/src/components/Workloads/useWorkloadsState.ts": "10f5b232ce7db4cf52d8df0f2d33c29731afcf1544f770aab1183f372763495d", + "frontend-modern/src/components/Workloads/workloadsFilterModel.ts": "c68b4193f9b926a468b018e06024fb3212e19b99e0824845076f8ed71bf9c0eb", + "frontend-modern/src/components/shared/FilterBar/FilterBar.tsx": "ce8c55f14d1358b2ed92e1970587cb1e671a97ade499d51ec635c8f78d256eda", + "frontend-modern/src/components/shared/FilterButtonGroup.tsx": "f573262dda64212a061626b3c7988f3ce2788f18979d523c1f737f6a7efd7a98", + "frontend-modern/src/components/shared/filterButtonGroupModel.ts": "06576097404a56f66d6ae0ef23fc913dbff11ceb5833140d00df58ad9711fe41", + "frontend-modern/src/features/docker/DockerPageSurface.tsx": "b1f8425708dad88bad3fe58764456250c32e5080dad3c6d04afbe2cba23e4762", + "frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx": "bc66b5a08453771c1f81d6b34c02a0b2e117f1c20e8cc250502a8c9ba846f3c4", + "frontend-modern/src/features/platformPage/PlatformEstateOverview.tsx": "deleted", + "frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts": "025fe43f28810fbafc7ce439b4e47e40d109559ecfc064b14e11de1bae4505db", + "frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "cd959981f59753727847621228a339c099034fe7b7a61fa2bb32bb6704dd64b3", + "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "5897b06f6e728f06d00d731b3675a33688681977bb3d51c76b0a92e21cf4c4bd", + "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "199cac37585598cc8533efd8010bcfa85915f4f7cc29fa5b967198460f70b43d", + "frontend-modern/src/features/standalone/StandalonePageSurface.tsx": "70f1f22cd61ed8822aa91cb3bb513fffd794316c6536e00d8bd0403fa9aecbdd", + "frontend-modern/src/features/truenas/TrueNASPageSurface.tsx": "c330710f229e6fa04edb12e077a34ea7fafb4edac2adda6c878a77984cde841e", + "frontend-modern/src/features/vmware/VmwarePageSurface.tsx": "efd0a33a0c9fa649993811f3dee89666a0ae3df32af5225c69fcc0978811ffb8" }, "routes": [ "/proxmox/overview", @@ -31,30 +45,23 @@ "/kubernetes/overview", "/truenas/overview", "/vmware/overview", - "/standalone/machines" + "/machines" ], "viewports": [ - { - "width": 1280, - "height": 720 - }, - { - "width": 390, - "height": 844 - } + { "width": 1440, "height": 900 }, + { "width": 390, "height": 844 } ], "states": [ - "Large Proxmox estate overview showing 32 nodes, 578 workloads, five clusters plus two standalone nodes, 104 attention resources, and three bounded operational spotlights", - "Canonical visible estate overview on Docker, Kubernetes, TrueNAS, VMware vSphere, and Standalone Machines with exactly four platform-specific metrics on each surface", - "Globally hidden estate overview on Docker after choosing Hide on Proxmox and again after a full reload", - "Restored visible overview after choosing Show, followed by navigation across every platform surface", - "Proxmox overview at 390 by 844 with four metrics, three stacked spotlights, and document scroll width equal to viewport width" + "Large Proxmox workload totals shown inline in the existing Type and Status controls", + "Proxmox Nodes header showing 32 nodes, five clusters, and two standalone nodes", + "Expanded phone filter rail with all inline totals visible without horizontal page overflow", + "Inline totals hidden through View, still hidden after a full reload, then restored", + "Docker, Kubernetes, TrueNAS, VMware vSphere, and Standalone overview routes without the retired estate panel" ], "interactions": [ - "Chose Hide on Proxmox, navigated to Docker, and confirmed the shared Show estate overview control replaced the summary", - "Reloaded Docker and confirmed the hidden preference persisted before first paint", - "Chose Show on Docker and navigated through Docker, Kubernetes, TrueNAS, VMware vSphere, Standalone Machines, and Proxmox to confirm the same canonical component marker", - "Inspected the rendered desktop Proxmox estate summary above the existing workload filters and dense inventory table", - "Applied a 390 by 844 viewport, verified no horizontal page overflow, captured the stacked responsive state, and reset the viewport" + "Expanded the phone Filters control and checked the complete type and status count rows", + "Opened View, selected Hide for Inventory totals, and confirmed count badges disappeared", + "Reloaded Proxmox and confirmed the hidden choice persisted, then restored Show", + "Navigated all six platform overview routes and confirmed the shared panel was absent" ] } diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index 6bbfe025b..f53bebf60 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -3195,24 +3195,20 @@ ] }, { - "id": "platform-estate-overview", + "id": "platform-estate-inline-counts", "category": "platform-overview", - "summary": "Every primary platform overview composes one shared estate metric and operational spotlight surface with one global persisted visibility preference.", + "summary": "Large-estate totals annotate the existing canonical type/status controls and table headers instead of creating a competing overview panel; the shared View preference owns their visibility.", "canonical": { - "path": "src/features/platformPage/PlatformEstateOverview.tsx", - "export": "PlatformEstateOverview" + "path": "src/components/Workloads/WorkloadsFilter.tsx", + "export": "WorkloadsFilter" }, "requiredConsumers": [ - { "path": "src/features/docker/DockerPageSurface.tsx" }, - { "path": "src/features/kubernetes/KubernetesPageSurface.tsx" }, { "path": "src/features/proxmox/ProxmoxPageSurface.tsx" }, - { "path": "src/features/standalone/StandalonePageSurface.tsx" }, - { "path": "src/features/truenas/TrueNASPageSurface.tsx" }, { "path": "src/features/vmware/VmwarePageSurface.tsx" } ], "proof": [ "src/components/shared/SharedPrimitives.guardrails.test.ts", - "src/features/platformPage/__tests__/PlatformEstateOverview.test.tsx", + "src/components/Workloads/__tests__/WorkloadsFilter.test.tsx", "src/features/platformPage/__tests__/platformEstateOverviewModel.test.ts", "src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts", "scripts/shared-template-audit.mjs" diff --git a/frontend-modern/src/components/Workloads/WorkloadsFilter.tsx b/frontend-modern/src/components/Workloads/WorkloadsFilter.tsx index ded0d9e0d..1da3db5ba 100644 --- a/frontend-modern/src/components/Workloads/WorkloadsFilter.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadsFilter.tsx @@ -17,6 +17,11 @@ import { } from '@/components/shared/FilterToolbar'; import { GroupedTableModeSegmentedControl } from '@/components/shared/GroupedTableModeSegmentedControl'; import { useBreakpoint } from '@/hooks/useBreakpoint'; +import { usePersistentSignal } from '@/hooks/usePersistentSignal'; +import { + PLATFORM_ESTATE_COUNTS_STORAGE_KEY, + deserializePlatformEstateCountsVisibility, +} from '@/features/platformPage/platformEstateOverviewModel'; import { STORAGE_KEYS } from '@/utils/localStorage'; import { normalizeSourcePlatformQueryValue, @@ -44,6 +49,11 @@ import { WORKLOAD_STATUS_FILTER_OPTIONS, WORKLOAD_TYPE_OPTIONS } from './workloa export const WorkloadsFilter: Component = (props) => { const { isMobile } = useBreakpoint(); + const [inventoryCountsVisible, setInventoryCountsVisible] = usePersistentSignal( + PLATFORM_ESTATE_COUNTS_STORAGE_KEY, + true, + { deserialize: deserializePlatformEstateCountsVisibility }, + ); const typeValue = () => isContainerWorkloadViewMode(props.viewMode()) ? 'container' : props.viewMode(); @@ -57,6 +67,16 @@ export const WorkloadsFilter: Component = (props) => { ); }; + const workloadTypeCount = (value: string): number | undefined => { + if (!inventoryCountsVisible() || !props.inventoryStats) return undefined; + const stats = props.inventoryStats(); + if (value === 'all') return stats.total; + if (value === 'vm') return stats.vms; + if (value === 'container') return stats.containers + stats.appContainers; + if (value === 'pod') return stats.pods; + return undefined; + }; + const workloadTypeOptions = (): FilterSelectOption[] => (isProxmoxScope() ? WORKLOAD_TYPE_OPTIONS.filter( @@ -76,8 +96,19 @@ export const WorkloadsFilter: Component = (props) => { ? BoxesIcon : undefined, tone: option.value === 'vm' ? 'info' : option.value === 'container' ? 'success' : undefined, + count: workloadTypeCount(option.value), })); + const workloadStatusCount = (value: string): number | undefined => { + if (!inventoryCountsVisible() || !props.inventoryStats) return undefined; + const stats = props.inventoryStats(); + if (value === 'all') return stats.total; + if (value === 'running') return stats.running; + if (value === 'degraded') return stats.degraded; + if (value === 'stopped') return stats.stopped; + return undefined; + }; + const workloadStatusOptions = (): FilterSelectOption[] => (props.statusOptions ?? WORKLOAD_STATUS_FILTER_OPTIONS).map((option) => ({ value: option.value, @@ -98,6 +129,7 @@ export const WorkloadsFilter: Component = (props) => { : option.value === 'stopped' ? 'danger' : undefined, + count: workloadStatusCount(option.value), })); const runtimeChipLabel = (value: string): string => { @@ -329,6 +361,23 @@ export const WorkloadsFilter: Component = (props) => { + +
+
+ Inventory totals +
+ setInventoryCountsVisible(value === 'shown')} + options={[ + { value: 'shown', label: 'Show' }, + { value: 'hidden', label: 'Hide' }, + ]} + /> +
+
+
diff --git a/frontend-modern/src/components/Workloads/__tests__/WorkloadsFilter.test.tsx b/frontend-modern/src/components/Workloads/__tests__/WorkloadsFilter.test.tsx index acf7b492f..a3d647ed9 100644 --- a/frontend-modern/src/components/Workloads/__tests__/WorkloadsFilter.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/WorkloadsFilter.test.tsx @@ -104,6 +104,7 @@ describe('WorkloadsFilter', () => { beforeEach(() => { vi.clearAllMocks(); isMobileMock.mockReturnValue(false); + window.localStorage.clear(); }); afterEach(() => { @@ -162,6 +163,67 @@ describe('WorkloadsFilter', () => { expect(dialog.getByRole('button', { name: 'List' })).toBeInTheDocument(); }); + it('folds large-estate totals into the existing type and status controls', () => { + render(() => ( + ({ + total: 578, + running: 500, + degraded: 12, + stopped: 66, + vms: 253, + containers: 325, + appContainers: 0, + pods: 0, + }), + })} + /> + )); + + expect( + within(inlineFilterGroup('Type')).getByRole('button', { name: 'All, 578' }), + ).toHaveTextContent('578'); + expect( + within(inlineFilterGroup('Type')).getByRole('button', { name: 'VMs, 253' }), + ).toHaveTextContent('253'); + expect( + within(inlineFilterGroup('Type')).getByRole('button', { name: 'LXCs, 325' }), + ).toHaveTextContent('325'); + expect( + within(inlineFilterGroup('Status')).getByRole('button', { name: 'Degraded, 12' }), + ).toHaveTextContent('12'); + }); + + it('keeps inventory totals optional through the existing View menu', () => { + render(() => ( + ({ + total: 578, + running: 500, + degraded: 12, + stopped: 66, + vms: 253, + containers: 325, + appContainers: 0, + pods: 0, + }), + })} + /> + )); + + const dialog = within(openViewPreferences()); + const visibility = dialog.getByRole('group', { name: 'Inventory totals visibility' }); + fireEvent.click(within(visibility).getByRole('button', { name: 'Hide' })); + + expect( + within(inlineFilterGroup('Type')).getByRole('button', { name: 'All' }), + ).not.toHaveTextContent('578'); + expect(window.localStorage.getItem('platformEstateOverviewVisible')).toBe('false'); + }); + it('offers a guest/host memory percentage basis when the owning page enables it', () => { const setMemoryDisplayBasis = vi.fn(); render(() => ( diff --git a/frontend-modern/src/components/Workloads/useWorkloadsState.ts b/frontend-modern/src/components/Workloads/useWorkloadsState.ts index e2183c62e..4831d29bb 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadsState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadsState.ts @@ -27,6 +27,7 @@ import { getCanonicalWorkloadId, resolveWorkloadType } from '@/utils/workloads'; import { nodeFromResource } from '@/utils/resourceStateAdapters'; import { buildWorkloadSummaryGroupScopeMap, + computeWorkloadStats, createWorkloadSortComparator, filterWorkloads, type FilterWorkloadsParams, @@ -512,11 +513,13 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) { tableBodyRef, groupLabelBadges, }); + const inventoryStats = createMemo(() => computeWorkloadStats(allGuests())); return { activeAlerts, alertsEnabled, allGuests, + inventoryStats, activeSummaryWorkloadGroupScope, activeSummaryWorkloadId, clearPinnedSummaryScope, diff --git a/frontend-modern/src/components/Workloads/workloadsFilterModel.ts b/frontend-modern/src/components/Workloads/workloadsFilterModel.ts index 3270d0b46..ca2e6a19e 100644 --- a/frontend-modern/src/components/Workloads/workloadsFilterModel.ts +++ b/frontend-modern/src/components/Workloads/workloadsFilterModel.ts @@ -15,6 +15,17 @@ export interface WorkloadsStatusOption { label: string; } +export interface WorkloadsInventoryStats { + total: number; + running: number; + degraded: number; + stopped: number; + vms: number; + containers: number; + appContainers: number; + pods: number; +} + export interface WorkloadsFilterSelectOption { value: string; label: string; @@ -45,6 +56,7 @@ export interface WorkloadsFilterProps { searchPlaceholder?: string; searchEmptyMessage?: string; statusOptions?: readonly WorkloadsStatusOption[]; + inventoryStats?: () => WorkloadsInventoryStats; columnVisibility?: { availableColumns: ColumnDef[]; isColumnHidden: (id: string) => boolean; diff --git a/frontend-modern/src/components/shared/FilterBar/FilterBar.tsx b/frontend-modern/src/components/shared/FilterBar/FilterBar.tsx index 320ef17af..4c04236cb 100644 --- a/frontend-modern/src/components/shared/FilterBar/FilterBar.tsx +++ b/frontend-modern/src/components/shared/FilterBar/FilterBar.tsx @@ -43,6 +43,7 @@ const InlineFilterControl: Component<{ filter: FilterDef }> = (props) => ( icon: option.icon, tone: option.tone, title: option.title, + count: option.count, }))} value={props.filter.value()} onChange={props.filter.setValue} diff --git a/frontend-modern/src/components/shared/FilterButtonGroup.tsx b/frontend-modern/src/components/shared/FilterButtonGroup.tsx index 22f072812..1d033f71a 100644 --- a/frontend-modern/src/components/shared/FilterButtonGroup.tsx +++ b/frontend-modern/src/components/shared/FilterButtonGroup.tsx @@ -52,7 +52,14 @@ export function FilterButtonGroup(props: FilterButton return ( ); }} diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index e0368474a..57bce11dc 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -412,6 +412,9 @@ describe('shared primitive guardrails', () => { }>; }; const registeredRule = registry.rules?.find((rule) => rule.id === 'filter-button-group-shell'); + const estateCountsRule = registry.rules?.find( + (rule) => rule.id === 'platform-estate-inline-counts', + ); const registeredGuard = registry.patternGuards?.find( (guard) => guard.id === 'filter-button-group-local-segmented-control-styles', ); @@ -421,6 +424,14 @@ describe('shared primitive guardrails', () => { expect(registeredRule?.canonical?.path).toBe('src/components/shared/FilterButtonGroup.tsx'); expect(registeredRule?.canonical?.export).toBe('FilterButtonGroup'); + expect(estateCountsRule?.canonical).toEqual({ + path: 'src/components/Workloads/WorkloadsFilter.tsx', + export: 'WorkloadsFilter', + }); + expect(estateCountsRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([ + 'src/features/proxmox/ProxmoxPageSurface.tsx', + 'src/features/vmware/VmwarePageSurface.tsx', + ]); expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([ 'src/components/Settings/GeneralSettingsPanel.tsx', 'src/components/Settings/ResourcePicker.tsx', @@ -488,6 +499,9 @@ describe('shared primitive guardrails', () => { expect(filterButtonGroupModelSource).toContain('getFilterButtonGroupButtonClass'); expect(filterButtonGroupModelSource).toContain('getFilterButtonGroupCompactLabel'); expect(filterButtonGroupModelSource).toContain("option.label.startsWith('All ')"); + expect(filterButtonGroupModelSource).toContain('count?: number'); + expect(workloadsFilterSource).toContain('inventoryStats'); + expect(workloadsFilterSource).toContain('PLATFORM_ESTATE_COUNTS_STORAGE_KEY'); expect(generalSettingsPanelSource).toContain('FilterButtonGroup'); expect(generalSettingsPanelSource.match(/ { expect(source).not.toContain(' { @@ -8325,7 +8340,8 @@ describe('shared primitive guardrails', () => { expect(filterBarSource).toContain('{props.viewOptions}'); expect(filterBarSource).toContain('aria-label="Filter actions"'); expect(sharedPlatformPageSource).toContain('viewOptions?: JSX.Element;'); - expect(sharedPlatformPageSource).toContain('viewOptions={props.viewOptions}'); + expect(sharedPlatformPageSource).toContain('{props.viewOptions}'); + expect(sharedPlatformPageSource).toContain('Inventory totals'); expect(sharedPlatformPageSource).toContain('showAddFilterLabel={false}'); expect(sharedPlatformPageSource).toContain('trailingControls={'); expect(sharedPlatformPageSource).not.toContain('ViewOptionsMenu'); diff --git a/frontend-modern/src/components/shared/filterButtonGroupModel.ts b/frontend-modern/src/components/shared/filterButtonGroupModel.ts index 1c175a58f..7df64490b 100644 --- a/frontend-modern/src/components/shared/filterButtonGroupModel.ts +++ b/frontend-modern/src/components/shared/filterButtonGroupModel.ts @@ -10,6 +10,7 @@ export interface FilterOption { visualLabel?: JSX.Element; icon?: (props: { class?: string }) => JSX.Element; tone?: FilterButtonGroupOptionTone; + count?: number; disabled?: boolean; } diff --git a/frontend-modern/src/features/docker/DockerPageSurface.tsx b/frontend-modern/src/features/docker/DockerPageSurface.tsx index 481b800e8..6f2f5680f 100644 --- a/frontend-modern/src/features/docker/DockerPageSurface.tsx +++ b/frontend-modern/src/features/docker/DockerPageSurface.tsx @@ -3,7 +3,6 @@ import { Show, createMemo, createSignal } from 'solid-js'; import BoxIcon from 'lucide-solid/icons/box'; import { ButtonLink } from '@/components/shared/Button'; import { getPlatformIcon } from '@/features/platformPage/platformIcon'; -import { PlatformEstateOverview } from '@/features/platformPage/PlatformEstateOverview'; import { useUnifiedResources } from '@/hooks/useUnifiedResources'; import { PLATFORM_HEALTH_FILTER_OPTIONS, @@ -158,7 +157,6 @@ export function DockerPageSurface() { />
-
-
diff --git a/frontend-modern/src/features/platformPage/PlatformEstateOverview.tsx b/frontend-modern/src/features/platformPage/PlatformEstateOverview.tsx deleted file mode 100644 index d2df813b4..000000000 --- a/frontend-modern/src/features/platformPage/PlatformEstateOverview.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { A } from '@solidjs/router'; -import ChevronRightIcon from 'lucide-solid/icons/chevron-right'; -import EyeIcon from 'lucide-solid/icons/eye'; -import EyeOffIcon from 'lucide-solid/icons/eye-off'; -import ServerOffIcon from 'lucide-solid/icons/server-off'; -import TriangleAlertIcon from 'lucide-solid/icons/triangle-alert'; -import { For, Show, createMemo } from 'solid-js'; -import { Button } from '@/components/shared/Button'; -import { TableCard } from '@/components/shared/TableCard'; -import { usePersistentSignal } from '@/hooks/usePersistentSignal'; -import type { Resource } from '@/types/resource'; -import { - PLATFORM_ESTATE_OVERVIEW_STORAGE_KEY, - buildPlatformEstateOverviewModel, - deserializePlatformEstateOverviewVisibility, - formatPlatformEstateMetricValue, - type PlatformEstateOverviewPlatform, -} from './platformEstateOverviewModel'; - -export function PlatformEstateOverview(props: { - platform: PlatformEstateOverviewPlatform; - resources: readonly Resource[]; -}) { - const [visible, setVisible] = usePersistentSignal(PLATFORM_ESTATE_OVERVIEW_STORAGE_KEY, true, { - deserialize: deserializePlatformEstateOverviewVisibility, - }); - const model = createMemo(() => buildPlatformEstateOverviewModel(props.platform, props.resources)); - - return ( - - -
- } - > -
-
-
-

- Estate at a glance -

-

- Live inventory and the most important operational signals. -

-
- -
- - -
- - {(item, index) => ( -
-
- {formatPlatformEstateMetricValue(item.value)} -
-
- {item.label} -
-
- )} -
-
-
- - 0}> -
-
- Operational spotlights -
- -
-
-
-
- ); -} - -export default PlatformEstateOverview; diff --git a/frontend-modern/src/features/platformPage/__tests__/PlatformEstateOverview.test.tsx b/frontend-modern/src/features/platformPage/__tests__/PlatformEstateOverview.test.tsx deleted file mode 100644 index 7f976ba20..000000000 --- a/frontend-modern/src/features/platformPage/__tests__/PlatformEstateOverview.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { Route, Router } from '@solidjs/router'; -import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import type { Resource } from '@/types/resource'; -import { PlatformEstateOverview } from '../PlatformEstateOverview'; -import { PLATFORM_ESTATE_OVERVIEW_STORAGE_KEY } from '../platformEstateOverviewModel'; - -const resources = [ - { - id: 'pve-a', - type: 'agent', - name: 'pve-a', - displayName: 'pve-a', - platformId: 'proxmox', - platformType: 'proxmox-pve', - sourceType: 'api', - status: 'offline', - lastSeen: 1_700_000_000_000, - proxmox: { clusterName: 'production' }, - }, - { - id: 'vm-101', - type: 'vm', - name: 'database', - displayName: 'Database', - platformId: 'proxmox', - platformType: 'proxmox-pve', - sourceType: 'api', - status: 'running', - lastSeen: 1_700_000_000_000, - }, -] as Resource[]; - -const renderOverview = () => - render(() => ( - - } - /> - - )); - -beforeEach(() => { - window.localStorage.clear(); -}); - -afterEach(() => { - cleanup(); - window.localStorage.clear(); -}); - -describe('PlatformEstateOverview', () => { - it('renders the shared metrics and operational spotlights by default', () => { - renderOverview(); - - expect(screen.getByRole('heading', { name: 'Estate at a glance' })).toBeInTheDocument(); - expect(screen.getByText('Proxmox nodes')).toBeInTheDocument(); - expect(screen.getByText('VMs and containers')).toBeInTheDocument(); - expect(screen.getByText('pve-a offline')).toBeInTheDocument(); - expect(screen.getByRole('link', { name: /pve-a offline/i })).toHaveAttribute( - 'href', - '/proxmox/overview', - ); - }); - - it('persists the global hide and show choice', async () => { - renderOverview(); - - await fireEvent.click(screen.getByRole('button', { name: 'Hide' })); - expect(screen.queryByRole('heading', { name: 'Estate at a glance' })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Show estate overview' })).toBeInTheDocument(); - expect(window.localStorage.getItem(PLATFORM_ESTATE_OVERVIEW_STORAGE_KEY)).toBe('false'); - - await fireEvent.click(screen.getByRole('button', { name: 'Show estate overview' })); - expect(screen.getByRole('heading', { name: 'Estate at a glance' })).toBeInTheDocument(); - expect(window.localStorage.getItem(PLATFORM_ESTATE_OVERVIEW_STORAGE_KEY)).toBe('true'); - }); - - it('honors an existing hidden preference before first paint', () => { - window.localStorage.setItem(PLATFORM_ESTATE_OVERVIEW_STORAGE_KEY, 'false'); - - renderOverview(); - - expect(screen.queryByRole('heading', { name: 'Estate at a glance' })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Show estate overview' })).toBeInTheDocument(); - }); -}); diff --git a/frontend-modern/src/features/platformPage/__tests__/platformEstateOverviewModel.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformEstateOverviewModel.test.ts index 916066c17..d92fb4b08 100644 --- a/frontend-modern/src/features/platformPage/__tests__/platformEstateOverviewModel.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/platformEstateOverviewModel.test.ts @@ -1,129 +1,56 @@ import { describe, expect, it } from 'vitest'; import type { Resource } from '@/types/resource'; import { - buildPlatformEstateMetrics, - buildPlatformOperationalSpotlights, - deserializePlatformEstateOverviewVisibility, - formatPlatformEstateMetricValue, - isPlatformEstateAttentionResource, - type PlatformEstateOverviewPlatform, + buildProxmoxEstateTopology, + deserializePlatformEstateCountsVisibility, } from '../platformEstateOverviewModel'; -const makeResource = ( - id: string, - type: Resource['type'], - overrides: Partial = {}, -): Resource => +const makeNode = (id: string, clusterName?: string): Resource => ({ id, - type, + type: 'agent', name: id, displayName: id, - platformId: 'estate', - platformType: 'generic', + platformId: 'proxmox', + platformType: 'proxmox-pve', sourceType: 'api', status: 'online', lastSeen: 1_700_000_000_000, - ...overrides, + proxmox: clusterName ? { clusterName } : {}, }) as Resource; describe('platformEstateOverviewModel', () => { - it('derives a large Proxmox estate from the canonical scoped resource set', () => { + it('derives node, cluster, and standalone totals from the canonical resource set', () => { const resources = [ - makeResource('pve-a', 'agent', { proxmox: { clusterName: 'production' } }), - makeResource('pve-b', 'agent', { proxmox: { clusterName: 'production' } }), - makeResource('pve-lab', 'agent'), - makeResource('vm-101', 'vm', { status: 'running' }), - makeResource('ct-201', 'system-container', { status: 'stopped' }), - makeResource('oci-301', 'oci-container', { status: 'warning' }), + makeNode('pve-a', 'production'), + makeNode('pve-b', 'production'), + makeNode('pve-c', 'lab'), + makeNode('pve-standalone'), + { ...makeNode('vm-101'), type: 'vm' as const }, ]; - expect(buildPlatformEstateMetrics('proxmox', resources)).toEqual([ - { id: 'nodes', label: 'Proxmox nodes', value: 3 }, - { id: 'workloads', label: 'VMs and containers', value: 3 }, - { id: 'topology', label: 'Clusters + standalone', value: '1 + 1' }, - { id: 'attention', label: 'Need attention', value: 1 }, - ]); - }); - - it.each([ - 'proxmox', - 'docker', - 'kubernetes', - 'truenas', - 'vmware', - 'standalone', - ])('keeps %s on the same four-metric overview contract', (platform) => { - const metrics = buildPlatformEstateMetrics(platform, []); - - expect(metrics).toHaveLength(4); - expect(metrics.map((item) => item.id)).toContain('attention'); - }); - - it('counts actionable evidence without treating an intentionally stopped workload as a fault', () => { - expect( - isPlatformEstateAttentionResource(makeResource('stopped', 'vm', { status: 'stopped' })), - ).toBe(false); - expect( - isPlatformEstateAttentionResource( - makeResource('pressure', 'storage', { disk: { current: 91 } }), - ), - ).toBe(true); - expect( - isPlatformEstateAttentionResource( - makeResource('alerting', 'agent', { - alerts: [ - { - id: 'alert-1', - type: 'temperature', - level: 'critical', - message: 'Temperature threshold exceeded', - value: 95, - threshold: 90, - startTime: 1_700_000_000_000, - }, - ], - }), - ), - ).toBe(true); - }); - - it('prioritizes danger, caps the list, and routes spotlights to platform detail areas', () => { - const resources = [ - makeResource('warning-pod', 'pod', { status: 'offline' }), - makeResource('offline-node', 'k8s-node', { status: 'offline' }), - makeResource('degraded-claim', 'k8s-persistent-volume-claim', { status: 'degraded' }), - makeResource('warning-event', 'k8s-event', { status: 'warning' }), - ]; - - const spotlights = buildPlatformOperationalSpotlights('kubernetes', resources, 3); - - expect(spotlights).toHaveLength(3); - expect(spotlights[0]).toMatchObject({ - resourceId: 'offline-node', - tone: 'danger', - href: '/kubernetes/nodes', + expect(buildProxmoxEstateTopology(resources)).toEqual({ + clusters: 2, + nodes: 4, + standalone: 1, }); - expect(spotlights.map((item) => item.href)).toContain('/kubernetes/storage'); - expect(spotlights.map((item) => item.href)).toContain('/kubernetes/workloads'); }); - it('uses one tolerant global visibility preference', () => { - expect(deserializePlatformEstateOverviewVisibility('false')).toBe(false); - expect(deserializePlatformEstateOverviewVisibility('true')).toBe(true); - expect(deserializePlatformEstateOverviewVisibility('legacy-value')).toBe(true); - }); - - it('keeps a very large estate projection bounded to four metrics and three spotlights', () => { + it('keeps a very large topology projection linear and bounded', () => { const resources = Array.from({ length: 10_000 }, (_, index) => - makeResource(`node-${index}`, 'agent', { - status: index % 5 === 0 ? 'offline' : 'online', - proxmox: { clusterName: `cluster-${index % 40}` }, - }), + makeNode(`node-${index}`, index % 25 === 0 ? undefined : `cluster-${index % 40}`), ); - expect(buildPlatformEstateMetrics('proxmox', resources)).toHaveLength(4); - expect(buildPlatformOperationalSpotlights('proxmox', resources)).toHaveLength(3); - expect(formatPlatformEstateMetricValue(resources.length).length).toBeGreaterThan(5); + expect(buildProxmoxEstateTopology(resources)).toEqual({ + clusters: 40, + nodes: 10_000, + standalone: 400, + }); + }); + + it('preserves the tolerant global visibility preference', () => { + expect(deserializePlatformEstateCountsVisibility('false')).toBe(false); + expect(deserializePlatformEstateCountsVisibility('true')).toBe(true); + expect(deserializePlatformEstateCountsVisibility('legacy-value')).toBe(true); }); }); diff --git a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts index f8364affc..7fc48e30b 100644 --- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts @@ -171,15 +171,17 @@ const proxmoxInlineDetailTableSources = [ ]; describe('platform overview layout guardrails', () => { - it('keeps attention summaries canonical across every platform overview', () => { + it('keeps attention summaries canonical without a competing estate panel', () => { for (const source of [truenasProtectionTableSource, vsphereAlertsTableSource]) { expect(source).toContain('withPlatformAttentionCount'); expect(source).not.toContain('PlatformAttentionSummary'); } for (const source of overviewSurfaceSources) { - expect(source).toContain('PlatformEstateOverview'); + expect(source).not.toContain('PlatformEstateOverview'); expect(source).not.toContain('PlatformAttentionSummary'); } + expect(proxmoxPageSurfaceSource).toContain('inventoryStats={workloadsState.inventoryStats}'); + expect(vmwarePageSurfaceSource).toContain('inventoryStats={workloadsState.inventoryStats}'); }); it('keeps platform inventory tables on the shared dense table styling contract', () => { diff --git a/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts b/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts index e4d868818..231409940 100644 --- a/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; +import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library'; import { afterEach, describe, expect, it } from 'vitest'; import { createRoot, createSignal } from 'solid-js'; import type { Resource } from '@/types/resource'; @@ -38,7 +38,10 @@ import { type PlatformResourceStatusFilter, } from '../sharedPlatformPage'; -afterEach(cleanup); +afterEach(() => { + cleanup(); + window.localStorage.clear(); +}); describe('withPlatformAttentionCount', () => { it('decorates one attention option with a compact accessible count', () => { @@ -444,7 +447,7 @@ describe('PlatformTableToolbar', () => { expect(status()).toBe('all'); }); - it('owns the View popover while keeping the row counter permanently visible', () => { + it('owns the View popover and the shared row-counter visibility preference', () => { render(() => PlatformTableToolbar({ search: () => '', @@ -472,6 +475,13 @@ describe('PlatformTableToolbar', () => { const viewDialog = screen.getByRole('dialog', { name: 'View preferences' }); expect(viewDialog).toHaveTextContent('Columns'); expect(viewDialog).not.toContainElement(screen.getByText('2 of 3 rows')); + + const visibility = within(viewDialog).getByRole('group', { + name: 'Inventory totals visibility', + }); + fireEvent.click(within(visibility).getByRole('button', { name: 'Hide' })); + expect(filterBar).not.toHaveTextContent('2 of 3 rows'); + expect(window.localStorage.getItem('platformEstateOverviewVisible')).toBe('false'); }); it('composes consumer context into the canonical toolbar rail', () => { diff --git a/frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts b/frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts index b39214045..f2975f85a 100644 --- a/frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts +++ b/frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts @@ -1,82 +1,18 @@ -import type { Resource, ResourceType } from '@/types/resource'; +import type { Resource } from '@/types/resource'; -export const PLATFORM_ESTATE_OVERVIEW_STORAGE_KEY = 'platformEstateOverviewVisible'; +// Keep the original key so operators who hid the earlier panel retain the same +// preference when the information moves into the existing filter controls. +export const PLATFORM_ESTATE_COUNTS_STORAGE_KEY = 'platformEstateOverviewVisible'; -export type PlatformEstateOverviewPlatform = - 'proxmox' | 'docker' | 'kubernetes' | 'truenas' | 'vmware' | 'standalone'; +export const deserializePlatformEstateCountsVisibility = (raw: string): boolean => raw !== 'false'; -export type PlatformEstateMetric = { - id: string; - label: string; - value: number | string; +export type ProxmoxEstateTopology = { + clusters: number; + nodes: number; + standalone: number; }; -export type PlatformEstateSpotlightTone = 'danger' | 'warning'; - -export type PlatformEstateSpotlight = { - id: string; - href: string; - label: string; - meta: string; - resourceId: string; - tone: PlatformEstateSpotlightTone; -}; - -export type PlatformEstateOverviewModel = { - metrics: readonly PlatformEstateMetric[]; - spotlights: readonly PlatformEstateSpotlight[]; -}; - -const ATTENTION_STATUSES = new Set(['offline', 'degraded', 'warning']); -const DANGER_TOKENS = new Set(['critical', 'danger', 'fatal', 'offline', 'failed', 'error']); -const WARNING_TOKENS = new Set(['warning', 'warn', 'degraded', 'attention']); - -const normalize = (value: unknown): string => - typeof value === 'string' ? value.trim().toLowerCase() : ''; - -const displayName = (resource: Resource): string => - resource.displayName?.trim() || resource.name?.trim() || resource.id; - -const countTypes = (resources: readonly Resource[], types: readonly ResourceType[]): number => { - const accepted = new Set(types); - return resources.filter((resource) => accepted.has(resource.type)).length; -}; - -const resourceHasDangerEvidence = (resource: Resource): boolean => { - if (normalize(resource.status) === 'offline') return true; - if (DANGER_TOKENS.has(normalize(resource.incidentSeverity))) return true; - if (resource.incidents?.some((incident) => DANGER_TOKENS.has(normalize(incident.severity)))) { - return true; - } - return Boolean(resource.alerts?.some((alert) => DANGER_TOKENS.has(normalize(alert.level)))); -}; - -const resourceHasWarningEvidence = (resource: Resource): boolean => { - if (ATTENTION_STATUSES.has(normalize(resource.status))) return true; - if (WARNING_TOKENS.has(normalize(resource.incidentSeverity))) return true; - if (resource.incidents?.some((incident) => WARNING_TOKENS.has(normalize(incident.severity)))) { - return true; - } - if (resource.alerts?.some((alert) => WARNING_TOKENS.has(normalize(alert.level)))) return true; - return resource.type === 'storage' && Number(resource.disk?.current) >= 85; -}; - -export const isPlatformEstateAttentionResource = (resource: Resource): boolean => - resourceHasDangerEvidence(resource) || resourceHasWarningEvidence(resource); - -const attentionCount = (resources: readonly Resource[]): number => - resources.filter(isPlatformEstateAttentionResource).length; - -const metric = (id: string, label: string, value: number | string): PlatformEstateMetric => ({ - id, - label, - value, -}); - -export const formatPlatformEstateMetricValue = (value: number | string): string => - typeof value === 'number' ? value.toLocaleString() : value; - -const buildProxmoxMetrics = (resources: readonly Resource[]): PlatformEstateMetric[] => { +export function buildProxmoxEstateTopology(resources: readonly Resource[]): ProxmoxEstateTopology { const nodes = resources.filter((resource) => resource.type === 'agent'); const clusters = new Set( nodes @@ -87,265 +23,5 @@ const buildProxmoxMetrics = (resources: readonly Resource[]): PlatformEstateMetr (resource) => !resource.proxmox?.clusterName?.trim() && !resource.clusterId?.trim(), ).length; - return [ - metric('nodes', 'Proxmox nodes', nodes.length), - metric( - 'workloads', - 'VMs and containers', - countTypes(resources, ['vm', 'system-container', 'oci-container']), - ), - metric('topology', 'Clusters + standalone', `${clusters.size} + ${standalone}`), - metric('attention', 'Need attention', attentionCount(resources)), - ]; -}; - -const buildDockerMetrics = (resources: readonly Resource[]): PlatformEstateMetric[] => [ - metric( - 'hosts', - 'Runtime hosts', - resources.filter( - (resource) => - resource.type === 'docker-host' || (resource.type === 'agent' && resource.docker), - ).length, - ), - metric('containers', 'Containers', countTypes(resources, ['app-container'])), - metric('images', 'Images', countTypes(resources, ['docker-image'])), - metric('attention', 'Need attention', attentionCount(resources)), -]; - -const buildKubernetesMetrics = (resources: readonly Resource[]): PlatformEstateMetric[] => [ - metric('clusters', 'Clusters', countTypes(resources, ['k8s-cluster'])), - metric( - 'nodes', - 'Nodes', - resources.filter( - (resource) => - resource.type === 'k8s-node' || (resource.type === 'agent' && resource.kubernetes), - ).length, - ), - metric('pods', 'Pods', countTypes(resources, ['pod'])), - metric('attention', 'Need attention', attentionCount(resources)), -]; - -const buildTrueNASMetrics = (resources: readonly Resource[]): PlatformEstateMetric[] => [ - metric('systems', 'TrueNAS systems', countTypes(resources, ['agent'])), - metric( - 'pools', - 'Storage pools', - resources.filter( - (resource) => resource.type === 'pool' || resource.storage?.topology === 'pool', - ).length, - ), - metric('workloads', 'Apps and VMs', countTypes(resources, ['app-container', 'vm'])), - metric('attention', 'Need attention', attentionCount(resources)), -]; - -const buildVmwareMetrics = (resources: readonly Resource[]): PlatformEstateMetric[] => { - const connections = new Set( - resources.map((resource) => resource.vmware?.connectionId?.trim()).filter(Boolean), - ); - return [ - metric('connections', 'vCenter connections', connections.size), - metric('hosts', 'ESXi hosts', countTypes(resources, ['agent'])), - metric('workloads', 'Virtual machines', countTypes(resources, ['vm'])), - metric('attention', 'Need attention', attentionCount(resources)), - ]; -}; - -const buildStandaloneMetrics = (resources: readonly Resource[]): PlatformEstateMetric[] => { - const machines = resources.filter((resource) => resource.type === 'agent'); - return [ - metric('machines', 'Machines', machines.length), - metric('checks', 'Availability checks', countTypes(resources, ['network-endpoint'])), - metric( - 'healthy', - 'Reporting normally', - machines.filter((resource) => ['online', 'running'].includes(normalize(resource.status))) - .length, - ), - metric('attention', 'Need attention', attentionCount(resources)), - ]; -}; - -export function buildPlatformEstateMetrics( - platform: PlatformEstateOverviewPlatform, - resources: readonly Resource[], -): PlatformEstateMetric[] { - switch (platform) { - case 'proxmox': - return buildProxmoxMetrics(resources); - case 'docker': - return buildDockerMetrics(resources); - case 'kubernetes': - return buildKubernetesMetrics(resources); - case 'truenas': - return buildTrueNASMetrics(resources); - case 'vmware': - return buildVmwareMetrics(resources); - case 'standalone': - return buildStandaloneMetrics(resources); - } + return { clusters: clusters.size, nodes: nodes.length, standalone }; } - -const resourceTypeLabel = (type: ResourceType): string => { - const labels: Partial> = { - agent: 'System', - 'docker-host': 'Runtime host', - 'app-container': 'Container', - 'docker-image': 'Image', - 'k8s-cluster': 'Cluster', - 'k8s-node': 'Node', - pod: 'Pod', - vm: 'Virtual machine', - 'system-container': 'Container', - 'oci-container': 'OCI container', - storage: 'Storage', - physical_disk: 'Physical disk', - 'network-endpoint': 'Availability check', - }; - return labels[type] ?? type.replace(/-/g, ' '); -}; - -const spotlightRoute = (platform: PlatformEstateOverviewPlatform, resource: Resource): string => { - switch (platform) { - case 'proxmox': - if (resource.type === 'pbs') return '/proxmox/backups'; - if (resource.type === 'ceph') return '/proxmox/ceph'; - if (resource.type === 'pmg') return '/proxmox/mail'; - if (['storage', 'physical_disk'].includes(resource.type)) return '/proxmox/storage'; - return '/proxmox/overview'; - case 'docker': - if (resource.type === 'docker-image') return '/docker/images'; - if (resource.type === 'docker-volume') return '/docker/storage'; - if (resource.type === 'docker-network') return '/docker/networks'; - if ( - [ - 'docker-service', - 'docker-task', - 'docker-swarm-node', - 'docker-secret', - 'docker-config', - ].includes(resource.type) - ) { - return '/docker/swarm'; - } - return '/docker/overview'; - case 'kubernetes': - if (resource.type === 'k8s-node' || (resource.type === 'agent' && resource.kubernetes)) { - return '/kubernetes/nodes'; - } - if ( - ['pod', 'k8s-deployment', 'k8s-replicaset', 'k8s-statefulset', 'k8s-daemonset'].includes( - resource.type, - ) - ) { - return '/kubernetes/workloads'; - } - if ( - ['k8s-persistent-volume', 'k8s-persistent-volume-claim', 'k8s-storage-class'].includes( - resource.type, - ) - ) { - return '/kubernetes/storage'; - } - if (resource.type === 'k8s-event') return '/kubernetes/events'; - return '/kubernetes/overview'; - case 'truenas': - if (['storage', 'pool', 'dataset', 'physical_disk'].includes(resource.type)) { - return '/truenas/storage'; - } - if (resource.type === 'app-container') return '/truenas/apps'; - if (resource.type === 'vm') return '/truenas/vms'; - if (resource.type === 'network-share') return '/truenas/shares'; - return '/truenas/overview'; - case 'vmware': - if (resource.type === 'storage') return '/vmware/storage'; - if (resource.type === 'network') return '/vmware/networks'; - if (resource.incidentCount || resource.incidents?.length) return '/vmware/health'; - return '/vmware/overview'; - case 'standalone': - return resource.type === 'network-endpoint' ? '/machines/availability' : '/machines'; - } -}; - -const spotlightEvidence = ( - resource: Resource, -): { label: string; meta: string; tone: PlatformEstateSpotlightTone } => { - const name = displayName(resource); - const incident = resource.incidents?.find((item) => item.summary?.trim()); - const alert = resource.alerts?.find((item) => item.message?.trim()); - const tone = resourceHasDangerEvidence(resource) ? 'danger' : 'warning'; - if (incident) { - return { - label: incident.summary.trim(), - meta: `${name} · ${resourceTypeLabel(resource.type)}`, - tone, - }; - } - if (alert) { - return { - label: alert.message.trim(), - meta: `${name} · ${resourceTypeLabel(resource.type)}`, - tone, - }; - } - if (resource.type === 'storage' && Number(resource.disk?.current) >= 85) { - return { - label: `${name} storage pressure`, - meta: `${Math.round(Number(resource.disk?.current))}% used`, - tone, - }; - } - const status = normalize(resource.status); - return { - label: status === 'offline' ? `${name} offline` : `${name} needs attention`, - meta: `${resourceTypeLabel(resource.type)} · ${status || 'attention'}`, - tone, - }; -}; - -export function buildPlatformOperationalSpotlights( - platform: PlatformEstateOverviewPlatform, - resources: readonly Resource[], - limit = 3, -): PlatformEstateSpotlight[] { - const boundedLimit = Math.max(0, Math.trunc(limit)); - if (boundedLimit === 0) return []; - - const compare = (left: PlatformEstateSpotlight, right: PlatformEstateSpotlight): number => { - if (left.tone !== right.tone) return left.tone === 'danger' ? -1 : 1; - return left.label.localeCompare(right.label, undefined, { numeric: true }); - }; - const selected: PlatformEstateSpotlight[] = []; - - for (const resource of resources) { - if (!isPlatformEstateAttentionResource(resource)) continue; - const evidence = spotlightEvidence(resource); - const spotlight = { - id: `${platform}:${resource.id}`, - href: spotlightRoute(platform, resource), - label: evidence.label, - meta: evidence.meta, - resourceId: resource.id, - tone: evidence.tone, - } satisfies PlatformEstateSpotlight; - const insertionIndex = selected.findIndex((candidate) => compare(spotlight, candidate) < 0); - selected.splice(insertionIndex < 0 ? selected.length : insertionIndex, 0, spotlight); - if (selected.length > boundedLimit) selected.pop(); - } - - return selected; -} - -export function buildPlatformEstateOverviewModel( - platform: PlatformEstateOverviewPlatform, - resources: readonly Resource[], -): PlatformEstateOverviewModel { - return { - metrics: buildPlatformEstateMetrics(platform, resources), - spotlights: buildPlatformOperationalSpotlights(platform, resources), - }; -} - -export const deserializePlatformEstateOverviewVisibility = (raw: string): boolean => - raw !== 'false'; diff --git a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx index 056f5fc4b..de3d96489 100644 --- a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx +++ b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx @@ -17,6 +17,7 @@ import { import { EmptyState } from '@/components/shared/EmptyState'; import { type FilterOption as PlatformTableFilterOption } from '@/components/shared/FilterButtonGroup'; import { FilterBar, filterChipStatusDot, type FilterDef } from '@/components/shared/FilterBar'; +import { FilterSegmentedControl } from '@/components/shared/FilterToolbar'; import { MetadataBadge } from '@/components/shared/MetadataBadge'; import { type SearchInputProps } from '@/components/shared/SearchInput'; import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/shared/Table'; @@ -36,6 +37,10 @@ import { import { asTrimmedString } from '@/utils/stringUtils'; import { formatVmwareClusterServices } from '@/utils/vmwareDisplay'; import { getPlatformColumnAlign, type PlatformTableColumnKind } from './columnAlignment'; +import { + PLATFORM_ESTATE_COUNTS_STORAGE_KEY, + deserializePlatformEstateCountsVisibility, +} from './platformEstateOverviewModel'; export type { PlatformTableFilterOption }; @@ -1188,6 +1193,11 @@ export function PlatformTableToolbar(props: { viewOptions?: JSX.Element; }) { const { isMobile } = useBreakpoint(); + const [inventoryCountsVisible, setInventoryCountsVisible] = usePersistentSignal( + PLATFORM_ESTATE_COUNTS_STORAGE_KEY, + true, + { deserialize: deserializePlatformEstateCountsVisibility }, + ); // Migrated onto the shared FilterBar so every platform table inherits the // same combinable-filter UX (chip rail, saved-view scaffolding, mobile @@ -1238,13 +1248,33 @@ export function PlatformTableToolbar(props: { showAddFilterLabel={false} savedViewsKey={props.savedViewsKey} leadingControls={props.leadingControls} - viewOptions={props.viewOptions} + viewOptions={ + <> + {props.viewOptions} +
+
+ Inventory totals +
+ setInventoryCountsVisible(value === 'shown')} + options={[ + { value: 'shown', label: 'Show' }, + { value: 'hidden', label: 'Hide' }, + ]} + /> +
+ + } trailingControls={ - + + + } showClearAll={() => Boolean(props.hasActiveFilters) || props.search().trim().length > 0} onClearAll={props.onResetFilters} diff --git a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx index e881b1789..89fc6cd7d 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx @@ -22,6 +22,7 @@ import { MetricMiniSparkline } from '@/components/Workloads/MetricMiniSparkline' import { TemperatureGauge } from '@/components/shared/TemperatureGauge'; import { hostOverrideIdCandidates } from '@/features/alerts/alertOverridesModel'; import { useBreakpoint } from '@/hooks/useBreakpoint'; +import { usePersistentSignal } from '@/hooks/usePersistentSignal'; import { TableCell, TableRow } from '@/components/shared/Table'; import { getSimpleStatusIndicator } from '@/utils/status'; import { getNodeExternalUrl } from '@/utils/nodes'; @@ -36,6 +37,7 @@ import { PlatformTableMetricFallback, PlatformTableShell, createPlatformTableSortState, + formatPlatformTableIntegerValue, formatPlatformTablePercentValue, formatPlatformTableUptimeValue, getPlatformTableFiniteMetric, @@ -43,6 +45,11 @@ import { type PlatformTableSortValue, } from '@/features/platformPage/sharedPlatformPage'; import { PlatformResourceDetailToggleButton } from '@/features/platformPage/PlatformResourceDetailTableRow'; +import { + PLATFORM_ESTATE_COUNTS_STORAGE_KEY, + deserializePlatformEstateCountsVisibility, + type ProxmoxEstateTopology, +} from '@/features/platformPage/platformEstateOverviewModel'; import { type WorkloadsMetricDisplayMode } from '@/components/Workloads/workloadsFilterModel'; import { type WorkloadTableMetricHistoryRange } from '@/components/Workloads/workloadMetricHistoryModel'; import type { Disk, Node as LegacyNode } from '@/types/api'; @@ -194,8 +201,12 @@ export const ProxmoxNodesTable: Component<{ emptyIcon: JSX.Element; emptyTitle: string; emptyDescription: string; + topology?: ProxmoxEstateTopology; }> = (props) => { const breakpoint = useBreakpoint(); + const [inventoryCountsVisible] = usePersistentSignal(PLATFORM_ESTATE_COUNTS_STORAGE_KEY, true, { + deserialize: deserializePlatformEstateCountsVisibility, + }); const { activeAlerts } = useWebSocket(); const alertsActivation = useAlertsActivation(); const alertsEnabled = alertsActivation.detectionEnabled; @@ -239,7 +250,38 @@ export const ProxmoxNodesTable: Component<{ } > + Nodes + + + {formatPlatformTableIntegerValue(props.nodes.length)} + + + + } + actions={ + 0 || props.topology.standalone > 0) + } + > + + 0}> + {formatPlatformTableIntegerValue(props.topology!.clusters)}{' '} + {props.topology!.clusters === 1 ? 'cluster' : 'clusters'} + + 0 && props.topology!.standalone > 0}> + {' · '} + + 0}> + {formatPlatformTableIntegerValue(props.topology!.standalone)} standalone + + + + } cardClass="proxmox-nodes-card" tableClass={`${getProxmoxHostTableMinWidthClass(layoutMode())} table-fixed text-xs`} colgroup={ diff --git a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx index 5bf3abaf3..060b861bf 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx @@ -1,6 +1,5 @@ import { useLocation } from '@solidjs/router'; import { Show, createMemo, createResource, type Accessor } from 'solid-js'; -import { StatusDot } from '@/components/shared/StatusDot'; import StorageSurface from '@/components/Storage/Storage'; import { WorkloadsFilter } from '@/components/Workloads/WorkloadsFilter'; import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface'; @@ -27,7 +26,7 @@ import { import { getPlatformIcon } from '@/features/platformPage/platformIcon'; import { PlatformOutdatedAgentNotice } from '@/features/platformPage/PlatformOutdatedAgentNotice'; import { PlatformOutdatedSensorSetupNotice } from '@/features/platformPage/PlatformOutdatedSensorSetupNotice'; -import { PlatformEstateOverview } from '@/features/platformPage/PlatformEstateOverview'; +import { buildProxmoxEstateTopology } from '@/features/platformPage/platformEstateOverviewModel'; import { collectOutdatedSensorSetupNodes } from '@/features/platformPage/sensorSetup'; import { usePersistentSignal } from '@/hooks/usePersistentSignal'; import { useObservedElementWidth } from '@/hooks/useObservedElementWidth'; @@ -205,7 +204,6 @@ export function ProxmoxPageSurface() { />
- 0, ); - const visibleGuestStats = createMemo(() => workloadsState.totalStats()); + const estateTopology = createMemo(() => buildProxmoxEstateTopology(currentModel().resources)); const filteredNodes = createMemo(() => filterProxmoxNodesForSearch( currentModel().pveNodes, @@ -336,6 +334,7 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) { searchPlaceholder="Search VMs and LXCs by name, VMID, node, or status" searchEmptyMessage="Recent Proxmox workload searches appear here." statusOptions={PROXMOX_WORKLOAD_STATUS_OPTIONS} + inventoryStats={workloadsState.inventoryStats} columnVisibility={workloadsState.workloadsFilterColumnVisibility()} containerRuntimeFilter={workloadsState.containerRuntimeFilterConfig()} hostFilter={workloadsState.hostFilterConfig()} @@ -366,6 +365,7 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) { emptyIcon={} emptyTitle="No Proxmox VE nodes" emptyDescription="Proxmox VE nodes appear here once a PVE host reports inventory." + topology={estateTopology()} /> - {/* Keep this orientation strip aligned with the rows the operator can - currently see rather than the unfiltered page-wide inventory. */} - 0}> -
- 0}> - - - {visibleGuestStats().running} running - - - 0}> - 0}> - - - - - {visibleGuestStats().degraded} attention - - - 0}> - 0 || visibleGuestStats().degraded > 0}> - - - - - {visibleGuestStats().stopped} stopped - - -
-
); } diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx index 8254306c4..5b7c8e836 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx @@ -9,6 +9,7 @@ const mockPathname = vi.hoisted(() => vi.fn(() => '/proxmox/overview')); const mockVersionInfo = vi.hoisted(() => vi.fn()); const mockStorageProps = vi.hoisted(() => vi.fn()); const mockTotalStats = vi.hoisted(() => vi.fn()); +const mockNodesTableProps = vi.hoisted(() => vi.fn()); const makeResource = (resource: Partial & Pick): Resource => ({ @@ -118,9 +119,10 @@ vi.mock('../ProxmoxMailGatewayTable', () => ({ })); vi.mock('../ProxmoxNodesTable', () => ({ - ProxmoxNodesTable: (props: { nodes: Resource[] }) => ( -
- ), + ProxmoxNodesTable: (props: { nodes: Resource[]; topology?: unknown }) => { + mockNodesTableProps(props); + return
; + }, })); vi.mock('../ProxmoxReplicationTable', () => ({ @@ -234,7 +236,7 @@ describe('ProxmoxPageSurface contract', () => { expect(screen.queryByTestId('platform-outdated-agent-notice')).not.toBeInTheDocument(); }); - it('renders guest totals from the filtered workload collection', () => { + it('folds estate topology into the existing nodes table header contract', () => { setResources([ makeResource({ id: 'agent:pve-1', @@ -260,23 +262,13 @@ describe('ProxmoxPageSurface contract', () => { proxmox: { nodeName: 'pve-1', vmid: 102 }, }), ]); - mockTotalStats.mockReturnValue({ - total: 1, - running: 0, - degraded: 0, - stopped: 1, - vms: 1, - containers: 0, - appContainers: 0, - pods: 0, - }); - renderSurface(); - const totals = screen.getByTestId('proxmox-guest-totals'); - expect(totals).not.toHaveTextContent('running'); - expect(totals).not.toHaveTextContent('attention'); - expect(totals).toHaveTextContent('1 stopped'); + expect(mockNodesTableProps).toHaveBeenLastCalledWith( + expect.objectContaining({ + topology: { clusters: 1, nodes: 1, standalone: 0 }, + }), + ); }); it('keeps Patrol coverage off the Proxmox overview', () => { diff --git a/frontend-modern/src/features/standalone/StandalonePageSurface.tsx b/frontend-modern/src/features/standalone/StandalonePageSurface.tsx index 3bb296c19..2b20e9964 100644 --- a/frontend-modern/src/features/standalone/StandalonePageSurface.tsx +++ b/frontend-modern/src/features/standalone/StandalonePageSurface.tsx @@ -12,7 +12,6 @@ import { } from '@/components/Settings/infrastructureWorkspaceModel'; import { buildAvailabilitySettingsPath } from '@/components/Settings/availabilitySettingsModel'; import { PlatformOutdatedAgentNotice } from '@/features/platformPage/PlatformOutdatedAgentNotice'; -import { PlatformEstateOverview } from '@/features/platformPage/PlatformEstateOverview'; import { collectOutdatedAgentHosts, formatAgentVersionDisplay, @@ -302,7 +301,6 @@ export function StandalonePageSurface() { } >
-
-
diff --git a/frontend-modern/src/features/vmware/VmwarePageSurface.tsx b/frontend-modern/src/features/vmware/VmwarePageSurface.tsx index 77967fc4e..f0f2447da 100644 --- a/frontend-modern/src/features/vmware/VmwarePageSurface.tsx +++ b/frontend-modern/src/features/vmware/VmwarePageSurface.tsx @@ -3,7 +3,6 @@ import { For, Show, createMemo, createResource, type Accessor } from 'solid-js'; import AlertTriangle from 'lucide-solid/icons/triangle-alert'; import { buildInfrastructureAgentUpdatesPath } from '@/components/Settings/infrastructureWorkspaceModel'; import { getPlatformIcon } from '@/features/platformPage/platformIcon'; -import { PlatformEstateOverview } from '@/features/platformPage/PlatformEstateOverview'; import { PlatformOutdatedAgentNotice } from '@/features/platformPage/PlatformOutdatedAgentNotice'; import { collectOutdatedAgentHosts, @@ -270,7 +269,6 @@ export function VmwarePageSurface() { />
-