mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-21 11:15:36 +00:00
348b731408
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.
325 lines
14 KiB
TypeScript
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;
|