From 0a7bd5d49ec189569c6db6808c55e4db5d11eb70 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 22 May 2026 21:13:38 +0100 Subject: [PATCH] Align vSphere tables with shared platform conventions Strip the bespoke colored pill components (HealthPill, ToolsPill, StatusPill) from the vSphere VMs, Datastores, and Networks tables. Status now reads from the leading StatusDot in the name cell, the same way Proxmox, Docker, Kubernetes, and TrueNAS already convey it. The Tools and Health columns on VMs and the State column on Datastores and Networks are removed; the underlying data stays reachable through the row detail drawer. Drop the cluster-services inline subtitle on Hosts and VMs and the managedObjectId / datastoreUrl subtitles on the VMs, Datastores, and Networks name cells, so row density matches Proxmox and Docker. Move that information into the row title attribute. Replace the hand-rolled emerald progress bar in CapacityCell with the shared StackedDiskBar so datastore capacity colouring responds to fill thresholds rather than always rendering green. Promote powerStateVariant and formatPowerState into vmwarePageModel as getVmwarePowerStateVariant and formatVmwarePowerState; both Hosts and VMs tables now share them. Export platformChipStatusDot from sharedPlatformPage so vSphere filter chip definitions stop redeclaring the same helper, and add leading status dots to the Datastores, Networks, and Alerts chips that were missing them. --- .../platformPage/sharedPlatformPage.tsx | 6 +- .../features/vmware/VsphereActivityTable.tsx | 26 +- .../features/vmware/VsphereAlertsTable.tsx | 22 +- .../vmware/VsphereDatastoresTable.tsx | 181 ++++++------- .../src/features/vmware/VsphereHostsTable.tsx | 34 +-- .../features/vmware/VsphereNetworksTable.tsx | 93 +++---- .../vmware/VsphereVirtualMachinesTable.tsx | 251 ++++-------------- .../__tests__/VsphereDatastoresTable.test.tsx | 10 +- .../__tests__/VsphereNetworksTable.test.tsx | 3 +- .../VsphereVirtualMachinesTable.test.tsx | 6 +- .../src/features/vmware/vmwarePageModel.ts | 23 ++ 11 files changed, 240 insertions(+), 415 deletions(-) diff --git a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx index 2038349832..309a170eb0 100644 --- a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx +++ b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx @@ -138,7 +138,11 @@ export function PlatformErrorState(props: { // 'running' vs 'online', 'stopped' vs 'offline') collapse to one chip set. export type PlatformResourceStatusFilter = 'all' | 'online' | 'degraded' | 'offline'; -const statusDot = (className: string) => ; +export const platformChipStatusDot = (className: string) => ( + +); + +const statusDot = platformChipStatusDot; export const PLATFORM_STATUS_FILTER_OPTIONS: PlatformTableFilterOption[] = [ diff --git a/frontend-modern/src/features/vmware/VsphereActivityTable.tsx b/frontend-modern/src/features/vmware/VsphereActivityTable.tsx index f0ec4c46b9..aad912ad79 100644 --- a/frontend-modern/src/features/vmware/VsphereActivityTable.tsx +++ b/frontend-modern/src/features/vmware/VsphereActivityTable.tsx @@ -20,6 +20,7 @@ import { createPlatformTableFilterState, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, + platformChipStatusDot, type PlatformTableFilterOption, } from '@/features/platformPage/sharedPlatformPage'; import { @@ -38,15 +39,26 @@ import { const VSPHERE_ACTIVITY_STATUS_OPTIONS: PlatformTableFilterOption[] = [ { value: 'all', label: 'All' }, - { value: 'tasks', label: 'Tasks', tone: 'success', leading: statusDot('bg-emerald-500') }, - { value: 'events', label: 'Events', tone: 'info', leading: statusDot('bg-blue-500') }, - { value: 'failed', label: 'Failed', tone: 'danger', leading: statusDot('bg-red-500') }, + { + value: 'tasks', + label: 'Tasks', + tone: 'success', + leading: platformChipStatusDot('bg-emerald-500'), + }, + { + value: 'events', + label: 'Events', + tone: 'info', + leading: platformChipStatusDot('bg-blue-500'), + }, + { + value: 'failed', + label: 'Failed', + tone: 'danger', + leading: platformChipStatusDot('bg-red-500'), + }, ]; -function statusDot(className: string): JSX.Element { - return ; -} - const formatResourceType = (type: ResourceType): string => { switch (type) { case 'agent': diff --git a/frontend-modern/src/features/vmware/VsphereAlertsTable.tsx b/frontend-modern/src/features/vmware/VsphereAlertsTable.tsx index 673bc2a1b2..5b30a15e1a 100644 --- a/frontend-modern/src/features/vmware/VsphereAlertsTable.tsx +++ b/frontend-modern/src/features/vmware/VsphereAlertsTable.tsx @@ -20,6 +20,7 @@ import { createPlatformTableFilterState, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, + platformChipStatusDot, type PlatformTableFilterOption, } from '@/features/platformPage/sharedPlatformPage'; import { @@ -37,9 +38,24 @@ import { const VSPHERE_INCIDENT_STATUS_OPTIONS: PlatformTableFilterOption[] = [ { value: 'all', label: 'All' }, - { value: 'critical', label: 'Critical', tone: 'danger' }, - { value: 'warning', label: 'Warning', tone: 'warning' }, - { value: 'info', label: 'Info', tone: 'success' }, + { + value: 'critical', + label: 'Critical', + tone: 'danger', + leading: platformChipStatusDot('bg-red-500'), + }, + { + value: 'warning', + label: 'Warning', + tone: 'warning', + leading: platformChipStatusDot('bg-amber-500'), + }, + { + value: 'info', + label: 'Info', + tone: 'success', + leading: platformChipStatusDot('bg-emerald-500'), + }, ]; const severityVariant = (severity: VmwareIncidentRow['severityBucket']): StatusIndicatorVariant => { diff --git a/frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx b/frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx index 8133de7177..d23135821a 100644 --- a/frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx +++ b/frontend-modern/src/features/vmware/VsphereDatastoresTable.tsx @@ -2,6 +2,7 @@ import { For, Show, createMemo, type Component, type JSX } from 'solid-js'; import { StatusDot } from '@/components/shared/StatusDot'; import { TableCard } from '@/components/shared/TableCard'; import { TableCardHeader } from '@/components/shared/TableCardHeader'; +import { StackedDiskBar } from '@/components/Workloads/StackedDiskBar'; import { Table, TableBody, @@ -10,7 +11,6 @@ import { TableHeader, TableRow, } from '@/components/shared/Table'; -import { formatBytes, formatPercent } from '@/utils/format'; import { getSimpleStatusIndicator } from '@/utils/status'; import { asTrimmedString } from '@/utils/stringUtils'; import { @@ -22,6 +22,7 @@ import { createPlatformTableFilterState, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, + platformChipStatusDot, type PlatformTableFilterOption, } from '@/features/platformPage/sharedPlatformPage'; import { @@ -33,16 +34,35 @@ import { import type { Resource } from '@/types/resource'; import { filterVmwareDatastores, - mapVmwareDatastoreStatus, type VmwareDatastoreStatusFilter, } from './vmwarePageModel'; const VSPHERE_DATASTORE_STATUS_OPTIONS: PlatformTableFilterOption[] = [ { value: 'all', label: 'All' }, - { value: 'accessible', label: 'Accessible', tone: 'success' }, - { value: 'attention', label: 'Attention', tone: 'warning' }, - { value: 'inaccessible', label: 'Inaccessible', tone: 'danger' }, - { value: 'maintenance', label: 'Maintenance', tone: 'warning' }, + { + value: 'accessible', + label: 'Accessible', + tone: 'success', + leading: platformChipStatusDot('bg-emerald-500'), + }, + { + value: 'attention', + label: 'Attention', + tone: 'warning', + leading: platformChipStatusDot('bg-amber-500'), + }, + { + value: 'inaccessible', + label: 'Inaccessible', + tone: 'danger', + leading: platformChipStatusDot('bg-red-500'), + }, + { + value: 'maintenance', + label: 'Maintenance', + tone: 'warning', + leading: platformChipStatusDot('bg-amber-500'), + }, { value: 'unknown', label: 'Unknown' }, ]; @@ -52,14 +72,14 @@ const datastoreName = (resource: Resource): string => const datastoreType = (resource: Resource): string => asTrimmedString(resource.vmware?.datastoreType) || asTrimmedString(resource.storage?.type)?.toUpperCase() || - '-'; + '—'; const compactList = (values: Array): string[] => values.map((value) => asTrimmedString(value)).filter((value): value is string => Boolean(value)); const summarizeValues = ( values: string[], - empty = '-', + empty = '—', visibleCount = 2, ): { label: string; title: string } => { if (values.length === 0) return { label: empty, title: '' }; @@ -69,95 +89,40 @@ const summarizeValues = ( }; const hostSummary = (resource: Resource): { label: string; title: string } => - summarizeValues(compactList(resource.storage?.nodes ?? []), '-', 2); + summarizeValues(compactList(resource.storage?.nodes ?? []), '—', 2); const consumerSummary = (resource: Resource): { label: string; title: string } => { const consumers = resource.storage?.topConsumers?.map((consumer) => consumer.name) ?? []; - return summarizeValues(compactList(consumers), '-', 2); + return summarizeValues(compactList(consumers), '—', 2); }; const consumerCount = (resource: Resource): number => resource.storage?.consumerCount ?? 0; -const capacityPercent = (resource: Resource): number | undefined => { +const capacityDisk = (resource: Resource) => { + const total = typeof resource.disk?.total === 'number' ? resource.disk.total : 0; + const used = typeof resource.disk?.used === 'number' ? resource.disk.used : 0; + const free = + typeof resource.disk?.free === 'number' ? resource.disk.free : Math.max(0, total - used); + const usage = + typeof resource.disk?.current === 'number' && Number.isFinite(resource.disk.current) + ? resource.disk.current + : total > 0 + ? (used / total) * 100 + : 0; + return { total, used, free, usage }; +}; + +const hasCapacityMetric = (resource: Resource): boolean => { if (typeof resource.disk?.current === 'number' && Number.isFinite(resource.disk.current)) { - return resource.disk.current; + return true; } - if ( + return ( typeof resource.disk?.used === 'number' && typeof resource.disk?.total === 'number' && resource.disk.total > 0 - ) { - return (resource.disk.used / resource.disk.total) * 100; - } - return undefined; -}; - -const capacityLabel = (resource: Resource): string => { - if (typeof resource.disk?.used === 'number' && typeof resource.disk?.total === 'number') { - return `${formatBytes(resource.disk.used)} / ${formatBytes(resource.disk.total)}`; - } - const percent = capacityPercent(resource); - return percent === undefined ? '-' : formatPercent(percent); -}; - -const CapacityCell: Component<{ resource: Resource }> = (props) => { - const percent = () => capacityPercent(props.resource); - return ( -
-
- {capacityLabel(props.resource)} -
- -
-
-
- -
); }; -const statusLabel = (resource: Resource): string => { - switch (mapVmwareDatastoreStatus(resource)) { - case 'accessible': - return 'Accessible'; - case 'attention': - return 'Attention'; - case 'inaccessible': - return 'Inaccessible'; - case 'maintenance': - return 'Maintenance'; - case 'unknown': - return 'Unknown'; - } -}; - -const statusPillClass = (resource: Resource): string => { - switch (mapVmwareDatastoreStatus(resource)) { - case 'accessible': - return 'border-emerald-300/50 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'; - case 'attention': - case 'maintenance': - return 'border-amber-300/50 bg-amber-500/10 text-amber-700 dark:text-amber-300'; - case 'inaccessible': - return 'border-red-300/50 bg-red-500/10 text-red-700 dark:text-red-300'; - case 'unknown': - return 'border-border bg-surface-alt text-muted'; - } -}; - -const StatusPill: Component<{ resource: Resource }> = (props) => ( - - {statusLabel(props.resource)} - -); - export const VsphereDatastoresTable: Component<{ datastores: Resource[]; scope: Resource[]; @@ -244,9 +209,6 @@ export const VsphereDatastoresTable: Component<{ > Datacenter - - State - @@ -257,7 +219,16 @@ export const VsphereDatastoresTable: Component<{ const indicator = () => getSimpleStatusIndicator(datastore.status); const name = () => datastoreName(datastore); const datacenter = () => - asTrimmedString(datastore.vmware?.datacenterName) || '-'; + asTrimmedString(datastore.vmware?.datacenterName) || '—'; + const datastoreSubtitle = () => + asTrimmedString(datastore.vmware?.datastoreUrl) || + asTrimmedString(datastore.vmware?.folderName) || + asTrimmedString(datastore.vmware?.vcenterHost) || + ''; + const datastoreTitle = () => + [name(), datastoreSubtitle()].filter(Boolean).join(' · ') || name(); + const disk = createMemo(() => capacityDisk(datastore)); + const showCapacity = () => hasCapacityMetric(datastore); const detailRowId = () => drawer.detailRowId(datastore); const isExpanded = () => drawer.isExpanded(datastore); return ( @@ -278,20 +249,12 @@ export const VsphereDatastoresTable: Component<{ variant={indicator().variant} title={indicator().label} /> -
-
- {name()} -
-
- {datastore.vmware?.datastoreUrl || - datastore.vmware?.folderName || - datastore.vmware?.vcenterHost || - 'vSphere datastore'} -
-
+ + {name()} +
@@ -300,7 +263,18 @@ export const VsphereDatastoresTable: Component<{
- + + + + } + > + + - - - drawer.close(datastore)} /> diff --git a/frontend-modern/src/features/vmware/VsphereHostsTable.tsx b/frontend-modern/src/features/vmware/VsphereHostsTable.tsx index 282b178b3c..90f583d657 100644 --- a/frontend-modern/src/features/vmware/VsphereHostsTable.tsx +++ b/frontend-modern/src/features/vmware/VsphereHostsTable.tsx @@ -15,6 +15,10 @@ import { import { getSimpleStatusIndicator } from '@/utils/status'; import { asTrimmedString } from '@/utils/stringUtils'; import { formatVmwareClusterServices } from '@/utils/vmwareDisplay'; +import { + formatVmwarePowerState, + getVmwarePowerStateVariant, +} from './vmwarePageModel'; import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys'; import { PLATFORM_TABLE_BODY_CLASS, @@ -60,25 +64,6 @@ const metricFallback = () => ( ); -const powerStateVariant = ( - state: string | undefined, -): 'success' | 'warning' | 'danger' | 'muted' => { - const normalized = (state || '').trim().toUpperCase(); - if (normalized === 'POWERED_ON') return 'success'; - if (normalized === 'POWERED_OFF') return 'muted'; - if (normalized === 'SUSPENDED') return 'warning'; - return 'muted'; -}; - -const formatPowerState = (state: string | undefined): string => { - const normalized = (state || '').trim(); - if (!normalized) return '—'; - return normalized - .split('_') - .map((part) => part.charAt(0) + part.slice(1).toLowerCase()) - .join(' '); -}; - export const VsphereHostsTable: Component<{ hosts: Resource[]; // Full vSphere scope so we can count VMs per host without spawning @@ -254,14 +239,9 @@ export const VsphereHostsTable: Component<{ diff --git a/frontend-modern/src/features/vmware/VsphereNetworksTable.tsx b/frontend-modern/src/features/vmware/VsphereNetworksTable.tsx index e9214b65d0..9b84a2c7a8 100644 --- a/frontend-modern/src/features/vmware/VsphereNetworksTable.tsx +++ b/frontend-modern/src/features/vmware/VsphereNetworksTable.tsx @@ -21,6 +21,7 @@ import { createPlatformTableFilterState, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, + platformChipStatusDot, type PlatformTableFilterOption, } from '@/features/platformPage/sharedPlatformPage'; import { @@ -32,14 +33,23 @@ import { import type { Resource } from '@/types/resource'; import { filterVmwareNetworks, - mapVmwareNetworkStatus, type VmwareNetworkStatusFilter, } from './vmwarePageModel'; const VSPHERE_NETWORK_STATUS_OPTIONS: PlatformTableFilterOption[] = [ { value: 'all', label: 'All' }, - { value: 'healthy', label: 'Healthy', tone: 'success' }, - { value: 'attention', label: 'Attention', tone: 'warning' }, + { + value: 'healthy', + label: 'Healthy', + tone: 'success', + leading: platformChipStatusDot('bg-emerald-500'), + }, + { + value: 'attention', + label: 'Attention', + tone: 'warning', + leading: platformChipStatusDot('bg-amber-500'), + }, { value: 'unknown', label: 'Unknown' }, ]; @@ -47,14 +57,14 @@ const networkName = (resource: Resource): string => asTrimmedString(resource.displayName) || asTrimmedString(resource.name) || resource.id; const networkType = (resource: Resource): string => - asTrimmedString(resource.vmware?.networkType) || '-'; + asTrimmedString(resource.vmware?.networkType) || '—'; const compactList = (values: Array): string[] => values.map((value) => asTrimmedString(value)).filter((value): value is string => Boolean(value)); const summarizeValues = ( values: string[], - empty = '-', + empty = '—', visibleCount = 2, ): { label: string; title: string } => { if (values.length === 0) return { label: empty, title: '' }; @@ -64,46 +74,14 @@ const summarizeValues = ( }; const hostSummary = (resource: Resource): { label: string; title: string } => - summarizeValues(compactList(resource.vmware?.networkHostNames ?? []), '-', 2); + summarizeValues(compactList(resource.vmware?.networkHostNames ?? []), '—', 2); const vmSummary = (resource: Resource): { label: string; title: string } => - summarizeValues(compactList(resource.vmware?.networkVmNames ?? []), '-', 2); + summarizeValues(compactList(resource.vmware?.networkVmNames ?? []), '—', 2); const vmCount = (resource: Resource): number => resource.vmware?.networkVmNames?.length ?? resource.vmware?.networkVmIds?.length ?? 0; -const statusLabel = (resource: Resource): string => { - switch (mapVmwareNetworkStatus(resource)) { - case 'healthy': - return 'Healthy'; - case 'attention': - return 'Attention'; - case 'unknown': - return 'Unknown'; - } -}; - -const statusPillClass = (resource: Resource): string => { - switch (mapVmwareNetworkStatus(resource)) { - case 'healthy': - return 'border-emerald-300/50 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'; - case 'attention': - return 'border-amber-300/50 bg-amber-500/10 text-amber-700 dark:text-amber-300'; - case 'unknown': - return 'border-border bg-surface-alt text-muted'; - } -}; - -const StatusPill: Component<{ resource: Resource }> = (props) => ( - - {statusLabel(props.resource)} - -); - export const VsphereNetworksTable: Component<{ networks: Resource[]; scope: Resource[]; @@ -187,9 +165,6 @@ export const VsphereNetworksTable: Component<{ > Datacenter - - State - @@ -199,7 +174,14 @@ export const VsphereNetworksTable: Component<{ const vms = createMemo(() => vmSummary(network)); const indicator = () => getSimpleStatusIndicator(network.status); const name = () => networkName(network); - const datacenter = () => asTrimmedString(network.vmware?.datacenterName) || '-'; + const datacenter = () => asTrimmedString(network.vmware?.datacenterName) || '—'; + const networkSubtitle = () => + asTrimmedString(network.vmware?.managedObjectId) || + asTrimmedString(network.vmware?.folderName) || + asTrimmedString(network.vmware?.vcenterHost) || + ''; + const networkTitle = () => + [name(), networkSubtitle()].filter(Boolean).join(' · ') || name(); const detailRowId = () => drawer.detailRowId(network); const isExpanded = () => drawer.isExpanded(network); return ( @@ -220,20 +202,12 @@ export const VsphereNetworksTable: Component<{ variant={indicator().variant} title={indicator().label} /> -
-
- {name()} -
-
- {network.vmware?.managedObjectId || - network.vmware?.folderName || - network.vmware?.vcenterHost || - 'vSphere network'} -
-
+ + {name()} + @@ -264,15 +238,12 @@ export const VsphereNetworksTable: Component<{ > {datacenter()} - - - drawer.close(network)} /> diff --git a/frontend-modern/src/features/vmware/VsphereVirtualMachinesTable.tsx b/frontend-modern/src/features/vmware/VsphereVirtualMachinesTable.tsx index 5d3cbe29b1..5c990a6406 100644 --- a/frontend-modern/src/features/vmware/VsphereVirtualMachinesTable.tsx +++ b/frontend-modern/src/features/vmware/VsphereVirtualMachinesTable.tsx @@ -26,6 +26,7 @@ import { createPlatformTableFilterState, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, + platformChipStatusDot, type PlatformTableFilterOption, } from '@/features/platformPage/sharedPlatformPage'; import { @@ -38,12 +39,12 @@ import type { Resource, ResourceVMwareNetworkAdapter, ResourceVMwareSnapshot, - ResourceVMwareTools, ResourceVMwareVirtualDisk, } from '@/types/resource'; import { filterVmwareVirtualMachines, - mapVmwareVirtualMachineStatus, + formatVmwarePowerState, + getVmwarePowerStateVariant, type VmwareVirtualMachineStatusFilter, } from './vmwarePageModel'; @@ -53,11 +54,26 @@ const VSPHERE_VM_STATUS_OPTIONS: PlatformTableFilterOption typeof value === 'number' && Number.isFinite(value) ? value : undefined; -function statusDot(className: string): JSX.Element { - return ; -} - const metricFallback = () => (
); -const normalizeToken = (value: string | undefined): string => - (value || '') - .trim() - .toLowerCase() - .replace(/[\s_-]/g, ''); - const vmName = (resource: Resource): string => asTrimmedString(resource.displayName) || asTrimmedString(resource.name) || resource.id; -const formatVmwarePowerState = (value: string | undefined): string => { - const normalized = normalizeToken(value); - if (normalized === 'poweredon' || normalized === 'on') return 'On'; - if (normalized === 'poweredoff' || normalized === 'off') return 'Off'; - if (normalized === 'suspended') return 'Suspended'; - return asTrimmedString(value) || 'Unknown'; -}; - -const powerStateVariant = ( - state: string | undefined, -): 'success' | 'warning' | 'danger' | 'muted' => { - const normalized = normalizeToken(state); - if (normalized === 'poweredon' || normalized === 'on') return 'success'; - if (normalized === 'poweredoff' || normalized === 'off') return 'danger'; - if (normalized === 'suspended') return 'warning'; - return 'muted'; -}; - const summarizeValues = ( values: Array, - empty = '-', + empty = '—', visibleCount = 2, ): { label: string; title: string } => { const compact = values @@ -167,7 +155,7 @@ const networkSummary = ( adapters: ResourceVMwareNetworkAdapter[] | undefined, ): { label: string; title: string } => { const names = (adapters ?? []).map(networkAdapterName).filter(Boolean); - const summary = summarizeValues(names, '-', 1); + const summary = summarizeValues(names, '—', 1); const title = (adapters ?? []).map(networkAdapterTitle).filter(Boolean).join(' | '); return { label: summary.label, title: title || summary.title }; }; @@ -210,134 +198,12 @@ const formatGuest = (resource: Resource): { label: string; detail: string; title const host = asTrimmedString(resource.vmware?.guestHostname); const ips = summarizeValues(resource.vmware?.guestIpAddresses ?? [], '', 1); return { - label: family || host || ips.label || '-', + label: family || host || ips.label || '—', detail: host || ips.label, title: [family, host, ips.title].filter(Boolean).join(' | '), }; }; -const healthLabel = (resource: Resource): string => { - const alarms = resource.vmware?.activeAlarmCount ?? 0; - if (alarms > 0) return `${alarms} alarm${alarms === 1 ? '' : 's'}`; - const overall = asTrimmedString(resource.vmware?.overallStatus); - if (!overall) return 'Unknown'; - if (overall.toLowerCase() === 'green') return 'Healthy'; - return overall.charAt(0).toUpperCase() + overall.slice(1).toLowerCase(); -}; - -const healthPillClass = (resource: Resource): string => { - const status = mapVmwareVirtualMachineStatus(resource); - if (status === 'attention') { - const overall = asTrimmedString(resource.vmware?.overallStatus)?.toLowerCase(); - if (overall === 'red') return 'border-red-300/50 bg-red-500/10 text-red-700 dark:text-red-300'; - return 'border-amber-300/50 bg-amber-500/10 text-amber-700 dark:text-amber-300'; - } - if (status === 'powered-on') { - return 'border-emerald-300/50 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'; - } - if (status === 'powered-off') { - return 'border-border bg-surface-alt text-muted'; - } - return 'border-border bg-surface-alt text-muted'; -}; - -const HealthPill: Component<{ resource: Resource }> = (props) => ( - - {healthLabel(props.resource)} - -); - -const VMWARE_TOKEN_ACRONYMS: Record = { - API: 'API', - OS: 'OS', - VM: 'VM', - VMDK: 'VMDK', -}; - -const titleizeToken = (value: string | undefined): string => - (value || '') - .split(/[\s_-]+/) - .filter(Boolean) - .map((part) => { - const upper = part.toUpperCase(); - return ( - VMWARE_TOKEN_ACRONYMS[upper] ?? part.charAt(0).toUpperCase() + part.slice(1).toLowerCase() - ); - }) - .join(' '); - -const toolsTitle = (tools: ResourceVMwareTools | undefined): string => - tools - ? [ - tools.runState ? `Run state: ${titleizeToken(tools.runState)}` : '', - tools.versionStatus ? `Version status: ${titleizeToken(tools.versionStatus)}` : '', - asTrimmedString(tools.version), - tools.installType ? titleizeToken(tools.installType) : '', - tools.upgradePolicy ? `Policy: ${titleizeToken(tools.upgradePolicy)}` : '', - tools.guestRebootRequested ? 'Guest reboot requested' : '', - ] - .filter(Boolean) - .join(' | ') - : ''; - -const toolsNeedsAttention = (tools: ResourceVMwareTools | undefined): boolean => { - const runState = normalizeToken(tools?.runState); - const versionStatus = normalizeToken(tools?.versionStatus); - return Boolean( - tools?.guestRebootRequested || - (runState && !['running', 'started'].includes(runState)) || - [ - 'notinstalled', - 'tooold', - 'toonew', - 'old', - 'blacklisted', - 'unsupported', - 'supportedold', - ].includes(versionStatus), - ); -}; - -const toolsLabel = (tools: ResourceVMwareTools | undefined): string => { - if (!tools) return 'Unknown'; - if (tools.guestRebootRequested) return 'Reboot'; - const runState = normalizeToken(tools.runState); - const versionStatus = normalizeToken(tools.versionStatus); - if (['notinstalled'].includes(versionStatus)) return 'Missing'; - if (['tooold', 'old', 'supportedold'].includes(versionStatus)) return 'Old'; - if (runState && !['running', 'started'].includes(runState)) return titleizeToken(tools.runState); - if (versionStatus && !['current', 'ok'].includes(versionStatus)) { - return titleizeToken(tools.versionStatus); - } - if (runState) return titleizeToken(tools.runState); - return 'Unknown'; -}; - -const toolsPillClass = (tools: ResourceVMwareTools | undefined): string => { - if (toolsNeedsAttention(tools)) { - return 'border-amber-300/50 bg-amber-500/10 text-amber-700 dark:text-amber-300'; - } - if (normalizeToken(tools?.runState) === 'running') { - return 'border-emerald-300/50 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'; - } - return 'border-border bg-surface-alt text-muted'; -}; - -const ToolsPill: Component<{ tools: ResourceVMwareTools | undefined }> = (props) => ( - - {toolsLabel(props.tools)} - -); - const vmGroupKey = (resource: Resource): string => asTrimmedString(resource.vmware?.runtimeHostName) || asTrimmedString(resource.parentName) || @@ -421,7 +287,7 @@ export const VsphereVirtualMachinesTable: Component<{ > - +
@@ -476,12 +342,6 @@ export const VsphereVirtualMachinesTable: Component<{ > Snapshots - - Tools - - - Health - @@ -489,7 +349,7 @@ export const VsphereVirtualMachinesTable: Component<{ {(group) => ( <> - + {group.label} @@ -507,17 +367,17 @@ export const VsphereVirtualMachinesTable: Component<{ const host = () => asTrimmedString(meta()?.runtimeHostName) || asTrimmedString(vm.parentName) || - '-'; + '—'; const cluster = () => asTrimmedString(meta()?.clusterName) || asTrimmedString(meta()?.computeResourceName) || - '-'; + '—'; const clusterServices = () => formatVmwareClusterServices(meta()); - const pool = () => asTrimmedString(meta()?.resourcePoolName) || '-'; + const pool = () => asTrimmedString(meta()?.resourcePoolName) || '—'; const guest = createMemo(() => formatGuest(vm)); const network = createMemo(() => networkSummary(meta()?.networkAdapters)); const datastores = createMemo(() => - summarizeValues(meta()?.datastoreNames ?? [], '-', 1), + summarizeValues(meta()?.datastoreNames ?? [], '—', 1), ); const disks = () => virtualDiskCount(meta()?.virtualDisks); const diskTitle = () => virtualDiskSummaryTitle(meta()?.virtualDisks); @@ -562,29 +422,27 @@ export const VsphereVirtualMachinesTable: Component<{ variant={indicator().variant} title={indicator().label} /> -
-
- {name()} -
-
- {meta()?.managedObjectId || - meta()?.instanceUuid || - 'vSphere VM'} -
-
+ + {name()} +
@@ -601,14 +459,9 @@ export const VsphereVirtualMachinesTable: Component<{ - - - - - - drawer.close(vm)} /> diff --git a/frontend-modern/src/features/vmware/__tests__/VsphereDatastoresTable.test.tsx b/frontend-modern/src/features/vmware/__tests__/VsphereDatastoresTable.test.tsx index fd68d343d5..017a79f70d 100644 --- a/frontend-modern/src/features/vmware/__tests__/VsphereDatastoresTable.test.tsx +++ b/frontend-modern/src/features/vmware/__tests__/VsphereDatastoresTable.test.tsx @@ -1,5 +1,9 @@ import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/components/Workloads/StackedDiskBar', () => ({ + StackedDiskBar: () =>
, +})); import { VsphereDatastoresTable } from '@/features/vmware/VsphereDatastoresTable'; import type { Resource } from '@/types/resource'; @@ -75,10 +79,10 @@ describe('VsphereDatastoresTable', () => { expect(within(table).getByText('VMs')).toBeInTheDocument(); expect(within(table).queryByText('Protection')).not.toBeInTheDocument(); expect(within(table).queryByText('Growth (24h)')).not.toBeInTheDocument(); + expect(within(table).queryByRole('columnheader', { name: 'State' })).not.toBeInTheDocument(); expect(screen.getAllByText('esxi-01.lab.local, esxi-02.lab.local')).toHaveLength(2); expect(screen.getAllByText('warehouse-api-01, etl-batch-01')).toHaveLength(2); - expect(screen.getByText('Accessible')).toBeInTheDocument(); - expect(screen.getByText('Inaccessible')).toBeInTheDocument(); + expect(screen.getAllByTestId('stacked-disk-bar').length).toBeGreaterThan(0); const row = screen.getByText('nvme-primary').closest('tr'); expect(row).toHaveAttribute('aria-expanded', 'false'); diff --git a/frontend-modern/src/features/vmware/__tests__/VsphereNetworksTable.test.tsx b/frontend-modern/src/features/vmware/__tests__/VsphereNetworksTable.test.tsx index 5ae51eec85..784e757c98 100644 --- a/frontend-modern/src/features/vmware/__tests__/VsphereNetworksTable.test.tsx +++ b/frontend-modern/src/features/vmware/__tests__/VsphereNetworksTable.test.tsx @@ -65,12 +65,11 @@ describe('VsphereNetworksTable', () => { expect(within(table).getByText('Type')).toBeInTheDocument(); expect(within(table).getByText('Hosts')).toBeInTheDocument(); expect(within(table).getByText('Connected VMs')).toBeInTheDocument(); + expect(within(table).queryByRole('columnheader', { name: 'State' })).not.toBeInTheDocument(); expect(screen.getByText('STANDARD_PORTGROUP')).toBeInTheDocument(); expect(screen.getByText('DISTRIBUTED_PORTGROUP')).toBeInTheDocument(); expect(screen.getByText('esxi-01.lab.local, esxi-02.lab.local')).toBeInTheDocument(); expect(screen.getByText('warehouse-api-01, etl-batch-01')).toBeInTheDocument(); - expect(screen.getByText('Healthy')).toBeInTheDocument(); - expect(screen.getByText('Attention')).toBeInTheDocument(); const row = screen.getByText('VM Network').closest('tr'); expect(row).toHaveAttribute('aria-expanded', 'false'); diff --git a/frontend-modern/src/features/vmware/__tests__/VsphereVirtualMachinesTable.test.tsx b/frontend-modern/src/features/vmware/__tests__/VsphereVirtualMachinesTable.test.tsx index c8ee41ef6c..ddc5a67c84 100644 --- a/frontend-modern/src/features/vmware/__tests__/VsphereVirtualMachinesTable.test.tsx +++ b/frontend-modern/src/features/vmware/__tests__/VsphereVirtualMachinesTable.test.tsx @@ -121,8 +121,8 @@ describe('VsphereVirtualMachinesTable', () => { expect(within(table).getByText('Network')).toBeInTheDocument(); expect(within(table).getByText('Disks')).toBeInTheDocument(); expect(within(table).getByText('Snapshots')).toBeInTheDocument(); - expect(within(table).getByText('Tools')).toBeInTheDocument(); - expect(within(table).getByText('Health')).toBeInTheDocument(); + expect(within(table).queryByRole('columnheader', { name: 'Tools' })).not.toBeInTheDocument(); + expect(within(table).queryByRole('columnheader', { name: 'Health' })).not.toBeInTheDocument(); expect(within(table).queryByRole('columnheader', { name: 'ID' })).not.toBeInTheDocument(); expect(within(table).queryByRole('columnheader', { name: 'Uptime' })).not.toBeInTheDocument(); expect(within(table).queryByRole('columnheader', { name: 'Backup' })).not.toBeInTheDocument(); @@ -134,8 +134,6 @@ describe('VsphereVirtualMachinesTable', () => { expect(screen.getByTitle(/Hard disk 1 · SCSI 0:1 · 100 GB · nvme-primary/)).toHaveTextContent( '1', ); - expect(screen.getByTitle(/Run state: Running/)).toHaveTextContent('Running'); - expect(screen.getByText('Healthy')).toBeInTheDocument(); expect(screen.getByTitle('pre-upgrade, post-upgrade (current)')).toHaveTextContent('2'); const row = screen.getByText('warehouse-api-01').closest('tr'); diff --git a/frontend-modern/src/features/vmware/vmwarePageModel.ts b/frontend-modern/src/features/vmware/vmwarePageModel.ts index ebbd6f1273..8b3998ff71 100644 --- a/frontend-modern/src/features/vmware/vmwarePageModel.ts +++ b/frontend-modern/src/features/vmware/vmwarePageModel.ts @@ -283,6 +283,29 @@ export function mapVmwareIncidentSeverity( return 'info'; } +export function normalizeVmwarePowerStateToken(value: string | undefined): string { + return (value || '').trim().toLowerCase().replace(/[\s_-]/g, ''); +} + +export function formatVmwarePowerState(value: string | undefined): string { + const normalized = normalizeVmwarePowerStateToken(value); + if (normalized === 'poweredon' || normalized === 'on') return 'On'; + if (normalized === 'poweredoff' || normalized === 'off') return 'Off'; + if (normalized === 'suspended') return 'Suspended'; + const trimmed = (value || '').trim(); + return trimmed || 'Unknown'; +} + +export function getVmwarePowerStateVariant( + state: string | undefined, +): 'success' | 'warning' | 'danger' | 'muted' { + const normalized = normalizeVmwarePowerStateToken(state); + if (normalized === 'poweredon' || normalized === 'on') return 'success'; + if (normalized === 'poweredoff' || normalized === 'off') return 'danger'; + if (normalized === 'suspended') return 'warning'; + return 'muted'; +} + export function mapVmwareVirtualMachineStatus( resource: Resource, ): Exclude {