From 66c87fb6e8595af47c360573bb8efa51d46492d1 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 19 Aug 2026 19:40:05 +0100 Subject: [PATCH] fix(frontend): place estate totals beside labels --- .../subsystems/frontend-primitives.md | 23 +- .../subsystems/performance-and-scalability.md | 10 +- .../v6/internal/subsystems/registry.json | 4 - .../internal/subsystems/storage-recovery.md | 6 +- .../internal/subsystems/unified-resources.md | 3 +- frontend-modern/browser-verification.json | 49 ++- .../scripts/shared-template-registry.json | 14 +- .../components/Workloads/WorkloadsFilter.tsx | 335 ++++++++++-------- .../Workloads/WorkloadsInventorySummary.tsx | 131 ------- .../__tests__/WorkloadsFilter.test.tsx | 63 +++- .../WorkloadsInventorySummary.test.tsx | 68 ---- .../Workloads/workloadsFilterModel.ts | 9 +- .../components/shared/FilterBar/FilterBar.tsx | 1 + .../shared/FilterBar/filterCatalog.ts | 1 + .../shared/FilterButtonGroup.test.tsx | 15 + .../components/shared/FilterButtonGroup.tsx | 17 +- .../SharedPrimitives.guardrails.test.ts | 17 +- .../shared/filterButtonGroupModel.ts | 1 + .../features/proxmox/ProxmoxNodesTable.tsx | 12 +- .../features/proxmox/ProxmoxPageSurface.tsx | 19 +- .../__tests__/ProxmoxNodesTable.test.tsx | 17 + .../ProxmoxPageSurface.contract.test.tsx | 30 +- 22 files changed, 364 insertions(+), 481 deletions(-) delete mode 100644 frontend-modern/src/components/Workloads/WorkloadsInventorySummary.tsx delete mode 100644 frontend-modern/src/components/Workloads/__tests__/WorkloadsInventorySummary.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 e9827cfbe..5f559d56d 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2057,21 +2057,22 @@ 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 inside the canonical control surface operators - already scan. Large-estate workload totals must flow from the unfiltered - shared workload inventory into `WorkloadsFilter.tsx`, through the canonical - `WorkloadsInventorySummary` composed by `WorkloadsFilter` immediately - before the canonical `FilterBar`, so a single glanceable ribbon with - labelled primary totals plus workload-mix and health distributions exposes - the estate without another fetch, nested count badges, or a metric-card - grid. Provider - topology belongs in the existing table header; Proxmox +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 each number sits + directly beside the type or status label it describes without another + fetch or a competing summary 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 this overview ribbon + `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, + across platform workload surfaces. A page with adjacent totals, such as + Proxmox's Nodes header, must own one visibility signal and pass it to both + the shared filter and table instead of creating independently persisted + signals that only synchronize after a reload. 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. 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 b7c0edf78..222b180be 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -715,10 +715,12 @@ 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`. - The large-estate inventory summary must reuse one memoized stats pass over - the unfiltered workload snapshot from `useWorkloadsState`; its type totals - and status distribution read that bounded projection and must not rescan - resources during render or issue a separate summary request. + 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. Adjacent table headers must + consume the page-owned visibility accessor rather than mount another + persistent-signal owner for the same preference. 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 4c6898ee1..f986f0fe8 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -6386,7 +6386,6 @@ "frontend-modern/src/components/Workloads/workloadSelectors.ts", "frontend-modern/src/components/Workloads/WorkloadsFilter.tsx", "frontend-modern/src/components/Workloads/workloadsFilterModel.ts", - "frontend-modern/src/components/Workloads/WorkloadsInventorySummary.tsx", "frontend-modern/src/components/Workloads/WorkloadsSurface.tsx", "frontend-modern/src/components/Workloads/WorkloadsTable.tsx", "frontend-modern/src/components/Workloads/WorkloadTableHeader.tsx", @@ -6427,7 +6426,6 @@ "frontend-modern/src/components/Workloads/__tests__/workloadRouteStateModel.test.ts", "frontend-modern/src/components/Workloads/__tests__/workloadSelectionModel.test.ts", "frontend-modern/src/components/Workloads/__tests__/WorkloadsFilter.test.tsx", - "frontend-modern/src/components/Workloads/__tests__/WorkloadsInventorySummary.test.tsx", "frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx", "frontend-modern/src/components/Workloads/__tests__/workloadUrlSyncModel.test.ts", "frontend-modern/src/components/Workloads/MetricBar.test.tsx", @@ -6551,7 +6549,6 @@ "frontend-modern/src/components/Workloads/workloadSelectors.ts", "frontend-modern/src/components/Workloads/WorkloadsFilter.tsx", "frontend-modern/src/components/Workloads/workloadsFilterModel.ts", - "frontend-modern/src/components/Workloads/WorkloadsInventorySummary.tsx", "frontend-modern/src/components/Workloads/WorkloadsSurface.tsx", "frontend-modern/src/components/Workloads/WorkloadsTable.tsx", "frontend-modern/src/components/Workloads/WorkloadTableHeader.tsx", @@ -6592,7 +6589,6 @@ "frontend-modern/src/components/Workloads/__tests__/workloadSelectionModel.test.ts", "frontend-modern/src/components/Workloads/__tests__/workloadSelectors.test.ts", "frontend-modern/src/components/Workloads/__tests__/WorkloadsFilter.test.tsx", - "frontend-modern/src/components/Workloads/__tests__/WorkloadsInventorySummary.test.tsx", "frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx", "frontend-modern/src/components/Workloads/__tests__/workloadTopology.test.ts", "frontend-modern/src/components/Workloads/__tests__/workloadUrlSyncModel.test.ts", diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index c1a136c69..e0b5404f4 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -403,12 +403,12 @@ 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 integrated estate summary is likewise an adjacent frontend-primitives +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 inventory summary and derive cluster/standalone topology through +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. That summary must not imply +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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 1c8fb68f4..6f4aba8f8 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -283,8 +283,7 @@ Platform estate orientation consumes the same canonical resource projection as 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 one canonical estate ribbon adjacent to the shared -filter surface. The +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. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index ac4e15e14..9172d2a72 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,7 +1,7 @@ { "version": 1, - "base_sha": "12050efc56de5f53f978b361c0bd2ae9f53071c9", - "verified_at": "2026-08-19T17:14:39Z", + "base_sha": "4de1ede6030836d09c1e0a118c26c6413fd9f112", + "verified_at": "2026-08-19T18:37:46Z", "result": "passed", "changed_paths": [ "frontend-modern/src/components/Workloads/WorkloadsFilter.tsx", @@ -15,39 +15,32 @@ "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx" ], "content_sha256": { - "frontend-modern/src/components/Workloads/WorkloadsFilter.tsx": "1e9fa7d4b9679d5f5a1cd2f7c62d7a9d933158452c6731c31cd23b406ff9de78", - "frontend-modern/src/components/Workloads/WorkloadsInventorySummary.tsx": "a18190ea5320759f6f15c0392934984e9fbb65d261c2838fe919a06da9814dc9", - "frontend-modern/src/components/Workloads/workloadsFilterModel.ts": "8f1b943923c4e5e7f1285ef09d565639ea343b9040d9a99625ec9b44ed265200", - "frontend-modern/src/components/shared/FilterBar/FilterBar.tsx": "59e60b5ecd379350de9284a27a739023730d3e2c7ca519962d766d69276c565c", - "frontend-modern/src/components/shared/FilterBar/filterCatalog.ts": "6fa670b97179850b445dd8b48198f9e3fbcb564add7f405c70fa5af11ad795fe", - "frontend-modern/src/components/shared/FilterButtonGroup.tsx": "0d1b8ab603b2e43986c53354dafebd3a652200479b50ecd2d4d8ffd6281aea96", - "frontend-modern/src/components/shared/filterButtonGroupModel.ts": "33408c8af3eece8604647d6cb7133ef675b50adbc983c0451200f35e99bf7075", - "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "59e64ba42750c4c2d71e2d1640cc403ded9ce711d376148a823bc0a248c50380", - "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "0003dd53e4f6f4f8ffe4ab5a41f10259d7eef1efcf157e941ec15396de43a24b" + "frontend-modern/src/components/Workloads/WorkloadsFilter.tsx": "05dc8ff16997ab1f7663a13b9df71ab8219607466933a46e5f2009f96ec80562", + "frontend-modern/src/components/Workloads/WorkloadsInventorySummary.tsx": "deleted", + "frontend-modern/src/components/Workloads/workloadsFilterModel.ts": "d2b4e7e74c81ba69e7f9c4003568108f02c0dc8bc9071555a1a46bddb4af3908", + "frontend-modern/src/components/shared/FilterBar/FilterBar.tsx": "98f8573ace4b7bf5a8ad5bdf6b33e0b6a75ebaabff83f6cd947c233b5a8b9843", + "frontend-modern/src/components/shared/FilterBar/filterCatalog.ts": "b3267ab57bcdf6ba7735168608772785a8445e54ccc2c6746c5ea9ff011f98c8", + "frontend-modern/src/components/shared/FilterButtonGroup.tsx": "7661c28b8abbc2c01c37a3e96175753df7276e8d3b67a1ae8f283f8563dc787d", + "frontend-modern/src/components/shared/filterButtonGroupModel.ts": "06576097404a56f66d6ae0ef23fc913dbff11ceb5833140d00df58ad9711fe41", + "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "29d2892a9b16e926cdafc35372b2ba7f7f8fe38b8099f7bb51d92319a4995f09", + "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "2fd5c8176ea82f7c39dfe1fab8c8500001fd48c37b0fa17118d9febf3853ff0f" }, - "routes": [ - "/proxmox/overview", - "/docker/overview", - "/kubernetes/overview", - "/truenas/overview", - "/vmware/overview", - "/machines" - ], + "routes": ["/proxmox/overview", "/vmware/overview"], "viewports": [ { "width": 1440, "height": 900 }, { "width": 390, "height": 844 } ], "states": [ - "Large Proxmox estate shown as one glanceable ribbon with 718 workloads, 32 nodes, five clusters, and two standalone nodes", - "Explicit workload-mix and health charts with labelled counts above the clean filter controls", - "Desktop utility controls wrapping as one right-aligned action cluster", - "Phone layout at 390 px with a two-by-two estate total grid, two full-width charts, and no horizontal page overflow", - "Estate overview hidden through View and then restored", - "Docker, Kubernetes, TrueNAS, VMware vSphere, and Standalone overview routes without the retired estate panel" + "Proxmox type and status totals shown as centred 11px tabular numbers beside their 12px labels", + "Proxmox Nodes total and cluster/standalone topology attached to the existing table heading", + "Inventory totals hidden from both filter controls and the Nodes heading through one shared preference, then restored", + "Selected Attention filter with inline count and clear-filter action", + "Phone filter collapsed and expanded at 390px with inline totals and no horizontal page overflow", + "vSphere workload status totals rendered through the same shared filter option-count contract" ], "interactions": [ - "Checked the full estate ribbon and clean collapsed filter control at 390 x 844", - "Opened View, selected Hide for Inventory totals, confirmed the ribbon disappeared, then restored Show", - "Navigated all six platform overview routes and confirmed the shared panel was absent" + "Opened View, selected Hide for Inventory totals, confirmed workload and node/topology totals disappeared immediately, then restored Show", + "Expanded the phone Filters control and inspected both count-bearing segmented controls", + "Navigated from Proxmox to vSphere and confirmed shared workload totals beside provider-specific status labels" ] } diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index dd587b816..f53bebf60 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -3195,18 +3195,20 @@ ] }, { - "id": "platform-estate-inventory-summary", + "id": "platform-estate-inline-counts", "category": "platform-overview", - "summary": "Large-estate totals use one glanceable canonical ribbon immediately before the existing filter surface, with labelled primary totals and workload/health distributions instead of nested badges or a metric-card grid; the shared View preference owns visibility.", + "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/components/Workloads/WorkloadsInventorySummary.tsx", - "export": "WorkloadsInventorySummary" + "path": "src/components/Workloads/WorkloadsFilter.tsx", + "export": "WorkloadsFilter" }, - "requiredConsumers": [{ "path": "src/components/Workloads/WorkloadsFilter.tsx" }], + "requiredConsumers": [ + { "path": "src/features/proxmox/ProxmoxPageSurface.tsx" }, + { "path": "src/features/vmware/VmwarePageSurface.tsx" } + ], "proof": [ "src/components/shared/SharedPrimitives.guardrails.test.ts", "src/components/Workloads/__tests__/WorkloadsFilter.test.tsx", - "src/components/Workloads/__tests__/WorkloadsInventorySummary.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 1e5a0a192..903a9a43b 100644 --- a/frontend-modern/src/components/Workloads/WorkloadsFilter.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadsFilter.tsx @@ -46,15 +46,23 @@ import { hasActiveWorkloadsFilters, } from './workloadsFilterModel'; import { WORKLOAD_STATUS_FILTER_OPTIONS, WORKLOAD_TYPE_OPTIONS } from './workloadFilterConfigModel'; -import { WorkloadsInventorySummary } from './WorkloadsInventorySummary'; export const WorkloadsFilter: Component = (props) => { const { isMobile } = useBreakpoint(); - const [inventoryCountsVisible, setInventoryCountsVisible] = usePersistentSignal( - PLATFORM_ESTATE_COUNTS_STORAGE_KEY, - true, - { deserialize: deserializePlatformEstateCountsVisibility }, - ); + const persistedInventoryCounts = props.inventoryCountsVisible + ? undefined + : usePersistentSignal(PLATFORM_ESTATE_COUNTS_STORAGE_KEY, true, { + deserialize: deserializePlatformEstateCountsVisibility, + }); + const inventoryCountsVisible = () => + props.inventoryCountsVisible?.() ?? persistedInventoryCounts?.[0]() ?? true; + const setInventoryCountsVisible = (visible: boolean) => { + if (props.setInventoryCountsVisible) { + props.setInventoryCountsVisible(visible); + return; + } + persistedInventoryCounts?.[1](visible); + }; const typeValue = () => isContainerWorkloadViewMode(props.viewMode()) ? 'container' : props.viewMode(); @@ -68,6 +76,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( @@ -87,8 +105,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, @@ -109,6 +138,7 @@ export const WorkloadsFilter: Component = (props) => { : option.value === 'stopped' ? 'danger' : undefined, + count: workloadStatusCount(option.value), })); const runtimeChipLabel = (value: string): string => { @@ -259,162 +289,153 @@ export const WorkloadsFilter: Component = (props) => { }; return ( - <> - - - - props.onClearPinnedSelection?.()} - > - - Clear selection - - ) : undefined - } - viewOptions={ - <> + props.onClearPinnedSelection?.()} + > + + Clear selection + + ) : undefined + } + viewOptions={ + <> +
+
+ Layout +
+ +
+ +
- Layout + Metrics
-
- - -
-
- Metrics -
- -
-
- - -
-
- Memory relative to -
- - props.setMemoryDisplayBasis!(value as WorkloadsMemoryDisplayBasis) - } - options={[ - { - value: 'guest', - label: 'Guest allocation', - ariaLabel: 'Guest', - title: 'Show memory as a percentage of each guest allocation', - }, - { - value: 'host', - label: 'Host capacity', - ariaLabel: 'Host', - title: 'Show memory as a percentage of the Proxmox host total', - }, - ]} - /> -
-
- - -
-
- Inventory totals -
- setInventoryCountsVisible(value === 'shown')} - options={[ - { value: 'shown', label: 'Show' }, - { value: 'hidden', label: 'Hide' }, - ]} - /> -
-
- - -
-
- Summary -
- props.onChartsToggle?.()} - /> -
-
- - - {(visibility) => ( -
-
- Table -
- -
- )} -
- - } - trailingControls={ - - - } - onClearAll={handleClearAll} - showClearAll={showClearAll} - /> - + + +
+
+ Memory relative to +
+ + props.setMemoryDisplayBasis!(value as WorkloadsMemoryDisplayBasis) + } + options={[ + { + value: 'guest', + label: 'Guest allocation', + ariaLabel: 'Guest', + title: 'Show memory as a percentage of each guest allocation', + }, + { + value: 'host', + label: 'Host capacity', + ariaLabel: 'Host', + title: 'Show memory as a percentage of the Proxmox host total', + }, + ]} + /> +
+
+ + +
+
+ Inventory totals +
+ setInventoryCountsVisible(value === 'shown')} + options={[ + { value: 'shown', label: 'Show' }, + { value: 'hidden', label: 'Hide' }, + ]} + /> +
+
+ + +
+
+ Summary +
+ props.onChartsToggle?.()} + /> +
+
+ + + {(visibility) => ( +
+
+ Table +
+ +
+ )} +
+ + } + trailingControls={ + + + + } + onClearAll={handleClearAll} + showClearAll={showClearAll} + /> ); }; diff --git a/frontend-modern/src/components/Workloads/WorkloadsInventorySummary.tsx b/frontend-modern/src/components/Workloads/WorkloadsInventorySummary.tsx deleted file mode 100644 index feef031fd..000000000 --- a/frontend-modern/src/components/Workloads/WorkloadsInventorySummary.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { For, Show, createMemo, type Component } from 'solid-js'; -import { Card } from '@/components/shared/Card'; -import type { WorkloadsInventoryStats, WorkloadsInventoryTopology } from './workloadsFilterModel'; - -const formatCount = (value: number): string => value.toLocaleString(); - -const segmentWidth = (value: number, total: number): string => - `${total > 0 ? Math.max(0, (value / total) * 100) : 0}%`; - -type DistributionItem = { label: string; value: number; class: string }; - -const Distribution: Component<{ - label: string; - items: DistributionItem[]; - total: number; -}> = (props) => ( -
-
-

{props.label}

-
- item.value > 0)}> - {(item) => ( - - - )} - -
-
- -
-); - -export const WorkloadsInventorySummary: Component<{ - stats: WorkloadsInventoryStats; - topology?: WorkloadsInventoryTopology; - containerLabel?: string; -}> = (props) => { - const containerCount = () => props.stats.containers + props.stats.appContainers; - const primaryStats = createMemo(() => { - const topology = props.topology; - if (topology) { - return [ - { label: 'Workloads', value: props.stats.total }, - { label: 'Nodes', value: topology.nodes }, - { label: 'Clusters', value: topology.clusters }, - { label: 'Standalone', value: topology.standalone }, - ]; - } - return [ - { label: 'Workloads', value: props.stats.total }, - { label: 'Running', value: props.stats.running }, - { label: 'Attention', value: props.stats.degraded }, - ]; - }); - const typeItems = createMemo(() => - [ - { label: 'VMs', value: props.stats.vms, class: 'bg-sky-500' }, - { - label: props.containerLabel ?? 'containers', - value: containerCount(), - class: 'bg-violet-500', - }, - { label: 'pods', value: props.stats.pods, class: 'bg-cyan-400' }, - ].filter((item) => item.value > 0), - ); - const statusItems = createMemo(() => [ - { label: 'running', value: props.stats.running, class: 'bg-emerald-500' }, - { label: 'attention', value: props.stats.degraded, class: 'bg-amber-500' }, - { label: 'stopped', value: props.stats.stopped, class: 'bg-red-500' }, - ]); - - return ( - -
-

Estate overview

- - Current inventory - -
-
-
- - {(item, index) => ( -
-
- {formatCount(item.value)} -
-
{item.label}
-
- )} -
-
-
- - -
-
-
- ); -}; - -export default WorkloadsInventorySummary; diff --git a/frontend-modern/src/components/Workloads/__tests__/WorkloadsFilter.test.tsx b/frontend-modern/src/components/Workloads/__tests__/WorkloadsFilter.test.tsx index 2f68a0155..8c46b0492 100644 --- a/frontend-modern/src/components/Workloads/__tests__/WorkloadsFilter.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/WorkloadsFilter.test.tsx @@ -163,7 +163,7 @@ describe('WorkloadsFilter', () => { expect(dialog.getByRole('button', { name: 'List' })).toBeInTheDocument(); }); - it('presents large-estate totals as one glanceable overview instead of nested filter badges', () => { + it('lands large-estate totals beside the type and status labels they describe', () => { render(() => ( { /> )); - const overview = screen.getByRole('region', { name: 'Estate overview' }); - expect(overview).toHaveTextContent('578Workloads'); - expect(overview).toHaveTextContent('253 VMs'); - expect(overview).toHaveTextContent('325 LXCs'); expect( - within(overview).getByRole('img', { - name: 'Health: 500 running, 12 attention, 66 stopped', - }), - ).toBeInTheDocument(); + within(inlineFilterGroup('Type')).getByRole('button', { name: 'All, 578' }), + ).toHaveTextContent('578'); expect( - within(inlineFilterGroup('Type')).getByRole('button', { name: 'VMs' }), - ).not.toHaveTextContent('253'); + within(inlineFilterGroup('Type')).getByRole('button', { name: 'VMs, 253' }), + ).toHaveTextContent('253'); expect( - within(inlineFilterGroup('Status')).getByRole('button', { name: 'Degraded' }), - ).not.toHaveTextContent('12'); + 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', () => { @@ -221,10 +218,48 @@ describe('WorkloadsFilter', () => { const visibility = dialog.getByRole('group', { name: 'Inventory totals visibility' }); fireEvent.click(within(visibility).getByRole('button', { name: 'Hide' })); - expect(screen.queryByRole('region', { name: 'Estate overview' })).not.toBeInTheDocument(); + expect( + within(inlineFilterGroup('Type')).getByRole('button', { name: 'All' }), + ).not.toHaveTextContent('578'); expect(window.localStorage.getItem('platformEstateOverviewVisible')).toBe('false'); }); + it('supports one page-owned visibility signal for filters and adjacent headers', () => { + const setInventoryCountsVisible = vi.fn(); + render(() => ( + ({ + total: 578, + running: 500, + degraded: 12, + stopped: 66, + vms: 253, + containers: 325, + appContainers: 0, + pods: 0, + }), + inventoryCountsVisible: () => false, + setInventoryCountsVisible, + })} + /> + )); + + expect( + within(inlineFilterGroup('Type')).getByRole('button', { name: 'All' }), + ).not.toHaveTextContent('578'); + + const dialog = within(openViewPreferences()); + fireEvent.click( + within(dialog.getByRole('group', { name: 'Inventory totals visibility' })).getByRole( + 'button', + { name: 'Show' }, + ), + ); + expect(setInventoryCountsVisible).toHaveBeenCalledWith(true); + expect(window.localStorage.getItem('platformEstateOverviewVisible')).toBeNull(); + }); + 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/__tests__/WorkloadsInventorySummary.test.tsx b/frontend-modern/src/components/Workloads/__tests__/WorkloadsInventorySummary.test.tsx deleted file mode 100644 index 317d7aec2..000000000 --- a/frontend-modern/src/components/Workloads/__tests__/WorkloadsInventorySummary.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { cleanup, render, screen, within } from '@solidjs/testing-library'; -import { afterEach, describe, expect, it } from 'vitest'; -import { WorkloadsInventorySummary } from '../WorkloadsInventorySummary'; - -describe('WorkloadsInventorySummary', () => { - afterEach(cleanup); - - it('shows labelled estate totals and two explicit distributions', () => { - render(() => ( - - )); - - const summary = screen.getByRole('region', { name: 'Estate overview' }); - expect(summary).toHaveTextContent('718Workloads'); - expect(summary).toHaveTextContent('32Nodes'); - expect(summary).toHaveTextContent('5Clusters'); - expect(summary).toHaveTextContent('2Standalone'); - expect(summary).toHaveTextContent('283 VMs'); - expect(summary).toHaveTextContent('315 LXCs'); - expect(summary).toHaveTextContent('120 pods'); - expect( - within(summary).getByRole('img', { - name: 'Workload mix: 283 VMs, 315 LXCs, 120 pods', - }), - ).toBeInTheDocument(); - expect( - within(summary).getByRole('img', { - name: 'Health: 635 running, 36 attention, 47 stopped', - }), - ).toBeInTheDocument(); - }); - - it('omits empty type categories without losing status context', () => { - render(() => ( - - )); - - const summary = screen.getByRole('region', { name: 'Estate overview' }); - expect(summary).toHaveTextContent('10 VMs'); - expect(summary).not.toHaveTextContent('containers'); - expect(summary).not.toHaveTextContent('pods'); - expect(summary).toHaveTextContent('10Running'); - }); -}); diff --git a/frontend-modern/src/components/Workloads/workloadsFilterModel.ts b/frontend-modern/src/components/Workloads/workloadsFilterModel.ts index 73b2ce0ff..b7ee4d237 100644 --- a/frontend-modern/src/components/Workloads/workloadsFilterModel.ts +++ b/frontend-modern/src/components/Workloads/workloadsFilterModel.ts @@ -26,12 +26,6 @@ export interface WorkloadsInventoryStats { pods: number; } -export interface WorkloadsInventoryTopology { - nodes: number; - clusters: number; - standalone: number; -} - export interface WorkloadsFilterSelectOption { value: string; label: string; @@ -63,7 +57,8 @@ export interface WorkloadsFilterProps { searchEmptyMessage?: string; statusOptions?: readonly WorkloadsStatusOption[]; inventoryStats?: () => WorkloadsInventoryStats; - inventoryTopology?: () => WorkloadsInventoryTopology; + inventoryCountsVisible?: () => boolean; + setInventoryCountsVisible?: (visible: boolean) => void; 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 fcb5b8217..e83c71e0c 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/FilterBar/filterCatalog.ts b/frontend-modern/src/components/shared/FilterBar/filterCatalog.ts index f6cedd72b..c32ee80ee 100644 --- a/frontend-modern/src/components/shared/FilterBar/filterCatalog.ts +++ b/frontend-modern/src/components/shared/FilterBar/filterCatalog.ts @@ -12,6 +12,7 @@ export interface FilterSelectOption { visualLabel?: JSX.Element; icon?: (props: { class?: string }) => JSX.Element; tone?: FilterButtonGroupOptionTone; + count?: number; } export type FilterGroupKey = 'scope' | 'status' | 'properties'; diff --git a/frontend-modern/src/components/shared/FilterButtonGroup.test.tsx b/frontend-modern/src/components/shared/FilterButtonGroup.test.tsx index af89808c7..74603ddfc 100644 --- a/frontend-modern/src/components/shared/FilterButtonGroup.test.tsx +++ b/frontend-modern/src/components/shared/FilterButtonGroup.test.tsx @@ -68,6 +68,21 @@ describe('FilterButtonGroup', () => { expect(onChange).toHaveBeenCalledWith('dark'); }); + it('renders an optional count directly beside its label', () => { + render(() => ( + undefined} + variant="compact" + /> + )); + + const button = screen.getByRole('button', { name: 'VMs, 1,234' }); + expect(button).toHaveTextContent('VMs1,234'); + expect(button.querySelector('[aria-hidden="true"]')).toHaveClass('tabular-nums'); + }); + it('blocks disabled option changes in the runtime owner', () => { const onChange = vi.fn(); diff --git a/frontend-modern/src/components/shared/FilterButtonGroup.tsx b/frontend-modern/src/components/shared/FilterButtonGroup.tsx index 22f072812..af3bf6c77 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 3b0386ba9..57bce11dc 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -412,8 +412,8 @@ describe('shared primitive guardrails', () => { }>; }; const registeredRule = registry.rules?.find((rule) => rule.id === 'filter-button-group-shell'); - const estateSummaryRule = registry.rules?.find( - (rule) => rule.id === 'platform-estate-inventory-summary', + 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', @@ -424,12 +424,13 @@ describe('shared primitive guardrails', () => { expect(registeredRule?.canonical?.path).toBe('src/components/shared/FilterButtonGroup.tsx'); expect(registeredRule?.canonical?.export).toBe('FilterButtonGroup'); - expect(estateSummaryRule?.canonical).toEqual({ - path: 'src/components/Workloads/WorkloadsInventorySummary.tsx', - export: 'WorkloadsInventorySummary', + expect(estateCountsRule?.canonical).toEqual({ + path: 'src/components/Workloads/WorkloadsFilter.tsx', + export: 'WorkloadsFilter', }); - expect(estateSummaryRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([ - 'src/components/Workloads/WorkloadsFilter.tsx', + 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', @@ -498,8 +499,8 @@ 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('WorkloadsInventorySummary'); expect(workloadsFilterSource).toContain('PLATFORM_ESTATE_COUNTS_STORAGE_KEY'); expect(generalSettingsPanelSource).toContain('FilterButtonGroup'); expect(generalSettingsPanelSource.match(/ { visualLabel?: JSX.Element; icon?: (props: { class?: string }) => JSX.Element; tone?: FilterButtonGroupOptionTone; + count?: number; disabled?: boolean; } diff --git a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx index 378b2fe51..f8f36f3c2 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx @@ -22,7 +22,6 @@ 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'; @@ -45,11 +44,7 @@ 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 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'; @@ -202,11 +197,10 @@ export const ProxmoxNodesTable: Component<{ emptyTitle: string; emptyDescription: string; topology?: ProxmoxEstateTopology; + inventoryCountsVisible?: Accessor; }> = (props) => { const breakpoint = useBreakpoint(); - const [inventoryCountsVisible] = usePersistentSignal(PLATFORM_ESTATE_COUNTS_STORAGE_KEY, true, { - deserialize: deserializePlatformEstateCountsVisibility, - }); + const inventoryCountsVisible = () => props.inventoryCountsVisible?.() ?? true; const { activeAlerts } = useWebSocket(); const alertsActivation = useAlertsActivation(); const alertsEnabled = alertsActivation.detectionEnabled; diff --git a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx index 02c30006c..c562d6839 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx @@ -26,7 +26,11 @@ import { import { getPlatformIcon } from '@/features/platformPage/platformIcon'; import { PlatformOutdatedAgentNotice } from '@/features/platformPage/PlatformOutdatedAgentNotice'; import { PlatformOutdatedSensorSetupNotice } from '@/features/platformPage/PlatformOutdatedSensorSetupNotice'; -import { buildProxmoxEstateTopology } from '@/features/platformPage/platformEstateOverviewModel'; +import { + PLATFORM_ESTATE_COUNTS_STORAGE_KEY, + buildProxmoxEstateTopology, + deserializePlatformEstateCountsVisibility, +} from '@/features/platformPage/platformEstateOverviewModel'; import { collectOutdatedSensorSetupNodes } from '@/features/platformPage/sensorSetup'; import { usePersistentSignal } from '@/hooks/usePersistentSignal'; import { useObservedElementWidth } from '@/hooks/useObservedElementWidth'; @@ -156,6 +160,11 @@ export function ProxmoxPageSurface() { deserialize: (raw) => (raw === 'host' ? 'host' : 'guest'), }, ); + const [inventoryCountsVisible, setInventoryCountsVisible] = usePersistentSignal( + PLATFORM_ESTATE_COUNTS_STORAGE_KEY, + true, + { deserialize: deserializePlatformEstateCountsVisibility }, + ); return (
@@ -212,6 +221,8 @@ export function ProxmoxPageSurface() { setMetricHistoryRange={setMetricHistoryRange} memoryDisplayBasis={memoryDisplayBasis} setMemoryDisplayBasis={setMemoryDisplayBasis} + inventoryCountsVisible={inventoryCountsVisible} + setInventoryCountsVisible={setInventoryCountsVisible} />
@@ -271,6 +282,8 @@ interface ProxmoxOverviewProps { setMetricHistoryRange: (value: WorkloadTableMetricHistoryRange) => void; memoryDisplayBasis: Accessor; setMemoryDisplayBasis: (value: WorkloadsMemoryDisplayBasis) => void; + inventoryCountsVisible: Accessor; + setInventoryCountsVisible: (visible: boolean) => void; } function ProxmoxOverview(props: ProxmoxOverviewProps) { @@ -335,7 +348,8 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) { searchEmptyMessage="Recent Proxmox workload searches appear here." statusOptions={PROXMOX_WORKLOAD_STATUS_OPTIONS} inventoryStats={workloadsState.inventoryStats} - inventoryTopology={estateTopology} + inventoryCountsVisible={props.inventoryCountsVisible} + setInventoryCountsVisible={props.setInventoryCountsVisible} columnVisibility={workloadsState.workloadsFilterColumnVisibility()} containerRuntimeFilter={workloadsState.containerRuntimeFilterConfig()} hostFilter={workloadsState.hostFilterConfig()} @@ -367,6 +381,7 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) { emptyTitle="No Proxmox VE nodes" emptyDescription="Proxmox VE nodes appear here once a PVE host reports inventory." topology={estateTopology()} + inventoryCountsVisible={props.inventoryCountsVisible} /> { }); describe('ProxmoxNodesTable', () => { + it('hides node and topology totals through the page-owned inventory preference', () => { + render(() => ( + false} + emptyIcon={} + emptyTitle="No Proxmox VE nodes" + emptyDescription="No nodes" + /> + )); + + expect(screen.getByText('Nodes').parentElement).toHaveTextContent(/^Nodes$/); + expect(screen.queryByText('1 cluster')).not.toBeInTheDocument(); + }); + it('links each node to its PVE web interface without hijacking the row click', () => { render(() => ( vi.fn()); const mockStorageProps = vi.hoisted(() => vi.fn()); const mockTotalStats = vi.hoisted(() => vi.fn()); const mockNodesTableProps = vi.hoisted(() => vi.fn()); -const mockWorkloadsFilterProps = vi.hoisted(() => vi.fn()); -const mockSurfaceConnected = vi.hoisted(() => vi.fn(() => false)); -const mockSurfaceInitialDataReceived = vi.hoisted(() => vi.fn(() => false)); -const mockAllGuests = vi.hoisted(() => vi.fn((): unknown[] => [])); const makeResource = (resource: Partial & Pick): Resource => ({ @@ -76,10 +72,7 @@ vi.mock('@/components/Storage/Storage', () => ({ })); vi.mock('@/components/Workloads/WorkloadsFilter', () => ({ - WorkloadsFilter: (props: { inventoryTopology?: () => unknown }) => { - mockWorkloadsFilterProps(props); - return
; - }, + WorkloadsFilter: () =>
, })); vi.mock('@/components/Workloads/WorkloadsSurface', () => ({ @@ -88,9 +81,9 @@ vi.mock('@/components/Workloads/WorkloadsSurface', () => ({ vi.mock('@/components/Workloads/useWorkloadsState', () => ({ useWorkloadsState: () => ({ - surfaceConnected: mockSurfaceConnected, - surfaceInitialDataReceived: mockSurfaceInitialDataReceived, - allGuests: mockAllGuests, + surfaceConnected: () => false, + surfaceInitialDataReceived: () => false, + allGuests: () => [], totalStats: mockTotalStats, search: () => '', setSearch: vi.fn(), @@ -148,9 +141,6 @@ describe('ProxmoxPageSurface contract', () => { beforeEach(() => { mockPathname.mockReturnValue('/proxmox/overview'); mockVersionInfo.mockReturnValue(null); - mockSurfaceConnected.mockReturnValue(false); - mockSurfaceInitialDataReceived.mockReturnValue(false); - mockAllGuests.mockReturnValue([]); mockTotalStats.mockReturnValue({ total: 3, running: 1, @@ -247,9 +237,6 @@ describe('ProxmoxPageSurface contract', () => { }); it('folds estate topology into the existing nodes table header contract', () => { - mockSurfaceConnected.mockReturnValue(true); - mockSurfaceInitialDataReceived.mockReturnValue(true); - mockAllGuests.mockReturnValue([{}]); setResources([ makeResource({ id: 'agent:pve-1', @@ -282,15 +269,6 @@ describe('ProxmoxPageSurface contract', () => { topology: { clusters: 1, nodes: 1, standalone: 0 }, }), ); - - const filterProps = mockWorkloadsFilterProps.mock.calls.at(-1)?.[0] as { - inventoryTopology?: () => unknown; - }; - expect(filterProps.inventoryTopology?.()).toEqual({ - clusters: 1, - nodes: 1, - standalone: 0, - }); }); it('keeps Patrol coverage off the Proxmox overview', () => {