fix(frontend): fold estate totals into platform controls

This commit is contained in:
rcourtman
2026-08-19 17:53:37 +01:00
parent 465ef1fda9
commit ba4a6c46d9
30 changed files with 397 additions and 833 deletions
@@ -217,8 +217,7 @@ puts the same machine on two surfaces that do not share an identity.
131a. `frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx`
131b. `frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx`
131c. `frontend-modern/src/features/platformPage/PlatformOutdatedSensorSetupNotice.tsx`
131d. `frontend-modern/src/features/platformPage/PlatformEstateOverview.tsx`
131e. `frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts`
131d. `frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts`
132. `frontend-modern/src/utils/platformSupportManifest.generated.ts`
133. `frontend-modern/src/utils/platformSupportManifest.ts`
134. `frontend-modern/src/utils/sourcePlatformOptions.ts`
@@ -2058,20 +2057,21 @@ default` instead of fusing provider and badge text such as
grouped-by-host containers view) sort within groups while grouping itself
stays orthogonal to sort state.
18. Keep estate orientation on one canonical platform primitive. Proxmox,
Docker / Podman, Kubernetes, TrueNAS, VMware vSphere, and Standalone
Machines overview surfaces must compose
`PlatformEstateOverview.tsx` and pass their already-loaded, platform-scoped
canonical resource set into `platformEstateOverviewModel.ts`. The model
owns metric definitions, actionable-status classification, spotlight
priority, bounded spotlight count, and platform workflow destinations; a
platform page may not recreate those rules or retain a provider-only
summary. The renderer owns the compact responsive metric frame and
spotlight presentation. Its single `platformEstateOverviewVisible`
preference is global and browser-persisted, so an operator's Hide or Show
choice follows them between platform pages and reloads without initiating
another resource fetch. Table-local filters and counts remain separate
consumer concerns and must not be promoted into a competing overview.
18. Keep estate orientation inside the canonical controls operators already
scan. Large-estate workload totals must flow from the unfiltered shared
workload inventory into `WorkloadsFilter.tsx`, through the canonical
`FilterBar` / `FilterButtonGroup` option-count contract, so type and status
buttons expose their totals without another fetch or a competing metric
panel. Provider topology belongs in the existing table header; Proxmox
derives cluster and standalone-node context through
`platformEstateOverviewModel.ts` and supplies it to `ProxmoxNodesTable.tsx`
rather than creating a page-level summary. The existing
`platformEstateOverviewVisible` preference now governs these inline totals
from the shared View menu, preserving the global browser-persisted choice
across platform workload surfaces. Docker / Podman, Kubernetes, TrueNAS,
VMware vSphere, and Standalone Machines must continue using their shared
`PlatformTableToolbar` counters and table headers; none may add a parallel
estate card grid or provider-only spotlight surface.
## Forbidden Paths
@@ -2098,8 +2098,8 @@ default` instead of fusing provider and badge text such as
Retired aliases must fail route eligibility instead of being kept as
compatibility redirects.
8. Platform pages implementing local estate metric cards, operational
spotlight classifiers, or separate visibility preferences instead of
composing the canonical platform estate overview.
spotlight panels, or separate visibility preferences instead of projecting
totals into the canonical filter and table-header controls.
## Completion Obligations
@@ -715,6 +715,10 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
reads the unfiltered guest set so narrowing the table by search or status
never removes the column.
18. Extend workload filter active-count, reset semantics, and mobile toolbar state through `frontend-modern/src/components/Workloads/workloadsFilterModel.ts` (defaults, `countActiveWorkloadsFilters`, `hasActiveWorkloadsFilters`) rather than rebuilding filter-local state inside `frontend-modern/src/components/Workloads/WorkloadsFilter.tsx`. Workloads filter presentation now composes the shared `FilterBar` (`frontend-modern/src/components/shared/FilterBar/FilterBar.tsx`) with a per-page `FilterDef[]` catalog rather than the legacy `PageControls` structured control deck. High-frequency Type and Status filters stay in that catalog but render as inline compact segmented controls (`inline: true`), while longer or dynamic scope filters continue through the "+ Filter" menu and chip popovers. The Add filter control inherits FilterBar's compact accessible-only label by default instead of paying for a page-local labelled-field shell. Durable presentation controls pass only their panel content through `FilterBar.viewOptions`; the shared FilterBar owns the single View trigger and popover. Contextual actions use `leadingControls`, while frequently changed analytical orientation such as the active trend range uses `trailingControls`.
Large-estate option counts must reuse one memoized stats pass over the
unfiltered workload snapshot from `useWorkloadsState`; type and status
options read that bounded projection and must not rescan resources during
render or issue a separate summary request.
Workload filter option semantics stay workload-owned, but FilterBar chip
presentation is frontend-primitives-owned: status and runtime leading dots
must use `filterChipStatusDot` rather than workload-local span factories.
@@ -4727,7 +4727,6 @@
"frontend-modern/src/components/Toast/Toast.tsx",
"frontend-modern/src/components/Workloads/nodeDrawerModel.ts",
"frontend-modern/src/features/docker/dockerHostDrawerModel.ts",
"frontend-modern/src/features/platformPage/PlatformEstateOverview.tsx",
"frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts",
"frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx",
"frontend-modern/src/features/platformPage/PlatformOutdatedSensorSetupNotice.tsx",
@@ -5242,7 +5241,6 @@
"match_files": [
"frontend-modern/scripts/shared-template-audit.mjs",
"frontend-modern/scripts/shared-template-registry.json",
"frontend-modern/src/features/platformPage/PlatformEstateOverview.tsx",
"frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts",
"frontend-modern/src/features/platformPage/PlatformOutdatedAgentNotice.tsx",
"frontend-modern/src/features/platformPage/PlatformOutdatedSensorSetupNotice.tsx",
@@ -5253,7 +5251,7 @@
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts",
"frontend-modern/src/features/platformPage/__tests__/PlatformEstateOverview.test.tsx",
"frontend-modern/src/components/Workloads/__tests__/WorkloadsFilter.test.tsx",
"frontend-modern/src/features/platformPage/__tests__/platformEstateOverviewModel.test.ts",
"frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts"
]
@@ -7218,6 +7216,7 @@
"frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx",
"frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx",
"frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx",
"frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx",
"tests/integration/tests/90-operational-trust-protection-posture.spec.ts"
]
},
@@ -403,14 +403,14 @@ Patrol coverage, schedule, finding, and approval state belongs on Patrol-owned
surfaces or explicit Patrol affordances; Proxmox overview must not treat it as
backup coverage, restore readiness, PBS verification/protection proof, or a
replacement for the Proxmox Backups tab and workload Backup column.
The Proxmox estate-at-a-glance metrics and operational spotlights are likewise
an adjacent frontend-primitives and unified-resource projection, not backup or
recovery evidence. `ProxmoxPageSurface` must compose the canonical shared
platform estate overview with its already-loaded Proxmox resource set rather
than deriving a Proxmox-only summary. A spotlight may route into the owned
Storage, Backups, Ceph, or Mail workflows, but it must not imply protection,
verification, or restore readiness beyond the evidence held by those
workflow-owned surfaces.
The Proxmox inline estate totals are likewise an adjacent frontend-primitives
and unified-resource projection, not backup or recovery evidence.
`ProxmoxPageSurface` must flow the already-loaded workload inventory into the
shared workload filter counts and derive cluster/standalone topology through
`platformEstateOverviewModel.ts` for the existing Nodes header rather than
creating a Proxmox-only metric or spotlight panel. Those counts must not imply
protection, verification, or restore readiness beyond the evidence held by the
workflow-owned Storage, Backups, Ceph, and Mail surfaces.
Proxmox backup inventory loading and load-failure chrome is likewise a
frontend-primitives dependency. `ProxmoxBackupsTable` owns the backup API
queries, recovery model, filters, coverage split, and backup-specific error
@@ -280,15 +280,13 @@ about the same disk cannot diverge.
## Shared Boundaries
Platform estate orientation consumes the same canonical resource projection as
the platform's tables. Docker / Podman, Kubernetes, TrueNAS, VMware vSphere,
Standalone Machines, and the storage/recovery-owned Proxmox surface pass their
already-loaded scoped resource set into the frontend-primitives-owned
`platformEstateOverviewModel.ts`; pages must not issue a summary fetch, infer
membership from a second platform heuristic, or locally reinterpret resource
status, incidents, alerts, or storage pressure. Unified resources owns the
identity, type, platform membership, status, and evidence fields supplied to
that shared model. Frontend primitives owns how those fields become the four
orientation metrics and bounded operational spotlights.
the platform's tables. Pages must not issue a second summary fetch or create a
parallel metric-card projection. Unified resources owns identity, type,
platform membership, and status; frontend primitives owns how the unfiltered
workload inventory becomes option counts in the shared filter controls. The
storage/recovery-owned Proxmox surface additionally passes its already-loaded
resource set through `platformEstateOverviewModel.ts` for cluster and
standalone-node topology in the existing Nodes table header.
Kubernetes workload presentation is API-native, not generic inventory. Pods,
Deployments, controllers, and autoscalers render under the `/kubernetes/workloads`
+38 -31
View File
@@ -1,29 +1,43 @@
{
"version": 1,
"base_sha": "2da68787550ca774b0e202591ab542d8e35a688e",
"verified_at": "2026-08-19T16:23:24Z",
"base_sha": "85ddd9f7f98389c05165527f62696c6f344074ee",
"verified_at": "2026-08-19T16:51:42Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/WorkloadsFilter.tsx",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts",
"frontend-modern/src/components/Workloads/workloadsFilterModel.ts",
"frontend-modern/src/components/shared/FilterBar/FilterBar.tsx",
"frontend-modern/src/components/shared/FilterButtonGroup.tsx",
"frontend-modern/src/components/shared/filterButtonGroupModel.ts",
"frontend-modern/src/features/docker/DockerPageSurface.tsx",
"frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx",
"frontend-modern/src/features/platformPage/PlatformAttentionSummary.tsx",
"frontend-modern/src/features/platformPage/PlatformEstateOverview.tsx",
"frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx",
"frontend-modern/src/features/standalone/StandalonePageSurface.tsx",
"frontend-modern/src/features/truenas/TrueNASPageSurface.tsx",
"frontend-modern/src/features/vmware/VmwarePageSurface.tsx"
],
"content_sha256": {
"frontend-modern/src/features/docker/DockerPageSurface.tsx": "5c8eb2147a5442ead798e9e1a3caeaad17413ef8d95f6742276c174deeeaa62d",
"frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx": "3a66c9d684ec6265dda01232228521d77ddc2ff8e35b6958629486c8c99728a9",
"frontend-modern/src/features/platformPage/PlatformAttentionSummary.tsx": "deleted",
"frontend-modern/src/features/platformPage/PlatformEstateOverview.tsx": "ecf80c58f89b646bc3bde4328634c0718bd5a8a47bf9cd8069d69f6c62ce2ded",
"frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts": "eb11afc71a9a1a593c5c7f69890b0893f230b824fa3380f2d51b58338327606a",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "c18e370e5c4067cefe7fc46e0ed15d8660b79f04c2dd6b9ecc552d2714007263",
"frontend-modern/src/features/standalone/StandalonePageSurface.tsx": "38f2a514a7c8641156acff7fba1cf366c893922532e82a23105648748d44ab3d",
"frontend-modern/src/features/truenas/TrueNASPageSurface.tsx": "c842d9c12e4cc6eaffc79b777cae9d0ffae5d68ac50b77985fbeba77e69d3e2d",
"frontend-modern/src/features/vmware/VmwarePageSurface.tsx": "d46924006f3b394b89edab2437248402c62ccebaa22266de03358dd69dac6d4a"
"frontend-modern/src/components/Workloads/WorkloadsFilter.tsx": "643e50ea4aa6ed89589bb77ce48fc6344ba4a5ed6b2939bba6b4d8e047b78bfd",
"frontend-modern/src/components/Workloads/useWorkloadsState.ts": "10f5b232ce7db4cf52d8df0f2d33c29731afcf1544f770aab1183f372763495d",
"frontend-modern/src/components/Workloads/workloadsFilterModel.ts": "c68b4193f9b926a468b018e06024fb3212e19b99e0824845076f8ed71bf9c0eb",
"frontend-modern/src/components/shared/FilterBar/FilterBar.tsx": "ce8c55f14d1358b2ed92e1970587cb1e671a97ade499d51ec635c8f78d256eda",
"frontend-modern/src/components/shared/FilterButtonGroup.tsx": "f573262dda64212a061626b3c7988f3ce2788f18979d523c1f737f6a7efd7a98",
"frontend-modern/src/components/shared/filterButtonGroupModel.ts": "06576097404a56f66d6ae0ef23fc913dbff11ceb5833140d00df58ad9711fe41",
"frontend-modern/src/features/docker/DockerPageSurface.tsx": "b1f8425708dad88bad3fe58764456250c32e5080dad3c6d04afbe2cba23e4762",
"frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx": "bc66b5a08453771c1f81d6b34c02a0b2e117f1c20e8cc250502a8c9ba846f3c4",
"frontend-modern/src/features/platformPage/PlatformEstateOverview.tsx": "deleted",
"frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts": "025fe43f28810fbafc7ce439b4e47e40d109559ecfc064b14e11de1bae4505db",
"frontend-modern/src/features/platformPage/sharedPlatformPage.tsx": "cd959981f59753727847621228a339c099034fe7b7a61fa2bb32bb6704dd64b3",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "5897b06f6e728f06d00d731b3675a33688681977bb3d51c76b0a92e21cf4c4bd",
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "199cac37585598cc8533efd8010bcfa85915f4f7cc29fa5b967198460f70b43d",
"frontend-modern/src/features/standalone/StandalonePageSurface.tsx": "70f1f22cd61ed8822aa91cb3bb513fffd794316c6536e00d8bd0403fa9aecbdd",
"frontend-modern/src/features/truenas/TrueNASPageSurface.tsx": "c330710f229e6fa04edb12e077a34ea7fafb4edac2adda6c878a77984cde841e",
"frontend-modern/src/features/vmware/VmwarePageSurface.tsx": "efd0a33a0c9fa649993811f3dee89666a0ae3df32af5225c69fcc0978811ffb8"
},
"routes": [
"/proxmox/overview",
@@ -31,30 +45,23 @@
"/kubernetes/overview",
"/truenas/overview",
"/vmware/overview",
"/standalone/machines"
"/machines"
],
"viewports": [
{
"width": 1280,
"height": 720
},
{
"width": 390,
"height": 844
}
{ "width": 1440, "height": 900 },
{ "width": 390, "height": 844 }
],
"states": [
"Large Proxmox estate overview showing 32 nodes, 578 workloads, five clusters plus two standalone nodes, 104 attention resources, and three bounded operational spotlights",
"Canonical visible estate overview on Docker, Kubernetes, TrueNAS, VMware vSphere, and Standalone Machines with exactly four platform-specific metrics on each surface",
"Globally hidden estate overview on Docker after choosing Hide on Proxmox and again after a full reload",
"Restored visible overview after choosing Show, followed by navigation across every platform surface",
"Proxmox overview at 390 by 844 with four metrics, three stacked spotlights, and document scroll width equal to viewport width"
"Large Proxmox workload totals shown inline in the existing Type and Status controls",
"Proxmox Nodes header showing 32 nodes, five clusters, and two standalone nodes",
"Expanded phone filter rail with all inline totals visible without horizontal page overflow",
"Inline totals hidden through View, still hidden after a full reload, then restored",
"Docker, Kubernetes, TrueNAS, VMware vSphere, and Standalone overview routes without the retired estate panel"
],
"interactions": [
"Chose Hide on Proxmox, navigated to Docker, and confirmed the shared Show estate overview control replaced the summary",
"Reloaded Docker and confirmed the hidden preference persisted before first paint",
"Chose Show on Docker and navigated through Docker, Kubernetes, TrueNAS, VMware vSphere, Standalone Machines, and Proxmox to confirm the same canonical component marker",
"Inspected the rendered desktop Proxmox estate summary above the existing workload filters and dense inventory table",
"Applied a 390 by 844 viewport, verified no horizontal page overflow, captured the stacked responsive state, and reset the viewport"
"Expanded the phone Filters control and checked the complete type and status count rows",
"Opened View, selected Hide for Inventory totals, and confirmed count badges disappeared",
"Reloaded Proxmox and confirmed the hidden choice persisted, then restored Show",
"Navigated all six platform overview routes and confirmed the shared panel was absent"
]
}
@@ -3195,24 +3195,20 @@
]
},
{
"id": "platform-estate-overview",
"id": "platform-estate-inline-counts",
"category": "platform-overview",
"summary": "Every primary platform overview composes one shared estate metric and operational spotlight surface with one global persisted visibility preference.",
"summary": "Large-estate totals annotate the existing canonical type/status controls and table headers instead of creating a competing overview panel; the shared View preference owns their visibility.",
"canonical": {
"path": "src/features/platformPage/PlatformEstateOverview.tsx",
"export": "PlatformEstateOverview"
"path": "src/components/Workloads/WorkloadsFilter.tsx",
"export": "WorkloadsFilter"
},
"requiredConsumers": [
{ "path": "src/features/docker/DockerPageSurface.tsx" },
{ "path": "src/features/kubernetes/KubernetesPageSurface.tsx" },
{ "path": "src/features/proxmox/ProxmoxPageSurface.tsx" },
{ "path": "src/features/standalone/StandalonePageSurface.tsx" },
{ "path": "src/features/truenas/TrueNASPageSurface.tsx" },
{ "path": "src/features/vmware/VmwarePageSurface.tsx" }
],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/features/platformPage/__tests__/PlatformEstateOverview.test.tsx",
"src/components/Workloads/__tests__/WorkloadsFilter.test.tsx",
"src/features/platformPage/__tests__/platformEstateOverviewModel.test.ts",
"src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts",
"scripts/shared-template-audit.mjs"
@@ -17,6 +17,11 @@ import {
} from '@/components/shared/FilterToolbar';
import { GroupedTableModeSegmentedControl } from '@/components/shared/GroupedTableModeSegmentedControl';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import {
PLATFORM_ESTATE_COUNTS_STORAGE_KEY,
deserializePlatformEstateCountsVisibility,
} from '@/features/platformPage/platformEstateOverviewModel';
import { STORAGE_KEYS } from '@/utils/localStorage';
import {
normalizeSourcePlatformQueryValue,
@@ -44,6 +49,11 @@ import { WORKLOAD_STATUS_FILTER_OPTIONS, WORKLOAD_TYPE_OPTIONS } from './workloa
export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
const { isMobile } = useBreakpoint();
const [inventoryCountsVisible, setInventoryCountsVisible] = usePersistentSignal(
PLATFORM_ESTATE_COUNTS_STORAGE_KEY,
true,
{ deserialize: deserializePlatformEstateCountsVisibility },
);
const typeValue = () =>
isContainerWorkloadViewMode(props.viewMode()) ? 'container' : props.viewMode();
@@ -57,6 +67,16 @@ export const WorkloadsFilter: Component<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(
@@ -76,8 +96,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,
@@ -98,6 +129,7 @@ export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
: option.value === 'stopped'
? 'danger'
: undefined,
count: workloadStatusCount(option.value),
}));
const runtimeChipLabel = (value: string): string => {
@@ -329,6 +361,23 @@ export const WorkloadsFilter: Component<WorkloadsFilterProps> = (props) => {
</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">
@@ -104,6 +104,7 @@ describe('WorkloadsFilter', () => {
beforeEach(() => {
vi.clearAllMocks();
isMobileMock.mockReturnValue(false);
window.localStorage.clear();
});
afterEach(() => {
@@ -162,6 +163,67 @@ describe('WorkloadsFilter', () => {
expect(dialog.getByRole('button', { name: 'List' })).toBeInTheDocument();
});
it('folds large-estate totals into the existing type and status controls', () => {
render(() => (
<WorkloadsFilter
{...makeProps({
forcedPlatform: 'proxmox-all',
inventoryStats: () => ({
total: 578,
running: 500,
degraded: 12,
stopped: 66,
vms: 253,
containers: 325,
appContainers: 0,
pods: 0,
}),
})}
/>
));
expect(
within(inlineFilterGroup('Type')).getByRole('button', { name: 'All, 578' }),
).toHaveTextContent('578');
expect(
within(inlineFilterGroup('Type')).getByRole('button', { name: 'VMs, 253' }),
).toHaveTextContent('253');
expect(
within(inlineFilterGroup('Type')).getByRole('button', { name: 'LXCs, 325' }),
).toHaveTextContent('325');
expect(
within(inlineFilterGroup('Status')).getByRole('button', { name: 'Degraded, 12' }),
).toHaveTextContent('12');
});
it('keeps inventory totals optional through the existing View menu', () => {
render(() => (
<WorkloadsFilter
{...makeProps({
inventoryStats: () => ({
total: 578,
running: 500,
degraded: 12,
stopped: 66,
vms: 253,
containers: 325,
appContainers: 0,
pods: 0,
}),
})}
/>
));
const dialog = within(openViewPreferences());
const visibility = dialog.getByRole('group', { name: 'Inventory totals visibility' });
fireEvent.click(within(visibility).getByRole('button', { name: 'Hide' }));
expect(
within(inlineFilterGroup('Type')).getByRole('button', { name: 'All' }),
).not.toHaveTextContent('578');
expect(window.localStorage.getItem('platformEstateOverviewVisible')).toBe('false');
});
it('offers a guest/host memory percentage basis when the owning page enables it', () => {
const setMemoryDisplayBasis = vi.fn();
render(() => (
@@ -27,6 +27,7 @@ import { getCanonicalWorkloadId, resolveWorkloadType } from '@/utils/workloads';
import { nodeFromResource } from '@/utils/resourceStateAdapters';
import {
buildWorkloadSummaryGroupScopeMap,
computeWorkloadStats,
createWorkloadSortComparator,
filterWorkloads,
type FilterWorkloadsParams,
@@ -512,11 +513,13 @@ export function useWorkloadsState(props: WorkloadsSurfaceProps) {
tableBodyRef,
groupLabelBadges,
});
const inventoryStats = createMemo(() => computeWorkloadStats(allGuests()));
return {
activeAlerts,
alertsEnabled,
allGuests,
inventoryStats,
activeSummaryWorkloadGroupScope,
activeSummaryWorkloadId,
clearPinnedSummaryScope,
@@ -15,6 +15,17 @@ export interface WorkloadsStatusOption {
label: string;
}
export interface WorkloadsInventoryStats {
total: number;
running: number;
degraded: number;
stopped: number;
vms: number;
containers: number;
appContainers: number;
pods: number;
}
export interface WorkloadsFilterSelectOption {
value: string;
label: string;
@@ -45,6 +56,7 @@ export interface WorkloadsFilterProps {
searchPlaceholder?: string;
searchEmptyMessage?: string;
statusOptions?: readonly WorkloadsStatusOption[];
inventoryStats?: () => WorkloadsInventoryStats;
columnVisibility?: {
availableColumns: ColumnDef[];
isColumnHidden: (id: string) => boolean;
@@ -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}
@@ -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="min-w-4 rounded bg-base/70 px-1 text-center text-[10px] font-semibold tabular-nums text-muted"
aria-hidden="true"
>
{option.count!.toLocaleString()}
</span>
</Show>
</button>
);
}}
@@ -412,6 +412,9 @@ describe('shared primitive guardrails', () => {
}>;
};
const registeredRule = registry.rules?.find((rule) => rule.id === 'filter-button-group-shell');
const estateCountsRule = registry.rules?.find(
(rule) => rule.id === 'platform-estate-inline-counts',
);
const registeredGuard = registry.patternGuards?.find(
(guard) => guard.id === 'filter-button-group-local-segmented-control-styles',
);
@@ -421,6 +424,14 @@ describe('shared primitive guardrails', () => {
expect(registeredRule?.canonical?.path).toBe('src/components/shared/FilterButtonGroup.tsx');
expect(registeredRule?.canonical?.export).toBe('FilterButtonGroup');
expect(estateCountsRule?.canonical).toEqual({
path: 'src/components/Workloads/WorkloadsFilter.tsx',
export: 'WorkloadsFilter',
});
expect(estateCountsRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([
'src/features/proxmox/ProxmoxPageSurface.tsx',
'src/features/vmware/VmwarePageSurface.tsx',
]);
expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([
'src/components/Settings/GeneralSettingsPanel.tsx',
'src/components/Settings/ResourcePicker.tsx',
@@ -488,6 +499,9 @@ describe('shared primitive guardrails', () => {
expect(filterButtonGroupModelSource).toContain('getFilterButtonGroupButtonClass');
expect(filterButtonGroupModelSource).toContain('getFilterButtonGroupCompactLabel');
expect(filterButtonGroupModelSource).toContain("option.label.startsWith('All ')");
expect(filterButtonGroupModelSource).toContain('count?: number');
expect(workloadsFilterSource).toContain('inventoryStats');
expect(workloadsFilterSource).toContain('PLATFORM_ESTATE_COUNTS_STORAGE_KEY');
expect(generalSettingsPanelSource).toContain('FilterButtonGroup');
expect(generalSettingsPanelSource.match(/<FilterButtonGroup/g) ?? []).toHaveLength(4);
expect(generalSettingsPanelSource).toContain('getLocalePreferenceOptions');
@@ -5282,7 +5296,8 @@ describe('shared primitive guardrails', () => {
expect(source).not.toContain('<MetadataBadge');
}
expect(kubernetesPageSurfaceSource).toContain('PlatformAttentionSummary');
expect(kubernetesPageSurfaceSource).not.toContain('PlatformAttentionSummary');
expect(kubernetesPageSurfaceSource).toContain('attentionCount={workloadAttention()}');
});
it('keeps platform table empty states on the shared shell template', () => {
@@ -8325,7 +8340,8 @@ describe('shared primitive guardrails', () => {
expect(filterBarSource).toContain('<ViewOptionsMenu>{props.viewOptions}</ViewOptionsMenu>');
expect(filterBarSource).toContain('aria-label="Filter actions"');
expect(sharedPlatformPageSource).toContain('viewOptions?: JSX.Element;');
expect(sharedPlatformPageSource).toContain('viewOptions={props.viewOptions}');
expect(sharedPlatformPageSource).toContain('{props.viewOptions}');
expect(sharedPlatformPageSource).toContain('Inventory totals');
expect(sharedPlatformPageSource).toContain('showAddFilterLabel={false}');
expect(sharedPlatformPageSource).toContain('trailingControls={');
expect(sharedPlatformPageSource).not.toContain('ViewOptionsMenu');
@@ -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;
}
@@ -3,7 +3,6 @@ import { Show, createMemo, createSignal } from 'solid-js';
import BoxIcon from 'lucide-solid/icons/box';
import { ButtonLink } from '@/components/shared/Button';
import { getPlatformIcon } from '@/features/platformPage/platformIcon';
import { PlatformEstateOverview } from '@/features/platformPage/PlatformEstateOverview';
import { useUnifiedResources } from '@/hooks/useUnifiedResources';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
@@ -158,7 +157,6 @@ export function DockerPageSurface() {
/>
<Show when={activeTab() === 'overview'}>
<div class="space-y-4">
<PlatformEstateOverview platform="docker" resources={model().resources} />
<DockerOverview
hosts={model().hosts}
hostSourceCount={model().hosts.length}
@@ -4,7 +4,6 @@ import { ButtonLink } from '@/components/shared/Button';
import { buildInfrastructureAgentUpdatesPath } from '@/components/Settings/infrastructureWorkspaceModel';
import type { FilterDef } from '@/components/shared/FilterBar';
import { getPlatformIcon } from '@/features/platformPage/platformIcon';
import { PlatformEstateOverview } from '@/features/platformPage/PlatformEstateOverview';
import { PlatformOutdatedAgentNotice } from '@/features/platformPage/PlatformOutdatedAgentNotice';
import {
collectOutdatedAgentHosts,
@@ -130,7 +129,6 @@ export function KubernetesPageSurface() {
/>
<Show when={activeTab() === 'overview'}>
<div class="space-y-4">
<PlatformEstateOverview platform="kubernetes" resources={model().resources} />
<KubernetesOverview model={model} />
</div>
</Show>
@@ -1,148 +0,0 @@
import { A } from '@solidjs/router';
import ChevronRightIcon from 'lucide-solid/icons/chevron-right';
import EyeIcon from 'lucide-solid/icons/eye';
import EyeOffIcon from 'lucide-solid/icons/eye-off';
import ServerOffIcon from 'lucide-solid/icons/server-off';
import TriangleAlertIcon from 'lucide-solid/icons/triangle-alert';
import { For, Show, createMemo } from 'solid-js';
import { Button } from '@/components/shared/Button';
import { TableCard } from '@/components/shared/TableCard';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import type { Resource } from '@/types/resource';
import {
PLATFORM_ESTATE_OVERVIEW_STORAGE_KEY,
buildPlatformEstateOverviewModel,
deserializePlatformEstateOverviewVisibility,
formatPlatformEstateMetricValue,
type PlatformEstateOverviewPlatform,
} from './platformEstateOverviewModel';
export function PlatformEstateOverview(props: {
platform: PlatformEstateOverviewPlatform;
resources: readonly Resource[];
}) {
const [visible, setVisible] = usePersistentSignal(PLATFORM_ESTATE_OVERVIEW_STORAGE_KEY, true, {
deserialize: deserializePlatformEstateOverviewVisibility,
});
const model = createMemo(() => buildPlatformEstateOverviewModel(props.platform, props.resources));
return (
<Show
when={visible()}
fallback={
<div class="flex justify-end" data-platform-estate-overview-hidden>
<Button
type="button"
variant="ghost"
size="sm"
class="gap-2"
onClick={() => setVisible(true)}
>
<EyeIcon class="h-3.5 w-3.5" aria-hidden="true" />
Show estate overview
</Button>
</div>
}
>
<section
aria-labelledby={`${props.platform}-estate-overview-title`}
data-platform-estate-overview={props.platform}
class="space-y-2"
>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<h2
id={`${props.platform}-estate-overview-title`}
class="text-xs font-semibold uppercase tracking-wider text-muted"
>
Estate at a glance
</h2>
<p class="mt-0.5 text-xs text-muted">
Live inventory and the most important operational signals.
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
class="shrink-0 gap-2"
onClick={() => setVisible(false)}
>
<EyeOffIcon class="h-3.5 w-3.5" aria-hidden="true" />
Hide
</Button>
</div>
<TableCard class="overflow-hidden">
<div class="grid grid-cols-2 lg:grid-cols-4" data-platform-estate-metrics>
<For each={model().metrics}>
{(item, index) => (
<div
class={`min-w-0 px-3 py-3 ${index() % 2 === 0 ? 'border-r border-border' : ''} ${
index() < 2 ? 'border-b border-border lg:border-b-0' : ''
} ${index() < 3 ? 'lg:border-r lg:border-border' : 'lg:border-r-0'}`}
data-platform-estate-metric={item.id}
>
<div class="text-lg font-semibold tabular-nums text-base-content">
{formatPlatformEstateMetricValue(item.value)}
</div>
<div class="mt-0.5 truncate text-[11px] text-muted" title={item.label}>
{item.label}
</div>
</div>
)}
</For>
</div>
</TableCard>
<Show when={model().spotlights.length > 0}>
<div class="pt-1">
<div class="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted">
Operational spotlights
</div>
<div class="grid gap-2 lg:grid-cols-3" data-platform-estate-spotlights>
<For each={model().spotlights}>
{(spotlight) => (
<A
href={spotlight.href}
class="group flex min-w-0 items-center gap-2 rounded-md border border-border bg-surface px-3 py-2 text-left transition-colors hover:border-blue-400 hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
data-platform-estate-spotlight={spotlight.resourceId}
>
<span
class={`flex h-7 w-7 shrink-0 items-center justify-center rounded-md ${
spotlight.tone === 'danger'
? 'bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300'
: 'bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300'
}`}
>
<Show
when={spotlight.tone === 'danger'}
fallback={<TriangleAlertIcon class="h-4 w-4" aria-hidden="true" />}
>
<ServerOffIcon class="h-4 w-4" aria-hidden="true" />
</Show>
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-xs font-medium text-base-content">
{spotlight.label}
</span>
<span class="mt-0.5 block truncate text-[11px] text-muted">
{spotlight.meta}
</span>
</span>
<ChevronRightIcon
class="h-4 w-4 shrink-0 text-muted transition-transform group-hover:translate-x-0.5"
aria-hidden="true"
/>
</A>
)}
</For>
</div>
</div>
</Show>
</section>
</Show>
);
}
export default PlatformEstateOverview;
@@ -1,88 +0,0 @@
import { Route, Router } from '@solidjs/router';
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource';
import { PlatformEstateOverview } from '../PlatformEstateOverview';
import { PLATFORM_ESTATE_OVERVIEW_STORAGE_KEY } from '../platformEstateOverviewModel';
const resources = [
{
id: 'pve-a',
type: 'agent',
name: 'pve-a',
displayName: 'pve-a',
platformId: 'proxmox',
platformType: 'proxmox-pve',
sourceType: 'api',
status: 'offline',
lastSeen: 1_700_000_000_000,
proxmox: { clusterName: 'production' },
},
{
id: 'vm-101',
type: 'vm',
name: 'database',
displayName: 'Database',
platformId: 'proxmox',
platformType: 'proxmox-pve',
sourceType: 'api',
status: 'running',
lastSeen: 1_700_000_000_000,
},
] as Resource[];
const renderOverview = () =>
render(() => (
<Router>
<Route
path="/"
component={() => <PlatformEstateOverview platform="proxmox" resources={resources} />}
/>
</Router>
));
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
cleanup();
window.localStorage.clear();
});
describe('PlatformEstateOverview', () => {
it('renders the shared metrics and operational spotlights by default', () => {
renderOverview();
expect(screen.getByRole('heading', { name: 'Estate at a glance' })).toBeInTheDocument();
expect(screen.getByText('Proxmox nodes')).toBeInTheDocument();
expect(screen.getByText('VMs and containers')).toBeInTheDocument();
expect(screen.getByText('pve-a offline')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /pve-a offline/i })).toHaveAttribute(
'href',
'/proxmox/overview',
);
});
it('persists the global hide and show choice', async () => {
renderOverview();
await fireEvent.click(screen.getByRole('button', { name: 'Hide' }));
expect(screen.queryByRole('heading', { name: 'Estate at a glance' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Show estate overview' })).toBeInTheDocument();
expect(window.localStorage.getItem(PLATFORM_ESTATE_OVERVIEW_STORAGE_KEY)).toBe('false');
await fireEvent.click(screen.getByRole('button', { name: 'Show estate overview' }));
expect(screen.getByRole('heading', { name: 'Estate at a glance' })).toBeInTheDocument();
expect(window.localStorage.getItem(PLATFORM_ESTATE_OVERVIEW_STORAGE_KEY)).toBe('true');
});
it('honors an existing hidden preference before first paint', () => {
window.localStorage.setItem(PLATFORM_ESTATE_OVERVIEW_STORAGE_KEY, 'false');
renderOverview();
expect(screen.queryByRole('heading', { name: 'Estate at a glance' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Show estate overview' })).toBeInTheDocument();
});
});
@@ -1,129 +1,56 @@
import { describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource';
import {
buildPlatformEstateMetrics,
buildPlatformOperationalSpotlights,
deserializePlatformEstateOverviewVisibility,
formatPlatformEstateMetricValue,
isPlatformEstateAttentionResource,
type PlatformEstateOverviewPlatform,
buildProxmoxEstateTopology,
deserializePlatformEstateCountsVisibility,
} from '../platformEstateOverviewModel';
const makeResource = (
id: string,
type: Resource['type'],
overrides: Partial<Resource> = {},
): Resource =>
const makeNode = (id: string, clusterName?: string): Resource =>
({
id,
type,
type: 'agent',
name: id,
displayName: id,
platformId: 'estate',
platformType: 'generic',
platformId: 'proxmox',
platformType: 'proxmox-pve',
sourceType: 'api',
status: 'online',
lastSeen: 1_700_000_000_000,
...overrides,
proxmox: clusterName ? { clusterName } : {},
}) as Resource;
describe('platformEstateOverviewModel', () => {
it('derives a large Proxmox estate from the canonical scoped resource set', () => {
it('derives node, cluster, and standalone totals from the canonical resource set', () => {
const resources = [
makeResource('pve-a', 'agent', { proxmox: { clusterName: 'production' } }),
makeResource('pve-b', 'agent', { proxmox: { clusterName: 'production' } }),
makeResource('pve-lab', 'agent'),
makeResource('vm-101', 'vm', { status: 'running' }),
makeResource('ct-201', 'system-container', { status: 'stopped' }),
makeResource('oci-301', 'oci-container', { status: 'warning' }),
makeNode('pve-a', 'production'),
makeNode('pve-b', 'production'),
makeNode('pve-c', 'lab'),
makeNode('pve-standalone'),
{ ...makeNode('vm-101'), type: 'vm' as const },
];
expect(buildPlatformEstateMetrics('proxmox', resources)).toEqual([
{ id: 'nodes', label: 'Proxmox nodes', value: 3 },
{ id: 'workloads', label: 'VMs and containers', value: 3 },
{ id: 'topology', label: 'Clusters + standalone', value: '1 + 1' },
{ id: 'attention', label: 'Need attention', value: 1 },
]);
});
it.each<PlatformEstateOverviewPlatform>([
'proxmox',
'docker',
'kubernetes',
'truenas',
'vmware',
'standalone',
])('keeps %s on the same four-metric overview contract', (platform) => {
const metrics = buildPlatformEstateMetrics(platform, []);
expect(metrics).toHaveLength(4);
expect(metrics.map((item) => item.id)).toContain('attention');
});
it('counts actionable evidence without treating an intentionally stopped workload as a fault', () => {
expect(
isPlatformEstateAttentionResource(makeResource('stopped', 'vm', { status: 'stopped' })),
).toBe(false);
expect(
isPlatformEstateAttentionResource(
makeResource('pressure', 'storage', { disk: { current: 91 } }),
),
).toBe(true);
expect(
isPlatformEstateAttentionResource(
makeResource('alerting', 'agent', {
alerts: [
{
id: 'alert-1',
type: 'temperature',
level: 'critical',
message: 'Temperature threshold exceeded',
value: 95,
threshold: 90,
startTime: 1_700_000_000_000,
},
],
}),
),
).toBe(true);
});
it('prioritizes danger, caps the list, and routes spotlights to platform detail areas', () => {
const resources = [
makeResource('warning-pod', 'pod', { status: 'offline' }),
makeResource('offline-node', 'k8s-node', { status: 'offline' }),
makeResource('degraded-claim', 'k8s-persistent-volume-claim', { status: 'degraded' }),
makeResource('warning-event', 'k8s-event', { status: 'warning' }),
];
const spotlights = buildPlatformOperationalSpotlights('kubernetes', resources, 3);
expect(spotlights).toHaveLength(3);
expect(spotlights[0]).toMatchObject({
resourceId: 'offline-node',
tone: 'danger',
href: '/kubernetes/nodes',
expect(buildProxmoxEstateTopology(resources)).toEqual({
clusters: 2,
nodes: 4,
standalone: 1,
});
expect(spotlights.map((item) => item.href)).toContain('/kubernetes/storage');
expect(spotlights.map((item) => item.href)).toContain('/kubernetes/workloads');
});
it('uses one tolerant global visibility preference', () => {
expect(deserializePlatformEstateOverviewVisibility('false')).toBe(false);
expect(deserializePlatformEstateOverviewVisibility('true')).toBe(true);
expect(deserializePlatformEstateOverviewVisibility('legacy-value')).toBe(true);
});
it('keeps a very large estate projection bounded to four metrics and three spotlights', () => {
it('keeps a very large topology projection linear and bounded', () => {
const resources = Array.from({ length: 10_000 }, (_, index) =>
makeResource(`node-${index}`, 'agent', {
status: index % 5 === 0 ? 'offline' : 'online',
proxmox: { clusterName: `cluster-${index % 40}` },
}),
makeNode(`node-${index}`, index % 25 === 0 ? undefined : `cluster-${index % 40}`),
);
expect(buildPlatformEstateMetrics('proxmox', resources)).toHaveLength(4);
expect(buildPlatformOperationalSpotlights('proxmox', resources)).toHaveLength(3);
expect(formatPlatformEstateMetricValue(resources.length).length).toBeGreaterThan(5);
expect(buildProxmoxEstateTopology(resources)).toEqual({
clusters: 40,
nodes: 10_000,
standalone: 400,
});
});
it('preserves the tolerant global visibility preference', () => {
expect(deserializePlatformEstateCountsVisibility('false')).toBe(false);
expect(deserializePlatformEstateCountsVisibility('true')).toBe(true);
expect(deserializePlatformEstateCountsVisibility('legacy-value')).toBe(true);
});
});
@@ -171,15 +171,17 @@ const proxmoxInlineDetailTableSources = [
];
describe('platform overview layout guardrails', () => {
it('keeps attention summaries canonical across every platform overview', () => {
it('keeps attention summaries canonical without a competing estate panel', () => {
for (const source of [truenasProtectionTableSource, vsphereAlertsTableSource]) {
expect(source).toContain('withPlatformAttentionCount');
expect(source).not.toContain('PlatformAttentionSummary');
}
for (const source of overviewSurfaceSources) {
expect(source).toContain('PlatformEstateOverview');
expect(source).not.toContain('PlatformEstateOverview');
expect(source).not.toContain('PlatformAttentionSummary');
}
expect(proxmoxPageSurfaceSource).toContain('inventoryStats={workloadsState.inventoryStats}');
expect(vmwarePageSurfaceSource).toContain('inventoryStats={workloadsState.inventoryStats}');
});
it('keeps platform inventory tables on the shared dense table styling contract', () => {
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
import { afterEach, describe, expect, it } from 'vitest';
import { createRoot, createSignal } from 'solid-js';
import type { Resource } from '@/types/resource';
@@ -38,7 +38,10 @@ import {
type PlatformResourceStatusFilter,
} from '../sharedPlatformPage';
afterEach(cleanup);
afterEach(() => {
cleanup();
window.localStorage.clear();
});
describe('withPlatformAttentionCount', () => {
it('decorates one attention option with a compact accessible count', () => {
@@ -444,7 +447,7 @@ describe('PlatformTableToolbar', () => {
expect(status()).toBe('all');
});
it('owns the View popover while keeping the row counter permanently visible', () => {
it('owns the View popover and the shared row-counter visibility preference', () => {
render(() =>
PlatformTableToolbar({
search: () => '',
@@ -472,6 +475,13 @@ describe('PlatformTableToolbar', () => {
const viewDialog = screen.getByRole('dialog', { name: 'View preferences' });
expect(viewDialog).toHaveTextContent('Columns');
expect(viewDialog).not.toContainElement(screen.getByText('2 of 3 rows'));
const visibility = within(viewDialog).getByRole('group', {
name: 'Inventory totals visibility',
});
fireEvent.click(within(visibility).getByRole('button', { name: 'Hide' }));
expect(filterBar).not.toHaveTextContent('2 of 3 rows');
expect(window.localStorage.getItem('platformEstateOverviewVisible')).toBe('false');
});
it('composes consumer context into the canonical toolbar rail', () => {
@@ -1,82 +1,18 @@
import type { Resource, ResourceType } from '@/types/resource';
import type { Resource } from '@/types/resource';
export const PLATFORM_ESTATE_OVERVIEW_STORAGE_KEY = 'platformEstateOverviewVisible';
// Keep the original key so operators who hid the earlier panel retain the same
// preference when the information moves into the existing filter controls.
export const PLATFORM_ESTATE_COUNTS_STORAGE_KEY = 'platformEstateOverviewVisible';
export type PlatformEstateOverviewPlatform =
'proxmox' | 'docker' | 'kubernetes' | 'truenas' | 'vmware' | 'standalone';
export const deserializePlatformEstateCountsVisibility = (raw: string): boolean => raw !== 'false';
export type PlatformEstateMetric = {
id: string;
label: string;
value: number | string;
export type ProxmoxEstateTopology = {
clusters: number;
nodes: number;
standalone: number;
};
export type PlatformEstateSpotlightTone = 'danger' | 'warning';
export type PlatformEstateSpotlight = {
id: string;
href: string;
label: string;
meta: string;
resourceId: string;
tone: PlatformEstateSpotlightTone;
};
export type PlatformEstateOverviewModel = {
metrics: readonly PlatformEstateMetric[];
spotlights: readonly PlatformEstateSpotlight[];
};
const ATTENTION_STATUSES = new Set(['offline', 'degraded', 'warning']);
const DANGER_TOKENS = new Set(['critical', 'danger', 'fatal', 'offline', 'failed', 'error']);
const WARNING_TOKENS = new Set(['warning', 'warn', 'degraded', 'attention']);
const normalize = (value: unknown): string =>
typeof value === 'string' ? value.trim().toLowerCase() : '';
const displayName = (resource: Resource): string =>
resource.displayName?.trim() || resource.name?.trim() || resource.id;
const countTypes = (resources: readonly Resource[], types: readonly ResourceType[]): number => {
const accepted = new Set<ResourceType>(types);
return resources.filter((resource) => accepted.has(resource.type)).length;
};
const resourceHasDangerEvidence = (resource: Resource): boolean => {
if (normalize(resource.status) === 'offline') return true;
if (DANGER_TOKENS.has(normalize(resource.incidentSeverity))) return true;
if (resource.incidents?.some((incident) => DANGER_TOKENS.has(normalize(incident.severity)))) {
return true;
}
return Boolean(resource.alerts?.some((alert) => DANGER_TOKENS.has(normalize(alert.level))));
};
const resourceHasWarningEvidence = (resource: Resource): boolean => {
if (ATTENTION_STATUSES.has(normalize(resource.status))) return true;
if (WARNING_TOKENS.has(normalize(resource.incidentSeverity))) return true;
if (resource.incidents?.some((incident) => WARNING_TOKENS.has(normalize(incident.severity)))) {
return true;
}
if (resource.alerts?.some((alert) => WARNING_TOKENS.has(normalize(alert.level)))) return true;
return resource.type === 'storage' && Number(resource.disk?.current) >= 85;
};
export const isPlatformEstateAttentionResource = (resource: Resource): boolean =>
resourceHasDangerEvidence(resource) || resourceHasWarningEvidence(resource);
const attentionCount = (resources: readonly Resource[]): number =>
resources.filter(isPlatformEstateAttentionResource).length;
const metric = (id: string, label: string, value: number | string): PlatformEstateMetric => ({
id,
label,
value,
});
export const formatPlatformEstateMetricValue = (value: number | string): string =>
typeof value === 'number' ? value.toLocaleString() : value;
const buildProxmoxMetrics = (resources: readonly Resource[]): PlatformEstateMetric[] => {
export function buildProxmoxEstateTopology(resources: readonly Resource[]): ProxmoxEstateTopology {
const nodes = resources.filter((resource) => resource.type === 'agent');
const clusters = new Set(
nodes
@@ -87,265 +23,5 @@ const buildProxmoxMetrics = (resources: readonly Resource[]): PlatformEstateMetr
(resource) => !resource.proxmox?.clusterName?.trim() && !resource.clusterId?.trim(),
).length;
return [
metric('nodes', 'Proxmox nodes', nodes.length),
metric(
'workloads',
'VMs and containers',
countTypes(resources, ['vm', 'system-container', 'oci-container']),
),
metric('topology', 'Clusters + standalone', `${clusters.size} + ${standalone}`),
metric('attention', 'Need attention', attentionCount(resources)),
];
};
const buildDockerMetrics = (resources: readonly Resource[]): PlatformEstateMetric[] => [
metric(
'hosts',
'Runtime hosts',
resources.filter(
(resource) =>
resource.type === 'docker-host' || (resource.type === 'agent' && resource.docker),
).length,
),
metric('containers', 'Containers', countTypes(resources, ['app-container'])),
metric('images', 'Images', countTypes(resources, ['docker-image'])),
metric('attention', 'Need attention', attentionCount(resources)),
];
const buildKubernetesMetrics = (resources: readonly Resource[]): PlatformEstateMetric[] => [
metric('clusters', 'Clusters', countTypes(resources, ['k8s-cluster'])),
metric(
'nodes',
'Nodes',
resources.filter(
(resource) =>
resource.type === 'k8s-node' || (resource.type === 'agent' && resource.kubernetes),
).length,
),
metric('pods', 'Pods', countTypes(resources, ['pod'])),
metric('attention', 'Need attention', attentionCount(resources)),
];
const buildTrueNASMetrics = (resources: readonly Resource[]): PlatformEstateMetric[] => [
metric('systems', 'TrueNAS systems', countTypes(resources, ['agent'])),
metric(
'pools',
'Storage pools',
resources.filter(
(resource) => resource.type === 'pool' || resource.storage?.topology === 'pool',
).length,
),
metric('workloads', 'Apps and VMs', countTypes(resources, ['app-container', 'vm'])),
metric('attention', 'Need attention', attentionCount(resources)),
];
const buildVmwareMetrics = (resources: readonly Resource[]): PlatformEstateMetric[] => {
const connections = new Set(
resources.map((resource) => resource.vmware?.connectionId?.trim()).filter(Boolean),
);
return [
metric('connections', 'vCenter connections', connections.size),
metric('hosts', 'ESXi hosts', countTypes(resources, ['agent'])),
metric('workloads', 'Virtual machines', countTypes(resources, ['vm'])),
metric('attention', 'Need attention', attentionCount(resources)),
];
};
const buildStandaloneMetrics = (resources: readonly Resource[]): PlatformEstateMetric[] => {
const machines = resources.filter((resource) => resource.type === 'agent');
return [
metric('machines', 'Machines', machines.length),
metric('checks', 'Availability checks', countTypes(resources, ['network-endpoint'])),
metric(
'healthy',
'Reporting normally',
machines.filter((resource) => ['online', 'running'].includes(normalize(resource.status)))
.length,
),
metric('attention', 'Need attention', attentionCount(resources)),
];
};
export function buildPlatformEstateMetrics(
platform: PlatformEstateOverviewPlatform,
resources: readonly Resource[],
): PlatformEstateMetric[] {
switch (platform) {
case 'proxmox':
return buildProxmoxMetrics(resources);
case 'docker':
return buildDockerMetrics(resources);
case 'kubernetes':
return buildKubernetesMetrics(resources);
case 'truenas':
return buildTrueNASMetrics(resources);
case 'vmware':
return buildVmwareMetrics(resources);
case 'standalone':
return buildStandaloneMetrics(resources);
}
return { clusters: clusters.size, nodes: nodes.length, standalone };
}
const resourceTypeLabel = (type: ResourceType): string => {
const labels: Partial<Record<ResourceType, string>> = {
agent: 'System',
'docker-host': 'Runtime host',
'app-container': 'Container',
'docker-image': 'Image',
'k8s-cluster': 'Cluster',
'k8s-node': 'Node',
pod: 'Pod',
vm: 'Virtual machine',
'system-container': 'Container',
'oci-container': 'OCI container',
storage: 'Storage',
physical_disk: 'Physical disk',
'network-endpoint': 'Availability check',
};
return labels[type] ?? type.replace(/-/g, ' ');
};
const spotlightRoute = (platform: PlatformEstateOverviewPlatform, resource: Resource): string => {
switch (platform) {
case 'proxmox':
if (resource.type === 'pbs') return '/proxmox/backups';
if (resource.type === 'ceph') return '/proxmox/ceph';
if (resource.type === 'pmg') return '/proxmox/mail';
if (['storage', 'physical_disk'].includes(resource.type)) return '/proxmox/storage';
return '/proxmox/overview';
case 'docker':
if (resource.type === 'docker-image') return '/docker/images';
if (resource.type === 'docker-volume') return '/docker/storage';
if (resource.type === 'docker-network') return '/docker/networks';
if (
[
'docker-service',
'docker-task',
'docker-swarm-node',
'docker-secret',
'docker-config',
].includes(resource.type)
) {
return '/docker/swarm';
}
return '/docker/overview';
case 'kubernetes':
if (resource.type === 'k8s-node' || (resource.type === 'agent' && resource.kubernetes)) {
return '/kubernetes/nodes';
}
if (
['pod', 'k8s-deployment', 'k8s-replicaset', 'k8s-statefulset', 'k8s-daemonset'].includes(
resource.type,
)
) {
return '/kubernetes/workloads';
}
if (
['k8s-persistent-volume', 'k8s-persistent-volume-claim', 'k8s-storage-class'].includes(
resource.type,
)
) {
return '/kubernetes/storage';
}
if (resource.type === 'k8s-event') return '/kubernetes/events';
return '/kubernetes/overview';
case 'truenas':
if (['storage', 'pool', 'dataset', 'physical_disk'].includes(resource.type)) {
return '/truenas/storage';
}
if (resource.type === 'app-container') return '/truenas/apps';
if (resource.type === 'vm') return '/truenas/vms';
if (resource.type === 'network-share') return '/truenas/shares';
return '/truenas/overview';
case 'vmware':
if (resource.type === 'storage') return '/vmware/storage';
if (resource.type === 'network') return '/vmware/networks';
if (resource.incidentCount || resource.incidents?.length) return '/vmware/health';
return '/vmware/overview';
case 'standalone':
return resource.type === 'network-endpoint' ? '/machines/availability' : '/machines';
}
};
const spotlightEvidence = (
resource: Resource,
): { label: string; meta: string; tone: PlatformEstateSpotlightTone } => {
const name = displayName(resource);
const incident = resource.incidents?.find((item) => item.summary?.trim());
const alert = resource.alerts?.find((item) => item.message?.trim());
const tone = resourceHasDangerEvidence(resource) ? 'danger' : 'warning';
if (incident) {
return {
label: incident.summary.trim(),
meta: `${name} · ${resourceTypeLabel(resource.type)}`,
tone,
};
}
if (alert) {
return {
label: alert.message.trim(),
meta: `${name} · ${resourceTypeLabel(resource.type)}`,
tone,
};
}
if (resource.type === 'storage' && Number(resource.disk?.current) >= 85) {
return {
label: `${name} storage pressure`,
meta: `${Math.round(Number(resource.disk?.current))}% used`,
tone,
};
}
const status = normalize(resource.status);
return {
label: status === 'offline' ? `${name} offline` : `${name} needs attention`,
meta: `${resourceTypeLabel(resource.type)} · ${status || 'attention'}`,
tone,
};
};
export function buildPlatformOperationalSpotlights(
platform: PlatformEstateOverviewPlatform,
resources: readonly Resource[],
limit = 3,
): PlatformEstateSpotlight[] {
const boundedLimit = Math.max(0, Math.trunc(limit));
if (boundedLimit === 0) return [];
const compare = (left: PlatformEstateSpotlight, right: PlatformEstateSpotlight): number => {
if (left.tone !== right.tone) return left.tone === 'danger' ? -1 : 1;
return left.label.localeCompare(right.label, undefined, { numeric: true });
};
const selected: PlatformEstateSpotlight[] = [];
for (const resource of resources) {
if (!isPlatformEstateAttentionResource(resource)) continue;
const evidence = spotlightEvidence(resource);
const spotlight = {
id: `${platform}:${resource.id}`,
href: spotlightRoute(platform, resource),
label: evidence.label,
meta: evidence.meta,
resourceId: resource.id,
tone: evidence.tone,
} satisfies PlatformEstateSpotlight;
const insertionIndex = selected.findIndex((candidate) => compare(spotlight, candidate) < 0);
selected.splice(insertionIndex < 0 ? selected.length : insertionIndex, 0, spotlight);
if (selected.length > boundedLimit) selected.pop();
}
return selected;
}
export function buildPlatformEstateOverviewModel(
platform: PlatformEstateOverviewPlatform,
resources: readonly Resource[],
): PlatformEstateOverviewModel {
return {
metrics: buildPlatformEstateMetrics(platform, resources),
spotlights: buildPlatformOperationalSpotlights(platform, resources),
};
}
export const deserializePlatformEstateOverviewVisibility = (raw: string): boolean =>
raw !== 'false';
@@ -17,6 +17,7 @@ import {
import { EmptyState } from '@/components/shared/EmptyState';
import { type FilterOption as PlatformTableFilterOption } from '@/components/shared/FilterButtonGroup';
import { FilterBar, filterChipStatusDot, type FilterDef } from '@/components/shared/FilterBar';
import { FilterSegmentedControl } from '@/components/shared/FilterToolbar';
import { MetadataBadge } from '@/components/shared/MetadataBadge';
import { type SearchInputProps } from '@/components/shared/SearchInput';
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/shared/Table';
@@ -36,6 +37,10 @@ import {
import { asTrimmedString } from '@/utils/stringUtils';
import { formatVmwareClusterServices } from '@/utils/vmwareDisplay';
import { getPlatformColumnAlign, type PlatformTableColumnKind } from './columnAlignment';
import {
PLATFORM_ESTATE_COUNTS_STORAGE_KEY,
deserializePlatformEstateCountsVisibility,
} from './platformEstateOverviewModel';
export type { PlatformTableFilterOption };
@@ -1188,6 +1193,11 @@ export function PlatformTableToolbar<T extends string | number>(props: {
viewOptions?: JSX.Element;
}) {
const { isMobile } = useBreakpoint();
const [inventoryCountsVisible, setInventoryCountsVisible] = usePersistentSignal(
PLATFORM_ESTATE_COUNTS_STORAGE_KEY,
true,
{ deserialize: deserializePlatformEstateCountsVisibility },
);
// Migrated onto the shared FilterBar so every platform table inherits the
// same combinable-filter UX (chip rail, saved-view scaffolding, mobile
@@ -1238,13 +1248,33 @@ export function PlatformTableToolbar<T extends string | number>(props: {
showAddFilterLabel={false}
savedViewsKey={props.savedViewsKey}
leadingControls={props.leadingControls}
viewOptions={props.viewOptions}
viewOptions={
<>
{props.viewOptions}
<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>
</>
}
trailingControls={
<PlatformResourceCounter
visible={props.visible}
total={props.total}
rowNoun={props.rowNoun}
/>
<Show when={inventoryCountsVisible()}>
<PlatformResourceCounter
visible={props.visible}
total={props.total}
rowNoun={props.rowNoun}
/>
</Show>
}
showClearAll={() => Boolean(props.hasActiveFilters) || props.search().trim().length > 0}
onClearAll={props.onResetFilters}
@@ -22,6 +22,7 @@ import { MetricMiniSparkline } from '@/components/Workloads/MetricMiniSparkline'
import { TemperatureGauge } from '@/components/shared/TemperatureGauge';
import { hostOverrideIdCandidates } from '@/features/alerts/alertOverridesModel';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { TableCell, TableRow } from '@/components/shared/Table';
import { getSimpleStatusIndicator } from '@/utils/status';
import { getNodeExternalUrl } from '@/utils/nodes';
@@ -36,6 +37,7 @@ import {
PlatformTableMetricFallback,
PlatformTableShell,
createPlatformTableSortState,
formatPlatformTableIntegerValue,
formatPlatformTablePercentValue,
formatPlatformTableUptimeValue,
getPlatformTableFiniteMetric,
@@ -43,6 +45,11 @@ import {
type PlatformTableSortValue,
} from '@/features/platformPage/sharedPlatformPage';
import { PlatformResourceDetailToggleButton } from '@/features/platformPage/PlatformResourceDetailTableRow';
import {
PLATFORM_ESTATE_COUNTS_STORAGE_KEY,
deserializePlatformEstateCountsVisibility,
type ProxmoxEstateTopology,
} from '@/features/platformPage/platformEstateOverviewModel';
import { type WorkloadsMetricDisplayMode } from '@/components/Workloads/workloadsFilterModel';
import { type WorkloadTableMetricHistoryRange } from '@/components/Workloads/workloadMetricHistoryModel';
import type { Disk, Node as LegacyNode } from '@/types/api';
@@ -194,8 +201,12 @@ export const ProxmoxNodesTable: Component<{
emptyIcon: JSX.Element;
emptyTitle: string;
emptyDescription: string;
topology?: ProxmoxEstateTopology;
}> = (props) => {
const breakpoint = useBreakpoint();
const [inventoryCountsVisible] = usePersistentSignal(PLATFORM_ESTATE_COUNTS_STORAGE_KEY, true, {
deserialize: deserializePlatformEstateCountsVisibility,
});
const { activeAlerts } = useWebSocket();
const alertsActivation = useAlertsActivation();
const alertsEnabled = alertsActivation.detectionEnabled;
@@ -239,7 +250,38 @@ export const ProxmoxNodesTable: Component<{
}
>
<PlatformTableShell
title="Nodes"
title={
<span class="inline-flex items-center gap-1.5">
Nodes
<Show when={inventoryCountsVisible()}>
<span class="rounded bg-base/70 px-1.5 text-[10px] tabular-nums text-muted">
{formatPlatformTableIntegerValue(props.nodes.length)}
</span>
</Show>
</span>
}
actions={
<Show
when={
inventoryCountsVisible() &&
props.topology &&
(props.topology.clusters > 0 || props.topology.standalone > 0)
}
>
<span class="text-[10px] font-medium text-muted">
<Show when={props.topology!.clusters > 0}>
{formatPlatformTableIntegerValue(props.topology!.clusters)}{' '}
{props.topology!.clusters === 1 ? 'cluster' : 'clusters'}
</Show>
<Show when={props.topology!.clusters > 0 && props.topology!.standalone > 0}>
{' · '}
</Show>
<Show when={props.topology!.standalone > 0}>
{formatPlatformTableIntegerValue(props.topology!.standalone)} standalone
</Show>
</span>
</Show>
}
cardClass="proxmox-nodes-card"
tableClass={`${getProxmoxHostTableMinWidthClass(layoutMode())} table-fixed text-xs`}
colgroup={
@@ -1,6 +1,5 @@
import { useLocation } from '@solidjs/router';
import { Show, createMemo, createResource, type Accessor } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import StorageSurface from '@/components/Storage/Storage';
import { WorkloadsFilter } from '@/components/Workloads/WorkloadsFilter';
import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface';
@@ -27,7 +26,7 @@ import {
import { getPlatformIcon } from '@/features/platformPage/platformIcon';
import { PlatformOutdatedAgentNotice } from '@/features/platformPage/PlatformOutdatedAgentNotice';
import { PlatformOutdatedSensorSetupNotice } from '@/features/platformPage/PlatformOutdatedSensorSetupNotice';
import { PlatformEstateOverview } from '@/features/platformPage/PlatformEstateOverview';
import { buildProxmoxEstateTopology } from '@/features/platformPage/platformEstateOverviewModel';
import { collectOutdatedSensorSetupNodes } from '@/features/platformPage/sensorSetup';
import { usePersistentSignal } from '@/hooks/usePersistentSignal';
import { useObservedElementWidth } from '@/hooks/useObservedElementWidth';
@@ -205,7 +204,6 @@ export function ProxmoxPageSurface() {
/>
<Show when={activeTab() === 'overview'}>
<div class="space-y-4">
<PlatformEstateOverview platform="proxmox" resources={model().resources} />
<ProxmoxOverview
model={model}
metricDisplayMode={metricDisplayMode}
@@ -306,7 +304,7 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
workloadsState.surfaceInitialDataReceived() &&
workloadsState.allGuests().length > 0,
);
const visibleGuestStats = createMemo(() => workloadsState.totalStats());
const estateTopology = createMemo(() => buildProxmoxEstateTopology(currentModel().resources));
const filteredNodes = createMemo(() =>
filterProxmoxNodesForSearch(
currentModel().pveNodes,
@@ -336,6 +334,7 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
searchPlaceholder="Search VMs and LXCs by name, VMID, node, or status"
searchEmptyMessage="Recent Proxmox workload searches appear here."
statusOptions={PROXMOX_WORKLOAD_STATUS_OPTIONS}
inventoryStats={workloadsState.inventoryStats}
columnVisibility={workloadsState.workloadsFilterColumnVisibility()}
containerRuntimeFilter={workloadsState.containerRuntimeFilterConfig()}
hostFilter={workloadsState.hostFilterConfig()}
@@ -366,6 +365,7 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
emptyIcon={<ProxmoxIcon class="h-6 w-6 text-slate-400" />}
emptyTitle="No Proxmox VE nodes"
emptyDescription="Proxmox VE nodes appear here once a PVE host reports inventory."
topology={estateTopology()}
/>
<WorkloadsSurface
state={workloadsState}
@@ -382,39 +382,6 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
emptyStateTitle="No Proxmox workloads"
emptyStateDescription="Proxmox VMs and LXCs appear here when inventory is available."
/>
{/* Keep this orientation strip aligned with the rows the operator can
currently see rather than the unfiltered page-wide inventory. */}
<Show when={visibleGuestStats().total > 0}>
<div
class="flex items-center gap-2 rounded border border-border bg-surface-alt px-2 py-1 text-xs text-muted"
data-testid="proxmox-guest-totals"
>
<Show when={visibleGuestStats().running > 0}>
<span class="flex items-center gap-1.5">
<StatusDot size="xs" variant="success" ariaHidden />
{visibleGuestStats().running} running
</span>
</Show>
<Show when={visibleGuestStats().degraded > 0}>
<Show when={visibleGuestStats().running > 0}>
<span aria-hidden="true">|</span>
</Show>
<span class="flex items-center gap-1.5">
<StatusDot size="xs" variant="warning" ariaHidden />
{visibleGuestStats().degraded} attention
</span>
</Show>
<Show when={visibleGuestStats().stopped > 0}>
<Show when={visibleGuestStats().running > 0 || visibleGuestStats().degraded > 0}>
<span aria-hidden="true">|</span>
</Show>
<span class="flex items-center gap-1.5">
<StatusDot size="xs" variant="danger" ariaHidden />
{visibleGuestStats().stopped} stopped
</span>
</Show>
</div>
</Show>
</div>
);
}
@@ -9,6 +9,7 @@ const mockPathname = vi.hoisted(() => vi.fn(() => '/proxmox/overview'));
const mockVersionInfo = vi.hoisted(() => vi.fn());
const mockStorageProps = vi.hoisted(() => vi.fn());
const mockTotalStats = vi.hoisted(() => vi.fn());
const mockNodesTableProps = vi.hoisted(() => vi.fn());
const makeResource = (resource: Partial<Resource> & Pick<Resource, 'id' | 'type'>): Resource =>
({
@@ -118,9 +119,10 @@ vi.mock('../ProxmoxMailGatewayTable', () => ({
}));
vi.mock('../ProxmoxNodesTable', () => ({
ProxmoxNodesTable: (props: { nodes: Resource[] }) => (
<div data-testid="nodes-table" data-rows={props.nodes.length} />
),
ProxmoxNodesTable: (props: { nodes: Resource[]; topology?: unknown }) => {
mockNodesTableProps(props);
return <div data-testid="nodes-table" data-rows={props.nodes.length} />;
},
}));
vi.mock('../ProxmoxReplicationTable', () => ({
@@ -234,7 +236,7 @@ describe('ProxmoxPageSurface contract', () => {
expect(screen.queryByTestId('platform-outdated-agent-notice')).not.toBeInTheDocument();
});
it('renders guest totals from the filtered workload collection', () => {
it('folds estate topology into the existing nodes table header contract', () => {
setResources([
makeResource({
id: 'agent:pve-1',
@@ -260,23 +262,13 @@ describe('ProxmoxPageSurface contract', () => {
proxmox: { nodeName: 'pve-1', vmid: 102 },
}),
]);
mockTotalStats.mockReturnValue({
total: 1,
running: 0,
degraded: 0,
stopped: 1,
vms: 1,
containers: 0,
appContainers: 0,
pods: 0,
});
renderSurface();
const totals = screen.getByTestId('proxmox-guest-totals');
expect(totals).not.toHaveTextContent('running');
expect(totals).not.toHaveTextContent('attention');
expect(totals).toHaveTextContent('1 stopped');
expect(mockNodesTableProps).toHaveBeenLastCalledWith(
expect.objectContaining({
topology: { clusters: 1, nodes: 1, standalone: 0 },
}),
);
});
it('keeps Patrol coverage off the Proxmox overview', () => {
@@ -12,7 +12,6 @@ import {
} from '@/components/Settings/infrastructureWorkspaceModel';
import { buildAvailabilitySettingsPath } from '@/components/Settings/availabilitySettingsModel';
import { PlatformOutdatedAgentNotice } from '@/features/platformPage/PlatformOutdatedAgentNotice';
import { PlatformEstateOverview } from '@/features/platformPage/PlatformEstateOverview';
import {
collectOutdatedAgentHosts,
formatAgentVersionDisplay,
@@ -302,7 +301,6 @@ export function StandalonePageSurface() {
}
>
<div class="space-y-4">
<PlatformEstateOverview platform="standalone" resources={resources()} />
<HostIdentityConflictNotice hosts={identityConflictHosts()} />
<PlatformOutdatedAgentNotice
hosts={outdatedAgentHosts()}
@@ -2,7 +2,6 @@ import { useLocation } from '@solidjs/router';
import { Show, createMemo, type Accessor } from 'solid-js';
import { buildInfrastructureAgentUpdatesPath } from '@/components/Settings/infrastructureWorkspaceModel';
import { getPlatformIcon } from '@/features/platformPage/platformIcon';
import { PlatformEstateOverview } from '@/features/platformPage/PlatformEstateOverview';
import { PlatformOutdatedAgentNotice } from '@/features/platformPage/PlatformOutdatedAgentNotice';
import {
collectOutdatedAgentHosts,
@@ -134,7 +133,6 @@ export function TrueNASPageSurface() {
/>
<Show when={activeTab() === 'overview'}>
<div class="space-y-4">
<PlatformEstateOverview platform="truenas" resources={model().resources} />
<TrueNASOverview model={model} />
</div>
</Show>
@@ -3,7 +3,6 @@ import { For, Show, createMemo, createResource, type Accessor } from 'solid-js';
import AlertTriangle from 'lucide-solid/icons/triangle-alert';
import { buildInfrastructureAgentUpdatesPath } from '@/components/Settings/infrastructureWorkspaceModel';
import { getPlatformIcon } from '@/features/platformPage/platformIcon';
import { PlatformEstateOverview } from '@/features/platformPage/PlatformEstateOverview';
import { PlatformOutdatedAgentNotice } from '@/features/platformPage/PlatformOutdatedAgentNotice';
import {
collectOutdatedAgentHosts,
@@ -270,7 +269,6 @@ export function VmwarePageSurface() {
/>
<Show when={activeTab() === 'overview'}>
<div class="space-y-4">
<PlatformEstateOverview platform="vmware" resources={model().resources} />
<VmwareOverview
model={model}
metricDisplayMode={metricDisplayMode}
@@ -408,6 +406,7 @@ function VmwareOverview(props: VmwareOverviewProps) {
searchPlaceholder="Search vSphere VMs by name, host, cluster, or status"
searchEmptyMessage="Recent vSphere workload searches appear here."
statusOptions={VMWARE_WORKLOAD_STATUS_OPTIONS}
inventoryStats={workloadsState.inventoryStats}
suppressTypeFilter
columnVisibility={workloadsState.workloadsFilterColumnVisibility()}
containerRuntimeFilter={workloadsState.containerRuntimeFilterConfig()}