diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 8c2151273..618200dd8 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -217,6 +217,8 @@ 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` 132. `frontend-modern/src/utils/platformSupportManifest.generated.ts` 133. `frontend-modern/src/utils/platformSupportManifest.ts` 134. `frontend-modern/src/utils/sourcePlatformOptions.ts` @@ -2056,6 +2058,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. + ## Forbidden Paths 1. Reinventing table/filter/toggle primitives when a shared version exists @@ -2080,6 +2097,9 @@ default` instead of fusing provider and badge text such as `/settings/infrastructure/*` paths back into current settings panels. 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. ## Completion Obligations diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 87ef3cc22..d00cd53c5 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -4727,6 +4727,8 @@ "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", "frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx", @@ -5240,6 +5242,8 @@ "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", "frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx", @@ -5248,7 +5252,10 @@ "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ - "frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts" + "frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts", + "frontend-modern/src/features/platformPage/__tests__/PlatformEstateOverview.test.tsx", + "frontend-modern/src/features/platformPage/__tests__/platformEstateOverviewModel.test.ts", + "frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts" ] }, { @@ -7340,11 +7347,13 @@ "frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx", "frontend-modern/src/features/truenas/TrueNASAppsTable.tsx", "frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx", + "frontend-modern/src/features/truenas/TrueNASPageSurface.tsx", "frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx", "frontend-modern/src/features/truenas/TrueNASServicesTable.tsx", "frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx", "frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx", "frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx", + "frontend-modern/src/features/vmware/VmwarePageSurface.tsx", "frontend-modern/src/features/vmware/VsphereActivityTable.tsx", "frontend-modern/src/features/vmware/VsphereAlertsTable.tsx", "frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx", @@ -7533,11 +7542,13 @@ "frontend-modern/src/features/truenas/TrueNASAlertsTable.tsx", "frontend-modern/src/features/truenas/TrueNASAppsTable.tsx", "frontend-modern/src/features/truenas/TrueNASNetworkSharesTable.tsx", + "frontend-modern/src/features/truenas/TrueNASPageSurface.tsx", "frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx", "frontend-modern/src/features/truenas/TrueNASServicesTable.tsx", "frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx", "frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx", "frontend-modern/src/features/truenas/TrueNASVirtualMachinesTable.tsx", + "frontend-modern/src/features/vmware/VmwarePageSurface.tsx", "frontend-modern/src/features/vmware/VsphereActivityTable.tsx", "frontend-modern/src/features/vmware/VsphereAlertsTable.tsx", "frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx", diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 69a45f83e..1bdf5220e 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -403,6 +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. 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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 1db98929f..7aae64d08 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -274,9 +274,22 @@ about the same disk cannot diverge. 132. `frontend-modern/src/features/vmware/VsphereAlertsTable.tsx` 133. `frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx` 134. `frontend-modern/src/features/vmware/VsphereNetworksTable.tsx` +135. `frontend-modern/src/features/truenas/TrueNASPageSurface.tsx` +136. `frontend-modern/src/features/vmware/VmwarePageSurface.tsx` ## 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. + Kubernetes workload presentation is API-native, not generic inventory. Pods, Deployments, controllers, and autoscalers render under the `/kubernetes/workloads` workflow tab; legacy object routes such as `/kubernetes/pods`, @@ -1367,6 +1380,8 @@ served clones. Proof: `TestClonedResourcesPreservePlatformAdmission` and 1. New ad hoc resource-type aliases outside unified resource normalization 2. New duplicate ID normalization logic outside unified resources 3. Reintroducing legacy runtime resource contracts as live truth +4. Platform page-local resource membership or attention rules for estate + overview metrics and spotlights ## Completion Obligations diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 542716d94..2584a2179 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,21 +1,37 @@ { "version": 1, - "base_sha": "57e08985c37e33dc48e90345afe98c1d7a8f1a31", - "verified_at": "2026-08-19T15:24:06Z", + "base_sha": "2da68787550ca774b0e202591ab542d8e35a688e", + "verified_at": "2026-08-19T16:23:24Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx", - "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx", - "frontend-modern/src/features/proxmox/useProxmoxBackupTableWindowing.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/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/proxmox/ProxmoxCoverageTable.tsx": "82e2164c3190b49a6540531576b598862ce829b353236cc8df6946e4b3fd917d", - "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx": "ebf464760597dbb92c172e9b51284578f156ddcae574da87c097a9dd7b6381f3", - "frontend-modern/src/features/proxmox/useProxmoxBackupTableWindowing.ts": "6e9ba89417d2d664c3a40f0e29922414bbc103e738e9698ea584049c67790fd0" + "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" }, "routes": [ "/proxmox/overview", - "/proxmox/backups" + "/docker/overview", + "/kubernetes/overview", + "/truenas/overview", + "/vmware/overview", + "/standalone/machines" ], "viewports": [ { @@ -28,17 +44,17 @@ } ], "states": [ - "Large Proxmox overview showing 578 workloads across five named six-node clusters and two standalone nodes with no legacy mock aliases", - "Overview workload table capped at 140 mounted rows while preserving the complete fleet totals and exact filtered results", - "Backup Coverage showing 581 targets, 1874 restore points, 225 PBS snapshots, and at most 140 mounted rows", - "Backup By date view preserving day headings and restore artifacts within the same 140-row mounted window", - "Coverage and By date tables at desktop and 390px narrow widths with continuous table geometry and responsive columns" + "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" ], "interactions": [ - "Searched the overview for checkout-web-01 and confirmed the exact deep-estate workload appeared in approximately 176ms", - "Scrolled through the large overview and backup tables to verify continuous window advancement without pagination or load-more controls", - "Switched between Coverage and By date backup views and confirmed each mounted at most 140 rows", - "Searched backups for checkout-web-01 and confirmed the exact deep-estate result appeared in approximately 82ms", - "Reloaded and inspected overview, backup coverage, and backup date states at 1280x720 and 390x844" + "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" ] } diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index a6df4ce44..6bbfe025b 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -3193,6 +3193,30 @@ "src/components/shared/SharedPrimitives.guardrails.test.ts", "scripts/shared-template-audit.mjs" ] + }, + { + "id": "platform-estate-overview", + "category": "platform-overview", + "summary": "Every primary platform overview composes one shared estate metric and operational spotlight surface with one global persisted visibility preference.", + "canonical": { + "path": "src/features/platformPage/PlatformEstateOverview.tsx", + "export": "PlatformEstateOverview" + }, + "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/features/platformPage/__tests__/platformEstateOverviewModel.test.ts", + "src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts", + "scripts/shared-template-audit.mjs" + ] } ], "patternGuards": [ diff --git a/frontend-modern/src/features/docker/DockerPageSurface.tsx b/frontend-modern/src/features/docker/DockerPageSurface.tsx index 6390ef0a2..481b800e8 100644 --- a/frontend-modern/src/features/docker/DockerPageSurface.tsx +++ b/frontend-modern/src/features/docker/DockerPageSurface.tsx @@ -3,6 +3,7 @@ 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, @@ -156,14 +157,17 @@ export function DockerPageSurface() { actionLabel="Open agent upgrade commands" /> - +
+ + +
- +
+ + +
buildKubernetesOverviewPosture(props.model())); - const workloadAttention = () => posture().podAttention + posture().deploymentAttention; - const needsProminentAttention = () => - posture().nodeAttention > 0 || posture().criticalIncidents > 0; - const headline = () => { - const resources = posture().attentionResources; - const signals = posture().attentionSignals; - if (resources > 0 && signals > 0) { - return `${resources} resource${resources === 1 ? '' : 's'} and ${signals} signal${signals === 1 ? '' : 's'} need review`; - } - if (resources > 0) { - return `${resources} resource${resources === 1 ? '' : 's'} ${resources === 1 ? 'needs' : 'need'} review`; - } - return `${signals} health signal${signals === 1 ? '' : 's'} ${signals === 1 ? 'needs' : 'need'} review`; - }; + const workloadAttention = createMemo(() => { + const posture = buildKubernetesOverviewPosture(props.model()); + return posture.podAttention + posture.deploymentAttention; + }); return (
- - 0 || posture().criticalIncidents > 0 - ? 'danger' - : 'warning' - } - metrics={[ - ...(posture().nodeAttention > 0 - ? [{ label: 'nodes', value: posture().nodeAttention }] - : []), - ...(workloadAttention() > 0 - ? [{ label: 'workloads', value: workloadAttention() }] - : []), - ...(posture().attentionSignals > 0 - ? [{ label: 'signals', value: posture().attentionSignals }] - : []), - ]} - actions={ - <> - 0}> - - Review nodes - - - 0}> - - Review workloads - - - - } - /> - = { - danger: 'border-red-300 bg-red-50/70 dark:border-red-900/70 dark:bg-red-950/20', - warning: 'border-amber-300 bg-amber-50/70 dark:border-amber-900/70 dark:bg-amber-950/20', - info: 'border-blue-300 bg-blue-50/70 dark:border-blue-900/70 dark:bg-blue-950/20', -}; - -const toneVariant = (tone: PlatformAttentionSummaryTone) => - tone === 'danger' ? 'danger' : tone === 'warning' ? 'warning' : 'info'; - -export function PlatformAttentionSummary(props: { - title: string; - headline: string; - description: string; - tone: PlatformAttentionSummaryTone; - metrics?: readonly PlatformAttentionSummaryMetric[]; - actions?: JSX.Element; -}) { - return ( - -
-
-
- - - {props.title} - - {props.headline} -
-

{props.description}

-
- -
- - {(metric) => ( -
-
- {metric.value} -
-
{metric.label}
-
- )} -
- -
{props.actions}
-
-
-
-
- ); -} - -export default PlatformAttentionSummary; diff --git a/frontend-modern/src/features/platformPage/PlatformEstateOverview.tsx b/frontend-modern/src/features/platformPage/PlatformEstateOverview.tsx new file mode 100644 index 000000000..d2df813b4 --- /dev/null +++ b/frontend-modern/src/features/platformPage/PlatformEstateOverview.tsx @@ -0,0 +1,148 @@ +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 ( + + +
+ } + > +
+
+
+

+ Estate at a glance +

+

+ Live inventory and the most important operational signals. +

+
+ +
+ + +
+ + {(item, index) => ( +
+
+ {formatPlatformEstateMetricValue(item.value)} +
+
+ {item.label} +
+
+ )} +
+
+
+ + 0}> +
+
+ Operational spotlights +
+ +
+
+
+
+ ); +} + +export default PlatformEstateOverview; diff --git a/frontend-modern/src/features/platformPage/__tests__/PlatformAttentionSummary.test.tsx b/frontend-modern/src/features/platformPage/__tests__/PlatformAttentionSummary.test.tsx deleted file mode 100644 index 3839083d2..000000000 --- a/frontend-modern/src/features/platformPage/__tests__/PlatformAttentionSummary.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { cleanup, render, screen } from '@solidjs/testing-library'; -import { afterEach, describe, expect, it } from 'vitest'; -import { PlatformAttentionSummary } from '../PlatformAttentionSummary'; - -afterEach(cleanup); - -describe('PlatformAttentionSummary', () => { - it('renders an actionable compact posture region', () => { - render(() => ( - Show attention} - /> - )); - - const region = screen.getByRole('region', { name: 'Platform attention' }); - expect(region).toHaveAttribute('data-platform-attention-summary', 'warning'); - expect(region).toHaveTextContent('2 resources need review'); - expect(screen.getByRole('button', { name: 'Show attention' })).toBeInTheDocument(); - }); -}); diff --git a/frontend-modern/src/features/platformPage/__tests__/PlatformEstateOverview.test.tsx b/frontend-modern/src/features/platformPage/__tests__/PlatformEstateOverview.test.tsx new file mode 100644 index 000000000..7f976ba20 --- /dev/null +++ b/frontend-modern/src/features/platformPage/__tests__/PlatformEstateOverview.test.tsx @@ -0,0 +1,88 @@ +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(() => ( + + } + /> + + )); + +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(); + }); +}); diff --git a/frontend-modern/src/features/platformPage/__tests__/platformEstateOverviewModel.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformEstateOverviewModel.test.ts new file mode 100644 index 000000000..916066c17 --- /dev/null +++ b/frontend-modern/src/features/platformPage/__tests__/platformEstateOverviewModel.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import type { Resource } from '@/types/resource'; +import { + buildPlatformEstateMetrics, + buildPlatformOperationalSpotlights, + deserializePlatformEstateOverviewVisibility, + formatPlatformEstateMetricValue, + isPlatformEstateAttentionResource, + type PlatformEstateOverviewPlatform, +} from '../platformEstateOverviewModel'; + +const makeResource = ( + id: string, + type: Resource['type'], + overrides: Partial = {}, +): Resource => + ({ + id, + type, + name: id, + displayName: id, + platformId: 'estate', + platformType: 'generic', + sourceType: 'api', + status: 'online', + lastSeen: 1_700_000_000_000, + ...overrides, + }) as Resource; + +describe('platformEstateOverviewModel', () => { + it('derives a large Proxmox estate from the canonical scoped 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' }), + ]; + + 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([ + '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(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', () => { + const resources = Array.from({ length: 10_000 }, (_, index) => + makeResource(`node-${index}`, 'agent', { + status: index % 5 === 0 ? 'offline' : 'online', + proxmox: { clusterName: `cluster-${index % 40}` }, + }), + ); + + expect(buildPlatformEstateMetrics('proxmox', resources)).toHaveLength(4); + expect(buildPlatformOperationalSpotlights('proxmox', resources)).toHaveLength(3); + expect(formatPlatformEstateMetricValue(resources.length).length).toBeGreaterThan(5); + }); +}); diff --git a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts index 0f701163b..f8364affc 100644 --- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts @@ -171,12 +171,15 @@ const proxmoxInlineDetailTableSources = [ ]; describe('platform overview layout guardrails', () => { - it('keeps table-local attention in filters and reserves summaries for cross-section overviews', () => { + it('keeps attention summaries canonical across every platform overview', () => { for (const source of [truenasProtectionTableSource, vsphereAlertsTableSource]) { expect(source).toContain('withPlatformAttentionCount'); expect(source).not.toContain('PlatformAttentionSummary'); } - expect(kubernetesPageSurfaceSource).toContain('PlatformAttentionSummary'); + for (const source of overviewSurfaceSources) { + expect(source).toContain('PlatformEstateOverview'); + expect(source).not.toContain('PlatformAttentionSummary'); + } }); it('keeps platform inventory tables on the shared dense table styling contract', () => { diff --git a/frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts b/frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts new file mode 100644 index 000000000..b39214045 --- /dev/null +++ b/frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts @@ -0,0 +1,351 @@ +import type { Resource, ResourceType } from '@/types/resource'; + +export const PLATFORM_ESTATE_OVERVIEW_STORAGE_KEY = 'platformEstateOverviewVisible'; + +export type PlatformEstateOverviewPlatform = + 'proxmox' | 'docker' | 'kubernetes' | 'truenas' | 'vmware' | 'standalone'; + +export type PlatformEstateMetric = { + id: string; + label: string; + value: number | string; +}; + +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(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[] => { + const nodes = resources.filter((resource) => resource.type === 'agent'); + const clusters = new Set( + nodes + .map((resource) => resource.proxmox?.clusterName?.trim() || resource.clusterId?.trim() || '') + .filter(Boolean), + ); + const standalone = nodes.filter( + (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); + } +} + +const resourceTypeLabel = (type: ResourceType): string => { + const labels: Partial> = { + 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'; diff --git a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx index c37b15b02..5bf3abaf3 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx @@ -27,6 +27,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 { collectOutdatedSensorSetupNodes } from '@/features/platformPage/sensorSetup'; import { usePersistentSignal } from '@/hooks/usePersistentSignal'; import { useObservedElementWidth } from '@/hooks/useObservedElementWidth'; @@ -203,15 +204,18 @@ export function ProxmoxPageSurface() { actionHref={buildInfrastructureWorkspacePath()} /> - +
+ + +
({ fetchReplicationJobs: () => Promise.resolve([]), })); +const renderSurface = () => + render(() => ( + + + + )); + describe('ProxmoxPageSurface contract', () => { beforeEach(() => { mockPathname.mockReturnValue('/proxmox/overview'); @@ -159,7 +167,7 @@ describe('ProxmoxPageSurface contract', () => { }), ]); - render(() => ); + renderSurface(); expect(screen.getByTestId('storage-surface')).toBeInTheDocument(); expect(mockStorageProps).toHaveBeenCalledWith( @@ -183,7 +191,7 @@ describe('ProxmoxPageSurface contract', () => { }), ]); - render(() => ); + renderSurface(); expect(screen.getByTestId('platform-section-tabs')).toHaveAttribute('data-active', 'overview'); expect(screen.getByTestId('nodes-table')).toHaveAttribute('data-rows', '1'); @@ -220,7 +228,7 @@ describe('ProxmoxPageSurface contract', () => { }), ]); - render(() => ); + renderSurface(); expect(screen.getByTestId('nodes-table')).toHaveAttribute('data-rows', '1'); expect(screen.queryByTestId('platform-outdated-agent-notice')).not.toBeInTheDocument(); @@ -263,7 +271,7 @@ describe('ProxmoxPageSurface contract', () => { pods: 0, }); - render(() => ); + renderSurface(); const totals = screen.getByTestId('proxmox-guest-totals'); expect(totals).not.toHaveTextContent('running'); @@ -280,7 +288,7 @@ describe('ProxmoxPageSurface contract', () => { }), ]); - render(() => ); + renderSurface(); expect(screen.getByTestId('nodes-table')).toHaveAttribute('data-rows', '1'); expect(screen.queryByRole('list', { name: 'Proxmox Patrol coverage' })).not.toBeInTheDocument(); @@ -293,7 +301,7 @@ describe('ProxmoxPageSurface contract', () => { it('does not crash while Proxmox resources hydrate', () => { setResourcesSnapshot(undefined, true); - render(() => ); + renderSurface(); expect(screen.getByTestId('platform-table-loading-state')).toBeInTheDocument(); expect(screen.queryByRole('list', { name: 'Proxmox Patrol coverage' })).not.toBeInTheDocument(); @@ -315,7 +323,7 @@ describe('ProxmoxPageSurface contract', () => { }), ]); - render(() => ); + renderSurface(); expect(screen.queryByTestId('platform-outdated-agent-notice')).not.toBeInTheDocument(); }); diff --git a/frontend-modern/src/features/standalone/StandalonePageSurface.tsx b/frontend-modern/src/features/standalone/StandalonePageSurface.tsx index 2b20e9964..3bb296c19 100644 --- a/frontend-modern/src/features/standalone/StandalonePageSurface.tsx +++ b/frontend-modern/src/features/standalone/StandalonePageSurface.tsx @@ -12,6 +12,7 @@ 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, @@ -301,6 +302,7 @@ export function StandalonePageSurface() { } >
+ - +
+ + +
diff --git a/frontend-modern/src/features/vmware/VmwarePageSurface.tsx b/frontend-modern/src/features/vmware/VmwarePageSurface.tsx index 0bfb8100c..77967fc4e 100644 --- a/frontend-modern/src/features/vmware/VmwarePageSurface.tsx +++ b/frontend-modern/src/features/vmware/VmwarePageSurface.tsx @@ -3,6 +3,7 @@ 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, @@ -268,14 +269,17 @@ export function VmwarePageSurface() { subjectPlural="VMs" /> - +
+ + +