feat: add canonical platform estate overviews

This commit is contained in:
rcourtman
2026-08-19 17:25:02 +01:00
parent 2da6878755
commit 85ddd9f7f9
20 changed files with 903 additions and 210 deletions
@@ -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
@@ -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",
@@ -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
@@ -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
+35 -19
View File
@@ -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"
]
}
@@ -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": [
@@ -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"
/>
<Show when={activeTab() === 'overview'}>
<DockerOverview
hosts={model().hosts}
hostSourceCount={model().hosts.length}
hostFilter={hostFilter()}
containers={model().containers}
incidents={model().incidents}
onLifecycleActionSettled={refreshDockerResources}
/>
<div class="space-y-4">
<PlatformEstateOverview platform="docker" resources={model().resources} />
<DockerOverview
hosts={model().hosts}
hostSourceCount={model().hosts.length}
hostFilter={hostFilter()}
containers={model().containers}
incidents={model().incidents}
onLifecycleActionSettled={refreshDockerResources}
/>
</div>
</Show>
<Show when={activeTab() === 'images'}>
<DockerImagesTable
@@ -4,7 +4,7 @@ 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 { PlatformAttentionSummary } from '@/features/platformPage/PlatformAttentionSummary';
import { PlatformEstateOverview } from '@/features/platformPage/PlatformEstateOverview';
import { PlatformOutdatedAgentNotice } from '@/features/platformPage/PlatformOutdatedAgentNotice';
import {
collectOutdatedAgentHosts,
@@ -129,7 +129,10 @@ export function KubernetesPageSurface() {
subjectPlural="nodes"
/>
<Show when={activeTab() === 'overview'}>
<KubernetesOverview model={model} />
<div class="space-y-4">
<PlatformEstateOverview platform="kubernetes" resources={model().resources} />
<KubernetesOverview model={model} />
</div>
</Show>
<Show when={activeTab() === 'nodes'}>
<KubernetesNodesTable
@@ -631,61 +634,13 @@ function KubernetesConfiguration(props: { model: KubernetesPageModel }) {
function KubernetesOverview(props: KubernetesOverviewProps) {
const [searchParams, setSearchParams] = useSearchParams();
const posture = createMemo(() => 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 (
<div class="space-y-4">
<Show when={needsProminentAttention()}>
<PlatformAttentionSummary
title="Kubernetes attention"
headline={headline()}
description="Start with unavailable nodes or critical health signals. Workload inventory remains available below."
tone={
posture().criticalResources > 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={
<>
<Show when={posture().nodeAttention > 0}>
<ButtonLink href={buildKubernetesPath('nodes')} variant="secondary" size="sm">
Review nodes
</ButtonLink>
</Show>
<Show when={workloadAttention() > 0}>
<ButtonLink href={buildKubernetesPath('workloads')} variant="secondary" size="sm">
Review workloads
</ButtonLink>
</Show>
</>
}
/>
</Show>
<KubernetesClustersTable
clusters={props.model().clusters}
scope={props.model().resources}
@@ -1,73 +0,0 @@
import { For, Show, type JSX } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCard } from '@/components/shared/TableCard';
export type PlatformAttentionSummaryTone = 'danger' | 'warning' | 'info';
export type PlatformAttentionSummaryMetric = {
label: string;
value: string | number;
};
const toneClasses: Record<PlatformAttentionSummaryTone, string> = {
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 (
<TableCard
class={`border ${toneClasses[props.tone]}`}
role="region"
aria-label={props.title}
data-platform-attention-summary={props.tone}
>
<div class="flex flex-col gap-3 px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<div class="flex items-center gap-2">
<StatusDot
size="sm"
variant={toneVariant(props.tone)}
title={props.headline}
ariaHidden
/>
<span class="text-[11px] font-semibold uppercase tracking-wide text-muted">
{props.title}
</span>
<span class="text-sm font-semibold text-base-content">{props.headline}</span>
</div>
<p class="mt-1 text-xs leading-5 text-muted">{props.description}</p>
</div>
<div class="flex flex-wrap items-center gap-3 sm:justify-end">
<For each={props.metrics ?? []}>
{(metric) => (
<div class="min-w-14 text-right">
<div class="text-sm font-semibold tabular-nums text-base-content">
{metric.value}
</div>
<div class="text-[10px] uppercase tracking-wide text-muted">{metric.label}</div>
</div>
)}
</For>
<Show when={props.actions}>
<div class="flex flex-wrap items-center gap-2">{props.actions}</div>
</Show>
</div>
</div>
</TableCard>
);
}
export default PlatformAttentionSummary;
@@ -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 (
<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,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(() => (
<PlatformAttentionSummary
title="Platform attention"
headline="2 resources need review"
description="Review the affected resources first."
tone="warning"
metrics={[
{ label: 'critical', value: 0 },
{ label: 'warning', value: 2 },
]}
actions={<button type="button">Show attention</button>}
/>
));
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();
});
});
@@ -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(() => (
<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();
});
});
@@ -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> = {},
): 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<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(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);
});
});
@@ -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', () => {
@@ -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<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[] => {
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<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';
@@ -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()}
/>
<Show when={activeTab() === 'overview'}>
<ProxmoxOverview
model={model}
metricDisplayMode={metricDisplayMode}
setMetricDisplayMode={setMetricDisplayMode}
metricHistoryRange={metricHistoryRange}
setMetricHistoryRange={setMetricHistoryRange}
memoryDisplayBasis={memoryDisplayBasis}
setMemoryDisplayBasis={setMemoryDisplayBasis}
/>
<div class="space-y-4">
<PlatformEstateOverview platform="proxmox" resources={model().resources} />
<ProxmoxOverview
model={model}
metricDisplayMode={metricDisplayMode}
setMetricDisplayMode={setMetricDisplayMode}
metricHistoryRange={metricHistoryRange}
setMetricHistoryRange={setMetricHistoryRange}
memoryDisplayBasis={memoryDisplayBasis}
setMemoryDisplayBasis={setMemoryDisplayBasis}
/>
</div>
</Show>
<Show when={activeTab() === 'storage'}>
<StorageSurface
@@ -1,4 +1,5 @@
import { cleanup, render, screen } from '@solidjs/testing-library';
import { Route, Router } from '@solidjs/router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Resource } from '@/types/resource';
import { ProxmoxPageSurface } from '../ProxmoxPageSurface';
@@ -127,6 +128,13 @@ vi.mock('../ProxmoxReplicationTable', () => ({
fetchReplicationJobs: () => Promise.resolve([]),
}));
const renderSurface = () =>
render(() => (
<Router>
<Route path="/" component={ProxmoxPageSurface} />
</Router>
));
describe('ProxmoxPageSurface contract', () => {
beforeEach(() => {
mockPathname.mockReturnValue('/proxmox/overview');
@@ -159,7 +167,7 @@ describe('ProxmoxPageSurface contract', () => {
}),
]);
render(() => <ProxmoxPageSurface />);
renderSurface();
expect(screen.getByTestId('storage-surface')).toBeInTheDocument();
expect(mockStorageProps).toHaveBeenCalledWith(
@@ -183,7 +191,7 @@ describe('ProxmoxPageSurface contract', () => {
}),
]);
render(() => <ProxmoxPageSurface />);
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(() => <ProxmoxPageSurface />);
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(() => <ProxmoxPageSurface />);
renderSurface();
const totals = screen.getByTestId('proxmox-guest-totals');
expect(totals).not.toHaveTextContent('running');
@@ -280,7 +288,7 @@ describe('ProxmoxPageSurface contract', () => {
}),
]);
render(() => <ProxmoxPageSurface />);
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(() => <ProxmoxPageSurface />);
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(() => <ProxmoxPageSurface />);
renderSurface();
expect(screen.queryByTestId('platform-outdated-agent-notice')).not.toBeInTheDocument();
});
@@ -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() {
}
>
<div class="space-y-4">
<PlatformEstateOverview platform="standalone" resources={resources()} />
<HostIdentityConflictNotice hosts={identityConflictHosts()} />
<PlatformOutdatedAgentNotice
hosts={outdatedAgentHosts()}
@@ -2,6 +2,7 @@ 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,
@@ -132,7 +133,10 @@ export function TrueNASPageSurface() {
actionLabel="Open agent upgrade commands"
/>
<Show when={activeTab() === 'overview'}>
<TrueNASOverview model={model} />
<div class="space-y-4">
<PlatformEstateOverview platform="truenas" resources={model().resources} />
<TrueNASOverview model={model} />
</div>
</Show>
<Show when={activeTab() === 'storage'}>
<TrueNASStorage model={model} />
@@ -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"
/>
<Show when={activeTab() === 'overview'}>
<VmwareOverview
model={model}
metricDisplayMode={metricDisplayMode}
setMetricDisplayMode={setMetricDisplayMode}
metricHistoryRange={metricHistoryRange}
setMetricHistoryRange={setMetricHistoryRange}
inventorySourcesQuery={inventorySources}
/>
<div class="space-y-4">
<PlatformEstateOverview platform="vmware" resources={model().resources} />
<VmwareOverview
model={model}
metricDisplayMode={metricDisplayMode}
setMetricDisplayMode={setMetricDisplayMode}
metricHistoryRange={metricHistoryRange}
setMetricHistoryRange={setMetricHistoryRange}
inventorySourcesQuery={inventorySources}
/>
</div>
</Show>
<Show when={activeTab() === 'storage'}>
<VsphereDatastoresTable