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.
This commit is contained in:
rcourtman
2026-05-22 21:13:38 +01:00
parent 5016cbc2ba
commit 0a7bd5d49e
11 changed files with 240 additions and 415 deletions
@@ -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) => <span class={`h-2 w-2 rounded-full ${className}`} />;
export const platformChipStatusDot = (className: string) => (
<span class={`h-2 w-2 rounded-full ${className}`} />
);
const statusDot = platformChipStatusDot;
export const PLATFORM_STATUS_FILTER_OPTIONS: PlatformTableFilterOption<PlatformResourceStatusFilter>[] =
[
@@ -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<VmwareActivityStatusFilter>[] = [
{ 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 <span class={`h-2 w-2 rounded-full ${className}`} />;
}
const formatResourceType = (type: ResourceType): string => {
switch (type) {
case 'agent':
@@ -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<VmwareIncidentSeverityFilter>[] = [
{ 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 => {
@@ -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<VmwareDatastoreStatusFilter>[] = [
{ 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 | undefined>): 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 (
<div class="min-w-0">
<div class="truncate text-base-content" title={capacityLabel(props.resource)}>
{capacityLabel(props.resource)}
</div>
<Show when={percent() !== undefined}>
<div class="mt-1 h-1.5 overflow-hidden rounded bg-surface-alt">
<div
class="h-full rounded bg-emerald-500"
style={{ width: `${Math.max(0, Math.min(100, percent() ?? 0))}%` }}
/>
</div>
</Show>
</div>
);
};
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) => (
<span
class={`inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium ${statusPillClass(
props.resource,
)}`}
>
{statusLabel(props.resource)}
</span>
);
export const VsphereDatastoresTable: Component<{
datastores: Resource[];
scope: Resource[];
@@ -244,9 +209,6 @@ export const VsphereDatastoresTable: Component<{
>
Datacenter
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[8%]`}>
State
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
@@ -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}
/>
<div class="min-w-0">
<div class="truncate font-medium text-base-content" title={name()}>
{name()}
</div>
<div
class="truncate text-[10px] text-muted"
title={datastore.vmware?.datastoreUrl}
>
{datastore.vmware?.datastoreUrl ||
datastore.vmware?.folderName ||
datastore.vmware?.vcenterHost ||
'vSphere datastore'}
</div>
</div>
<span
class="truncate font-medium text-base-content"
title={datastoreTitle()}
>
{name()}
</span>
</div>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('text')}>
@@ -300,7 +263,18 @@ export const VsphereDatastoresTable: Component<{
</span>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('metric-bar')}>
<CapacityCell resource={datastore} />
<Show
when={showCapacity()}
fallback={
<div class="flex justify-center">
<span class="text-xs text-muted" aria-hidden="true">
—
</span>
</div>
}
>
<StackedDiskBar aggregateDisk={disk()} />
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
@@ -325,15 +299,12 @@ export const VsphereDatastoresTable: Component<{
>
<span class="block truncate">{datacenter()}</span>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('badge')}>
<StatusPill resource={datastore} />
</TableCell>
</TableRow>
<PlatformResourceDetailTableRow
resource={datastore}
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={8}
colSpan={7}
resolveResourceLabel={resolveResourceLabel}
onClose={() => drawer.close(datastore)}
/>
@@ -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 = () => (
</div>
);
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<{
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
title={[cluster(), clusterServices()].filter(Boolean).join(' | ')}
title={[cluster(), clusterServices()].filter(Boolean).join(' · ')}
>
<span class="block truncate">{cluster()}</span>
<Show when={clusterServices()}>
<span class="block truncate text-[10px] text-muted">
{clusterServices()}
</span>
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden md:table-cell`}
@@ -269,12 +249,12 @@ export const VsphereHostsTable: Component<{
<div class="flex items-center gap-2">
<StatusDot
size="sm"
variant={powerStateVariant(meta()?.powerState)}
variant={getVmwarePowerStateVariant(meta()?.powerState)}
title={meta()?.powerState || 'unknown'}
ariaHidden
/>
<span class="text-base-content">
{formatPowerState(meta()?.powerState)}
{formatVmwarePowerState(meta()?.powerState)}
</span>
</div>
</TableCell>
@@ -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<VmwareNetworkStatusFilter>[] = [
{ 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 | undefined>): 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) => (
<span
class={`inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium ${statusPillClass(
props.resource,
)}`}
>
{statusLabel(props.resource)}
</span>
);
export const VsphereNetworksTable: Component<{
networks: Resource[];
scope: Resource[];
@@ -187,9 +165,6 @@ export const VsphereNetworksTable: Component<{
>
Datacenter
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[8%]`}>
State
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
@@ -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}
/>
<div class="min-w-0">
<div class="truncate font-medium text-base-content" title={name()}>
{name()}
</div>
<div
class="truncate text-[10px] text-muted"
title={network.vmware?.managedObjectId}
>
{network.vmware?.managedObjectId ||
network.vmware?.folderName ||
network.vmware?.vcenterHost ||
'vSphere network'}
</div>
</div>
<span
class="truncate font-medium text-base-content"
title={networkTitle()}
>
{name()}
</span>
</div>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('text')}>
@@ -264,15 +238,12 @@ export const VsphereNetworksTable: Component<{
>
<span class="block truncate">{datacenter()}</span>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('badge')}>
<StatusPill resource={network} />
</TableCell>
</TableRow>
<PlatformResourceDetailTableRow
resource={network}
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={7}
colSpan={6}
resolveResourceLabel={resolveResourceLabel}
onClose={() => drawer.close(network)}
/>
@@ -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<VmwareVirtualMachineS
value: 'powered-on',
label: 'Powered on',
tone: 'success',
leading: statusDot('bg-emerald-500'),
leading: platformChipStatusDot('bg-emerald-500'),
},
{
value: 'attention',
label: 'Attention',
tone: 'warning',
leading: platformChipStatusDot('bg-amber-500'),
},
{
value: 'powered-off',
label: 'Powered off',
tone: 'danger',
leading: platformChipStatusDot('bg-red-500'),
},
{
value: 'suspended',
label: 'Suspended',
tone: 'warning',
leading: platformChipStatusDot('bg-amber-500'),
},
{ value: 'attention', label: 'Attention', tone: 'warning', leading: statusDot('bg-amber-500') },
{ value: 'powered-off', label: 'Powered off', tone: 'danger', leading: statusDot('bg-red-500') },
{ value: 'suspended', label: 'Suspended', tone: 'warning' },
{ value: 'unknown', label: 'Unknown' },
];
@@ -71,48 +87,20 @@ type VmwareVmGroup = {
const finiteMetric = (value: number | undefined): number | undefined =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
function statusDot(className: string): JSX.Element {
return <span class={`h-2 w-2 rounded-full ${className}`} />;
}
const metricFallback = () => (
<div class="flex justify-center">
<span class="text-xs text-muted" aria-hidden="true">
-
—
</span>
</div>
);
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<string | undefined>,
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) => (
<span
class={`inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium ${healthPillClass(
props.resource,
)}`}
>
{healthLabel(props.resource)}
</span>
);
const VMWARE_TOKEN_ACRONYMS: Record<string, string> = {
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) => (
<span
class={`inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium ${toolsPillClass(
props.tools,
)}`}
title={toolsTitle(props.tools)}
>
{toolsLabel(props.tools)}
</span>
);
const vmGroupKey = (resource: Resource): string =>
asTrimmedString(resource.vmware?.runtimeHostName) ||
asTrimmedString(resource.parentName) ||
@@ -421,7 +287,7 @@ export const VsphereVirtualMachinesTable: Component<{
>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<TableCardHeader title="Virtual Machines" />
<Table class="min-w-full table-fixed text-xs md:min-w-[1660px]">
<Table class="min-w-full table-fixed text-xs md:min-w-[1420px]">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[18%]`}>
@@ -476,12 +342,6 @@ export const VsphereVirtualMachinesTable: Component<{
>
Snapshots
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[7%]`}>
Tools
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[8%]`}>
Health
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
@@ -489,7 +349,7 @@ export const VsphereVirtualMachinesTable: Component<{
{(group) => (
<>
<TableRow class="bg-surface-alt/70 text-[11px] font-semibold text-base-content">
<TableCell colSpan={14} class="px-2 py-1">
<TableCell colSpan={12} class="px-2 py-1">
<span>{group.label}</span>
<Show when={group.cluster}>
<span class="ml-2 rounded border border-border bg-surface px-1.5 py-0.5 text-[10px] font-medium text-muted">
@@ -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}
/>
<div class="min-w-0">
<div
class="truncate font-medium text-base-content"
title={name()}
>
{name()}
</div>
<div
class="truncate text-[10px] text-muted"
title={meta()?.managedObjectId}
>
{meta()?.managedObjectId ||
meta()?.instanceUuid ||
'vSphere VM'}
</div>
</div>
<span
class="truncate font-medium text-base-content"
title={
[
name(),
meta()?.managedObjectId,
meta()?.instanceUuid,
]
.filter(Boolean)
.join(' · ') || name()
}
>
{name()}
</span>
</div>
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('text')}>
<div class="flex items-center gap-2">
<StatusDot
size="sm"
variant={powerStateVariant(meta()?.powerState)}
variant={getVmwarePowerStateVariant(meta()?.powerState)}
title={meta()?.powerState || 'unknown'}
ariaHidden
/>
@@ -601,14 +459,9 @@ export const VsphereVirtualMachinesTable: Component<{
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
title={[cluster(), clusterServices()].filter(Boolean).join(' | ')}
title={[cluster(), clusterServices()].filter(Boolean).join(' · ')}
>
<span class="block truncate">{cluster()}</span>
<Show when={clusterServices()}>
<span class="block truncate text-[10px] text-muted">
{clusterServices()}
</span>
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content lg:table-cell`}
@@ -678,18 +531,12 @@ export const VsphereVirtualMachinesTable: Component<{
>
{snapshots()}
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('badge')}>
<ToolsPill tools={meta()?.tools} />
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('badge')}>
<HealthPill resource={vm} />
</TableCell>
</TableRow>
<PlatformResourceDetailTableRow
resource={vm}
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={14}
colSpan={12}
resolveResourceLabel={resolveResourceLabel}
onClose={() => drawer.close(vm)}
/>
@@ -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: () => <div data-testid="stacked-disk-bar" />,
}));
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');
@@ -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');
@@ -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');
@@ -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<VmwareVirtualMachineStatusFilter, 'all'> {