mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
fix(frontend): place estate totals beside labels
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-26
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user