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 {