fix(frontend): place estate totals beside labels

This commit is contained in:
rcourtman
2026-08-19 19:40:05 +01:00
parent e19478d85d
commit 66c87fb6e8
22 changed files with 364 additions and 481 deletions
@@ -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.
@@ -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.
@@ -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",
@@ -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
@@ -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.
+21 -28
View File
@@ -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"
]
}
@@ -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"
@@ -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<WorkloadsFilterProps> = (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<WorkloadsFilterProps> = (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<WorkloadsFilterProps> = (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<WorkloadsFilterProps> = (props) => {
: option.value === 'stopped'
? 'danger'
: undefined,
count: workloadStatusCount(option.value),
}));
const runtimeChipLabel = (value: string): string => {
@@ -259,162 +289,153 @@ export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
};
return (
<>
<Show when={inventoryCountsVisible() && props.inventoryStats}>
<WorkloadsInventorySummary
stats={props.inventoryStats!()}
topology={props.inventoryTopology?.()}
containerLabel={isProxmoxScope() ? 'LXCs' : 'containers'}
/>
</Show>
<FilterBar
role="group"
ariaLabel={props.ariaLabel ?? 'Workloads filters'}
isMobile={isMobile}
search={{
value: props.search,
setValue: props.setSearch,
placeholder: props.searchPlaceholder ?? 'Search workloads by name, ID, node, or image',
historyKey: STORAGE_KEYS.WORKLOADS_SEARCH_HISTORY,
emptyMessage: props.searchEmptyMessage ?? 'Recent workload searches appear here.',
onBeforeAutoFocus: props.onBeforeAutoFocus,
}}
searchTrailing={props.searchTrailing}
filters={buildFilters()}
showAddFilterLabel={false}
savedViewsKey={props.savedViewsKey}
leadingControls={
props.pinnedSelectionActive?.() && props.onClearPinnedSelection ? (
<FilterActionButton
aria-label="Clear pinned selection"
title="Clear pinned selection"
onClick={() => props.onClearPinnedSelection?.()}
>
<XIcon class="h-3 w-3" />
Clear selection
</FilterActionButton>
) : undefined
}
viewOptions={
<>
<FilterBar
role="group"
ariaLabel={props.ariaLabel ?? 'Workloads filters'}
isMobile={isMobile}
search={{
value: props.search,
setValue: props.setSearch,
placeholder: props.searchPlaceholder ?? 'Search workloads by name, ID, node, or image',
historyKey: STORAGE_KEYS.WORKLOADS_SEARCH_HISTORY,
emptyMessage: props.searchEmptyMessage ?? 'Recent workload searches appear here.',
onBeforeAutoFocus: props.onBeforeAutoFocus,
}}
searchTrailing={props.searchTrailing}
filters={buildFilters()}
showAddFilterLabel={false}
savedViewsKey={props.savedViewsKey}
leadingControls={
props.pinnedSelectionActive?.() && props.onClearPinnedSelection ? (
<FilterActionButton
aria-label="Clear pinned selection"
title="Clear pinned selection"
onClick={() => props.onClearPinnedSelection?.()}
>
<XIcon class="h-3 w-3" />
Clear selection
</FilterActionButton>
) : undefined
}
viewOptions={
<>
<div>
<div class="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted">
Layout
</div>
<GroupedTableModeSegmentedControl
value={props.groupingMode()}
onChange={props.setGroupingMode}
/>
</div>
<Show when={props.metricDisplayMode && props.setMetricDisplayMode}>
<div>
<div class="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted">
Layout
Metrics
</div>
<GroupedTableModeSegmentedControl
value={props.groupingMode()}
onChange={props.setGroupingMode}
<MetricDisplayModeSegmentedControl
value={props.metricDisplayMode!()}
onChange={props.setMetricDisplayMode!}
/>
</div>
<Show when={props.metricDisplayMode && props.setMetricDisplayMode}>
<div>
<div class="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted">
Metrics
</div>
<MetricDisplayModeSegmentedControl
value={props.metricDisplayMode!()}
onChange={props.setMetricDisplayMode!}
/>
</div>
</Show>
<Show when={props.memoryDisplayBasis && props.setMemoryDisplayBasis}>
<div>
<div class="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted">
Memory relative to
</div>
<FilterSegmentedControl
aria-label="Memory percentage basis"
value={props.memoryDisplayBasis!()}
onChange={(value) =>
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',
},
]}
/>
</div>
</Show>
<Show when={props.inventoryStats}>
<div>
<div class="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted">
Inventory totals
</div>
<FilterSegmentedControl
aria-label="Inventory totals visibility"
value={inventoryCountsVisible() ? 'shown' : 'hidden'}
onChange={(value) => setInventoryCountsVisible(value === 'shown')}
options={[
{ value: 'shown', label: 'Show' },
{ value: 'hidden', label: 'Hide' },
]}
/>
</div>
</Show>
<Show when={props.onChartsToggle}>
<div>
<div class="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted">
Summary
</div>
<ChartVisibilityToggleButton
class="!inline-flex"
collapsed={props.chartsCollapsed?.() ?? false}
onToggle={() => props.onChartsToggle?.()}
/>
</div>
</Show>
<Show when={props.columnVisibility}>
{(visibility) => (
<div>
<div class="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted">
Table
</div>
<ColumnPicker
inline
columns={visibility().availableColumns}
isHidden={visibility().isColumnHidden}
onToggle={visibility().onColumnToggle}
onReset={visibility().onColumnReset}
showReset={visibility().showReset}
/>
</div>
)}
</Show>
</>
}
trailingControls={
<Show
when={
props.metricDisplayMode?.() === 'sparklines' &&
props.metricHistoryRange &&
props.setMetricHistoryRange
}
>
<MetricHistoryRangeSegmentedControl
label="Trend range"
range={props.metricHistoryRange!()}
onRangeChange={props.setMetricHistoryRange!}
/>
</Show>
}
onClearAll={handleClearAll}
showClearAll={showClearAll}
/>
</>
<Show when={props.memoryDisplayBasis && props.setMemoryDisplayBasis}>
<div>
<div class="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted">
Memory relative to
</div>
<FilterSegmentedControl
aria-label="Memory percentage basis"
value={props.memoryDisplayBasis!()}
onChange={(value) =>
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',
},
]}
/>
</div>
</Show>
<Show when={props.inventoryStats}>
<div>
<div class="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted">
Inventory totals
</div>
<FilterSegmentedControl
aria-label="Inventory totals visibility"
value={inventoryCountsVisible() ? 'shown' : 'hidden'}
onChange={(value) => setInventoryCountsVisible(value === 'shown')}
options={[
{ value: 'shown', label: 'Show' },
{ value: 'hidden', label: 'Hide' },
]}
/>
</div>
</Show>
<Show when={props.onChartsToggle}>
<div>
<div class="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted">
Summary
</div>
<ChartVisibilityToggleButton
class="!inline-flex"
collapsed={props.chartsCollapsed?.() ?? false}
onToggle={() => props.onChartsToggle?.()}
/>
</div>
</Show>
<Show when={props.columnVisibility}>
{(visibility) => (
<div>
<div class="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted">
Table
</div>
<ColumnPicker
inline
columns={visibility().availableColumns}
isHidden={visibility().isColumnHidden}
onToggle={visibility().onColumnToggle}
onReset={visibility().onColumnReset}
showReset={visibility().showReset}
/>
</div>
)}
</Show>
</>
}
trailingControls={
<Show
when={
props.metricDisplayMode?.() === 'sparklines' &&
props.metricHistoryRange &&
props.setMetricHistoryRange
}
>
<MetricHistoryRangeSegmentedControl
label="Trend range"
range={props.metricHistoryRange!()}
onRangeChange={props.setMetricHistoryRange!}
/>
</Show>
}
onClearAll={handleClearAll}
showClearAll={showClearAll}
/>
);
};
@@ -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) => (
<div class="space-y-2">
<div class="flex items-center justify-between gap-3">
<h3 class="text-[10px] font-semibold uppercase tracking-wide text-muted">{props.label}</h3>
<div class="flex flex-wrap items-center justify-end gap-x-3 gap-y-1 text-[11px] text-muted">
<For each={props.items.filter((item) => item.value > 0)}>
{(item) => (
<span class="inline-flex items-center gap-1.5 whitespace-nowrap">
<span aria-hidden="true" class={`h-1.5 w-1.5 rounded-full ${item.class}`} />
<strong class="font-semibold tabular-nums text-base-content">
{formatCount(item.value)}
</strong>{' '}
{item.label}
</span>
)}
</For>
</div>
</div>
<div
class="flex h-2.5 overflow-hidden rounded-full bg-surface-alt ring-1 ring-inset ring-border-subtle"
role="img"
aria-label={`${props.label}: ${props.items
.map((item) => `${formatCount(item.value)} ${item.label}`)
.join(', ')}`}
>
<For each={props.items}>
{(item) => (
<Show when={item.value > 0}>
<span class={item.class} style={{ width: segmentWidth(item.value, props.total) }} />
</Show>
)}
</For>
</div>
</div>
);
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<DistributionItem[]>(() =>
[
{ 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<DistributionItem[]>(() => [
{ 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 (
<Card
padding="md"
class="mb-2 sm:mb-4"
role="region"
aria-label="Estate overview"
data-testid="workloads-estate-overview"
>
<div class="mb-3 flex items-center justify-between gap-3">
<h2 class="text-xs font-semibold text-base-content">Estate overview</h2>
<span class="text-[10px] font-medium uppercase tracking-wide text-muted">
Current inventory
</span>
</div>
<div class="grid gap-4 lg:grid-cols-[minmax(18rem,0.8fr)_minmax(28rem,1.2fr)] lg:items-center lg:gap-6">
<div
class={`grid grid-cols-2 divide-x divide-border-subtle ${
props.topology ? 'sm:grid-cols-4' : 'sm:grid-cols-3'
}`}
>
<For each={primaryStats()}>
{(item, index) => (
<div class={index() === 0 ? 'pr-3' : 'px-3'}>
<div class="text-xl font-semibold leading-none tabular-nums text-base-content sm:text-2xl">
{formatCount(item.value)}
</div>
<div class="mt-1 text-[11px] font-medium text-muted">{item.label}</div>
</div>
)}
</For>
</div>
<div class="grid gap-4 sm:grid-cols-2 sm:gap-5">
<Distribution label="Workload mix" items={typeItems()} total={props.stats.total} />
<Distribution label="Health" items={statusItems()} total={props.stats.total} />
</div>
</div>
</Card>
);
};
export default WorkloadsInventorySummary;
@@ -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(() => (
<WorkloadsFilter
{...makeProps({
@@ -182,21 +182,18 @@ describe('WorkloadsFilter', () => {
/>
));
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(() => (
<WorkloadsFilter
{...makeProps({
inventoryStats: () => ({
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(() => (
@@ -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(() => (
<WorkloadsInventorySummary
containerLabel="LXCs"
topology={{ nodes: 32, clusters: 5, standalone: 2 }}
stats={{
total: 718,
running: 635,
degraded: 36,
stopped: 47,
vms: 283,
containers: 300,
appContainers: 15,
pods: 120,
}}
/>
));
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(() => (
<WorkloadsInventorySummary
stats={{
total: 10,
running: 10,
degraded: 0,
stopped: 0,
vms: 10,
containers: 0,
appContainers: 0,
pods: 0,
}}
/>
));
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');
});
});
@@ -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;
@@ -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}
@@ -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';
@@ -68,6 +68,21 @@ describe('FilterButtonGroup', () => {
expect(onChange).toHaveBeenCalledWith('dark');
});
it('renders an optional count directly beside its label', () => {
render(() => (
<FilterButtonGroup
options={[{ value: 'vm', label: 'VMs', count: 1234 }]}
value="vm"
onChange={() => 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();
@@ -52,7 +52,14 @@ export function FilterButtonGroup<T extends string | number>(props: FilterButton
return (
<button
type="button"
aria-label={option.ariaLabel ?? (option.visualLabel ? undefined : option.label)}
aria-label={
option.ariaLabel ??
(option.visualLabel
? undefined
: option.count === undefined
? option.label
: `${option.label}, ${option.count.toLocaleString()}`)
}
title={option.title}
onClick={() => filterButtonGroup.handleOptionClick(option)}
class={getFilterButtonGroupButtonClass(
@@ -83,6 +90,14 @@ export function FilterButtonGroup<T extends string | number>(props: FilterButton
{renderedLabel()}
</span>
</Show>
<Show when={option.count !== undefined}>
<span
class="inline-flex items-center self-center text-[11px] font-semibold leading-none tabular-nums text-base-content/70"
aria-hidden="true"
>
{option.count!.toLocaleString()}
</span>
</Show>
</button>
);
}}
@@ -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(/<FilterButtonGroup/g) ?? []).toHaveLength(4);
@@ -10,6 +10,7 @@ export interface FilterOption<T extends string | number> {
visualLabel?: JSX.Element;
icon?: (props: { class?: string }) => JSX.Element;
tone?: FilterButtonGroupOptionTone;
count?: number;
disabled?: boolean;
}
@@ -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<boolean>;
}> = (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;
@@ -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 (
<div data-testid="proxmox-page" class="pulse-wide-data-surface space-y-3">
@@ -212,6 +221,8 @@ export function ProxmoxPageSurface() {
setMetricHistoryRange={setMetricHistoryRange}
memoryDisplayBasis={memoryDisplayBasis}
setMemoryDisplayBasis={setMemoryDisplayBasis}
inventoryCountsVisible={inventoryCountsVisible}
setInventoryCountsVisible={setInventoryCountsVisible}
/>
</div>
</Show>
@@ -271,6 +282,8 @@ interface ProxmoxOverviewProps {
setMetricHistoryRange: (value: WorkloadTableMetricHistoryRange) => void;
memoryDisplayBasis: Accessor<WorkloadsMemoryDisplayBasis>;
setMemoryDisplayBasis: (value: WorkloadsMemoryDisplayBasis) => void;
inventoryCountsVisible: Accessor<boolean>;
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}
/>
<WorkloadsSurface
state={workloadsState}
@@ -100,6 +100,23 @@ afterEach(() => {
});
describe('ProxmoxNodesTable', () => {
it('hides node and topology totals through the page-owned inventory preference', () => {
render(() => (
<ProxmoxNodesTable
nodes={[makeNodeResource()]}
guests={[]}
topology={{ nodes: 1, clusters: 1, standalone: 0 }}
inventoryCountsVisible={() => false}
emptyIcon={<span />}
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(() => (
<ProxmoxNodesTable
@@ -10,10 +10,6 @@ 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 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<Resource> & Pick<Resource, 'id' | 'type'>): Resource =>
({
@@ -76,10 +72,7 @@ vi.mock('@/components/Storage/Storage', () => ({
}));
vi.mock('@/components/Workloads/WorkloadsFilter', () => ({
WorkloadsFilter: (props: { inventoryTopology?: () => unknown }) => {
mockWorkloadsFilterProps(props);
return <div data-testid="workloads-filter" />;
},
WorkloadsFilter: () => <div data-testid="workloads-filter" />,
}));
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', () => {