Files
pulse/frontend-modern/src/features/vmware/VsphereHostsTable.tsx
T
rcourtman 348b731408 Establish canonical platform table column alignment
Until now, the same column (CPU/Memory/Disk) was aligned differently
depending on which table it appeared in: Proxmox top tables defaulted
to left, Docker/TrueNAS/vSphere/Kubernetes top tables used right, the
shared workloads bottom table used center, and integer-count columns
(VMs/CTs/Containers/Datastores/Desired/etc.) were a mix of right and
center. The user's read: "complete hobbledy codge."

Codify the rule once and for all. The new
frontend-modern/src/features/platformPage/columnAlignment.ts defines
five canonical column kinds with a documented align mapping and the
rationale for each:

  - 'name'           left   primary identifier
  - 'text'           left   readable strings
  - 'metric-bar'     center bar fills cell, label rides at fill edge
  - 'numeric-value'  right  units/digits line up for column scanning
  - 'badge'          center pill/icon-only cells

sharedPlatformPage exposes getPlatformTableHeadClassForKind() and
getPlatformTableCellClassForKind() wrappers; every platform top table
plus the Docker services table now consume these instead of passing
literal align strings. The Proxmox host column model carries a kind
field instead of a raw align so the same source of truth drives both
the model file and the JSX.

KubernetesClustersTable.tsx is intentionally not migrated in this
commit: another agent has it staged in their working tree. The same
migration applies once that lands; left for a follow-up.

The WorkloadTableHeader (shared bottom guest-list table) is also
unmigrated here. It center-aligns everything today, and changing it to
canonical means flipping Uptime / Net I/O / Disk I/O from center to
right across every guest list on every platform page — bigger visual
blast radius, so it gets its own scoped commit next.

A canonical-platform-audit rule and a CLAUDE.md/AGENTS.md note will
follow in a third commit, so any future agent that writes a
non-canonical align fails the pre-push hook rather than discovers the
drift visually weeks later.
2026-05-20 11:06:32 +01:00

325 lines
14 KiB
TypeScript

import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
import { StatusDot } from '@/components/shared/StatusDot';
import { ResponsiveMetricCell } from '@/components/shared/responsive';
import { TableCard } from '@/components/shared/TableCard';
import { TableCardHeader } from '@/components/shared/TableCardHeader';
import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import { getSimpleStatusIndicator } from '@/utils/status';
import { asTrimmedString } from '@/utils/stringUtils';
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
import {
PLATFORM_TABLE_BODY_CLASS,
PLATFORM_TABLE_CARD_CLASS,
PLATFORM_TABLE_HEADER_ROW_CLASS,
PLATFORM_HEALTH_FILTER_OPTIONS,
PlatformTableToolbar,
PlatformTableEmptyState,
createPlatformTableFilterState,
filterPlatformResources,
getPlatformTableCellClassForKind,
getPlatformTableHeadClassForKind,
type PlatformResourceStatusFilter,
} from '@/features/platformPage/sharedPlatformPage';
import {
PlatformResourceDetailTableRow,
createPlatformResourceDetailState,
createPlatformResourceLabelResolver,
getPlatformResourceDetailRowClass,
} from '@/features/platformPage/PlatformResourceDetailTableRow';
import type { Resource } from '@/types/resource';
// vSphere ESXi hosts are virtualization hypervisors managed by vCenter,
// not generic Pulse Agents. The generic infrastructure table renders
// dashes for Uptime / Temperature (vCenter inventory does not expose
// host uptime today) and lacks the columns that matter for the cluster
// operator: datacenter, cluster, power state, connection state,
// datastore count, and VM count alongside CPU / Memory utilisation.
// This bespoke table reuses canonical shared primitives and surfaces
// those ESXi-native columns — matching the Docker / Proxmox bar
// treatment so the Overview stack reads as one consistent surface.
// Per-host VM count is computed from the page scope client-side (no
// extra API calls).
const finiteMetric = (value: number | undefined): number | undefined =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
const metricFallback = () => (
<div class="flex justify-center">
<span class="text-xs text-muted" aria-hidden="true">
—
</span>
</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
// additional fetches.
scope: Resource[];
emptyIcon: JSX.Element;
emptyTitle: string;
emptyDescription: string;
title?: string;
showToolbar?: boolean;
}> = (props) => {
const tableState = createPlatformTableFilterState({
resources: () => props.hosts,
initialStatus: 'all' as PlatformResourceStatusFilter,
filter: filterPlatformResources,
});
const drawer = createPlatformResourceDetailState({ idPrefix: 'vsphere-host-drawer' });
const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope);
const vmCountByHost = createMemo(() => {
const map = new Map<string, number>();
for (const resource of props.scope) {
if (resource.type !== 'vm') continue;
const runtimeHost = asTrimmedString(resource.vmware?.runtimeHostId);
if (!runtimeHost) continue;
map.set(runtimeHost, (map.get(runtimeHost) ?? 0) + 1);
}
return map;
});
return (
<Show
when={props.hosts.length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title={props.emptyTitle}
description={props.emptyDescription}
/>
}
>
<div class="space-y-3">
<Show when={props.showToolbar !== false}>
<PlatformTableToolbar
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search ESXi hosts"
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
visible={tableState.visible()}
total={tableState.total()}
rowNoun="hosts"
/>
</Show>
<Show
when={tableState.filtered().length > 0}
fallback={
<PlatformTableEmptyState
icon={props.emptyIcon}
title="No hosts match current filters"
description="Adjust the search or status filter to see more hosts."
/>
}
>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<TableCardHeader title={props.title ?? 'Hosts'} />
<Table class="min-w-full table-fixed text-xs md:min-w-[960px]">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
{/*
Desktop widths give the Host FQDN room, balance the CPU
and Memory bars, and trim the Datacenter / Cluster /
Power / vCenter text columns and the Datastores / VMs
integer-count columns to what their content actually
needs. Mobile widths are unchanged.
*/}
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[18%]`}>
Host
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[11%]`}>
Datacenter
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[12%]`}>
Cluster
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[9%]`}>
Power
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[13%]`}>
CPU
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('metric-bar')} md:w-[13%]`}>
Memory
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} hidden md:table-cell md:w-[9%]`}>
Datastores
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[5%]`}>
VMs
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[10%]`}>
vCenter
</TableHead>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={tableState.filtered()}>
{(host) => {
const meta = () => host.vmware;
const name = () => asTrimmedString(host.name) || host.id;
const datacenter = () => asTrimmedString(meta()?.datacenterName) || '—';
const cluster = () => asTrimmedString(meta()?.clusterName) || '—';
const vcenter = () => asTrimmedString(meta()?.vcenterHost) || '—';
const datastoreCount = () =>
meta()?.datastoreIds?.length ?? meta()?.datastoreNames?.length ?? 0;
const vmCount = () =>
vmCountByHost().get(asTrimmedString(meta()?.managedObjectId) || '') ?? 0;
const indicator = () => getSimpleStatusIndicator(host.status);
const metricsKey = () => buildMetricKeyForUnifiedResource(host);
const cpuPercent = () => finiteMetric(host.cpu?.current);
const memoryTotal = () => finiteMetric(host.memory?.total) ?? 0;
const memoryUsed = () => finiteMetric(host.memory?.used) ?? 0;
const memoryPercentOnly = () =>
memoryTotal() > 0 ? undefined : finiteMetric(host.memory?.current);
const hasMemoryMetric = () =>
memoryTotal() > 0 || memoryPercentOnly() !== undefined;
const canRenderMetrics = () => indicator().variant !== 'muted';
const detailRowId = () => drawer.detailRowId(host);
const isExpanded = () => drawer.isExpanded(host);
return (
<>
<TableRow
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
aria-controls={isExpanded() ? detailRowId() : undefined}
aria-expanded={isExpanded() ? 'true' : 'false'}
data-vsphere-host-row={host.id}
onClick={() => drawer.toggle(host)}
onKeyDown={drawer.handleActivationKey(host)}
tabIndex={0}
>
<TableCell class={getPlatformTableCellClassForKind('name')}>
<div class="flex min-w-0 items-center gap-2">
<StatusDot
size="sm"
variant={indicator().variant}
title={host.status || 'unknown'}
ariaHidden
/>
<span class="truncate font-semibold text-base-content" title={name()}>
{name()}
</span>
</div>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
{datacenter()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
{cluster()}
</TableCell>
<TableCell class={`${getPlatformTableCellClassForKind('text')} hidden md:table-cell`}>
<div class="flex items-center gap-2">
<StatusDot
size="sm"
variant={powerStateVariant(meta()?.powerState)}
title={meta()?.powerState || 'unknown'}
ariaHidden
/>
<span class="text-base-content">
{formatPowerState(meta()?.powerState)}
</span>
</div>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('metric-bar')} w-[20%] md:w-auto`}
>
<ResponsiveMetricCell
class="w-full"
value={cpuPercent() ?? 0}
type="cpu"
resourceId={metricsKey()}
isRunning={canRenderMetrics() && cpuPercent() !== undefined}
showMobile={false}
/>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('metric-bar')} w-[20%] md:w-auto`}
>
<Show
when={canRenderMetrics() && hasMemoryMetric()}
fallback={metricFallback()}
>
<StackedMemoryBar
used={memoryUsed()}
total={memoryTotal()}
percentOnly={memoryPercentOnly()}
/>
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} hidden text-base-content tabular-nums md:table-cell`}
>
{datastoreCount()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
>
{vmCount()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden font-mono text-[11px] text-base-content md:table-cell`}
>
<span class="inline-block max-w-[12rem] truncate" title={vcenter()}>
{vcenter()}
</span>
</TableCell>
</TableRow>
<PlatformResourceDetailTableRow
resource={host}
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={9}
resolveResourceLabel={resolveResourceLabel}
onClose={() => drawer.close(host)}
/>
</>
);
}}
</For>
</TableBody>
</Table>
</TableCard>
</Show>
</div>
</Show>
);
};
export default VsphereHostsTable;