mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
feat: add canonical platform estate overviews
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+129
@@ -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);
|
||||
});
|
||||
});
|
||||
+5
-2
@@ -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
|
||||
|
||||
+15
-7
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user