From 07f97c3f58c08da7a18b3ac672d2a8e63fcfa5cb Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 16 May 2026 21:05:50 +0100 Subject: [PATCH] proxmox(nodes-table): metric bars, gauges, and badges for the canonical look MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass shipped the v5 layout but the table itself was raw cells (formatPercent text, plain digits). Match the canonical platform-page metric primitives so CPU / Memory / Disk render as actual bars with severity coloring, temperature uses the shared gauge, and status / VM / container / cluster columns read as proper badges. - CPU: ResponsiveMetricCell (animated bar + sparkline overlay, severity-aware text color). - Memory: StackedMemoryBar (handles totals + percentOnly fallback). - Disk: StackedDiskBar (per-disk segments when available, otherwise the aggregate disk row). - Temperature: TemperatureGauge (severity-colored °C). - Status: rounded-full pill via getStatusIndicatorBadgeToneClasses. - Version: mono pill on bg-surface-alt. - Uptime: warns in orange when < 1h (v5 "recently restarted" cue). - VMs / CTs counts: sky / violet pill badges, muted when zero. - Cluster: subtle chip on bg-surface-alt. Online-only metric cells fall back to a muted em-dash when the node is degraded or offline, matching how the canonical host table elsewhere on the platform pages handles non-running hosts. --- .../features/proxmox/ProxmoxNodesTable.tsx | 192 +++++++++++++----- 1 file changed, 144 insertions(+), 48 deletions(-) diff --git a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx index 13a5f3c4b..ee803f5ff 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx @@ -4,6 +4,10 @@ import { EmptyState } from '@/components/shared/EmptyState'; import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup'; import { SearchInput } from '@/components/shared/SearchInput'; import { StatusDot } from '@/components/shared/StatusDot'; +import { ResponsiveMetricCell } from '@/components/shared/responsive'; +import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar'; +import { StackedDiskBar } from '@/components/Workloads/StackedDiskBar'; +import { TemperatureGauge } from '@/components/shared/TemperatureGauge'; import { Table, TableBody, @@ -12,12 +16,15 @@ import { TableHeader, TableRow, } from '@/components/shared/Table'; -import { getSimpleStatusIndicator } from '@/utils/status'; +import { getSimpleStatusIndicator, getStatusIndicatorBadgeToneClasses } from '@/utils/status'; import { asTrimmedString } from '@/utils/stringUtils'; +import { normalizeDiskArray } from '@/utils/format'; +import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys'; import { filterPlatformResources, type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; +import type { Disk } from '@/types/api'; import type { Resource } from '@/types/resource'; import { getResourceClusterLabel, @@ -25,13 +32,11 @@ import { getResourceVersion, } from './proxmoxPageModel'; -// Proxmox Overview now mirrors the v5 layout: a dedicated hosts table sits -// above the canonical Workloads filter + guest table on /proxmox/overview, so -// operators can see node-level uptime / load / temperature without scrolling -// past every guest row. The Workloads filter still drives the guest table -// below; this table has its own narrow search + status filter the same way -// the sibling platform-host tables (Docker / K8s / TrueNAS / vSphere) do, so -// the page composes one canonical shape across the platform-first nav. +// Proxmox Overview mirrors the v5 Dashboard layout: a dedicated nodes table on +// top, the canonical Workloads filter + guest table below. The nodes table +// uses the canonical metric primitives (ResponsiveMetricCell / StackedMemoryBar +// / StackedDiskBar / TemperatureGauge) so the bars, severity coloring, and +// sparkline overlays match the rest of the platform-first surfaces. const STATUS_FILTER_OPTIONS: FilterOption[] = [ { value: 'all', label: 'All' }, @@ -40,24 +45,15 @@ const STATUS_FILTER_OPTIONS: FilterOption[] = [ { value: 'offline', label: 'Offline' }, ]; -const formatPercent = (percent?: number): JSX.Element => { - if (typeof percent !== 'number' || Number.isNaN(percent)) return ; - return {percent.toFixed(1)}%; -}; - -const formatUptime = (seconds: number | undefined): string => { - if (!seconds || seconds <= 0) return '—'; +const formatUptime = (seconds: number | undefined): { label: string; warn: boolean } => { + if (!seconds || seconds <= 0) return { label: '—', warn: false }; + const warn = seconds < 3_600; // <1h matches v5 "recently restarted" highlight const days = Math.floor(seconds / 86_400); - if (days > 0) return `${days}d`; + if (days > 0) return { label: `${days}d`, warn }; const hours = Math.floor(seconds / 3_600); - if (hours > 0) return `${hours}h`; + if (hours > 0) return { label: `${hours}h`, warn }; const mins = Math.floor(seconds / 60); - return `${mins}m`; -}; - -const formatTemperature = (celsius: number | undefined): JSX.Element => { - if (typeof celsius !== 'number' || celsius <= 0) return ; - return {celsius.toFixed(1)}°C; + return { label: `${mins}m`, warn }; }; type GuestCounts = { vms: number; containers: number }; @@ -74,6 +70,13 @@ const countGuestsForNode = (guests: Resource[], nodeName: string): GuestCounts = return counts; }; +const VMS_BADGE = + 'inline-flex min-w-[2rem] justify-center items-center rounded-md bg-sky-100 px-1.5 py-0.5 text-[11px] font-semibold tabular-nums text-sky-700 dark:bg-sky-900/40 dark:text-sky-300'; +const CTS_BADGE = + 'inline-flex min-w-[2rem] justify-center items-center rounded-md bg-violet-100 px-1.5 py-0.5 text-[11px] font-semibold tabular-nums text-violet-700 dark:bg-violet-900/40 dark:text-violet-300'; +const ZERO_BADGE = + 'inline-flex min-w-[2rem] justify-center items-center rounded-md bg-surface-alt px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-muted'; + export const ProxmoxNodesTable: Component<{ nodes: Resource[]; guests: Resource[]; @@ -131,18 +134,19 @@ export const ProxmoxNodesTable: Component<{ } > - +
Node + Status Version Uptime - CPU - Memory - Disk + CPU + Memory + Disk Temp - VMs - CTs + VMs + CTs Cluster @@ -150,10 +154,31 @@ export const ProxmoxNodesTable: Component<{ {(node) => { const name = () => asTrimmedString(node.name) || node.id; - const version = () => asTrimmedString(getResourceVersion(node)) || '—'; + const version = () => asTrimmedString(getResourceVersion(node)); const cluster = () => getResourceClusterLabel(node); - const counts = () => countGuestsForNode(props.guests, getResourceNodeName(node)); + const counts = () => + countGuestsForNode(props.guests, getResourceNodeName(node)); const indicator = () => getSimpleStatusIndicator(node.status); + const isOnline = () => indicator().variant === 'success'; + const uptime = () => formatUptime(node.uptime); + const metricsKey = () => buildMetricKeyForUnifiedResource(node); + const temperature = () => node.temperature; + const cpuPercent = () => node.cpu?.current ?? 0; + const memoryUsed = () => node.memory?.used ?? 0; + const memoryTotal = () => node.memory?.total ?? 0; + const memoryPercentOnly = () => + !memoryTotal() && typeof node.memory?.current === 'number' + ? node.memory.current + : undefined; + const aggregateDisk = (): Disk | undefined => + node.disk + ? ({ + total: node.disk.total ?? 0, + used: node.disk.used ?? 0, + free: node.disk.free ?? 0, + usage: node.disk.current ?? 0, + } as Disk) + : undefined; return ( @@ -169,31 +194,102 @@ export const ProxmoxNodesTable: Component<{ - - {version()} + + + {indicator().label} + - - {formatUptime(node.uptime)} + + —} + > + + {version()} + + - - {formatPercent(node.cpu?.current)} + + {uptime().label} - - {formatPercent(node.memory?.current)} + + - - {formatPercent(node.disk?.current)} + + 0 || memoryPercentOnly() != null)} + fallback={ +
+ +
+ } + > + +
- - {formatTemperature(node.temperature)} + + + + + } + > + + - - {counts().vms} + + 0} + fallback={} + > + + - - {counts().containers} + + 0 ? VMS_BADGE : ZERO_BADGE}> + {counts().vms} + + + + 0 ? CTS_BADGE : ZERO_BADGE}> + {counts().containers} + + + + + {cluster()} + - {cluster()}
); }}