mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
fix(frontend): fold estate totals into platform controls
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+30
-103
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+4
-2
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+11
-19
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user