Add platform table resource drawers

This commit is contained in:
rcourtman
2026-05-19 22:05:45 +01:00
parent 11ab890880
commit cd85d581a3
6 changed files with 681 additions and 277 deletions
@@ -25,6 +25,12 @@ import {
getPlatformTableHeadClass,
type PlatformResourceStatusFilter,
} from '@/features/platformPage/sharedPlatformPage';
import {
PlatformResourceDetailTableRow,
createPlatformResourceDetailState,
createPlatformResourceLabelResolver,
getPlatformResourceDetailRowClass,
} from '@/features/platformPage/PlatformResourceDetailTableRow';
import type { Resource } from '@/types/resource';
// Kubernetes clusters are control-plane aggregates, not single processes —
@@ -60,6 +66,8 @@ export const KubernetesClustersTable: Component<{
initialStatus: 'all' as PlatformResourceStatusFilter,
filter: filterPlatformResources,
});
const drawer = createPlatformResourceDetailState({ idPrefix: 'kubernetes-cluster-drawer' });
const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope);
const countsByCluster = createMemo(() => {
const map = new Map<string, { nodes: number; pods: number; deployments: number }>();
@@ -173,57 +181,80 @@ export const KubernetesClustersTable: Component<{
deployments: 0,
};
const indicator = () => getSimpleStatusIndicator(cluster.status);
const detailRowId = () => drawer.detailRowId(cluster);
const isExpanded = () => drawer.isExpanded(cluster);
return (
<TableRow class="text-[11px] sm:text-xs">
<TableCell class={getPlatformTableCellClass()}>
<div class="flex min-w-0 items-center gap-2">
<StatusDot
size="sm"
variant={indicator().variant}
title={cluster.status || 'unknown'}
ariaHidden
/>
<span class="truncate font-semibold text-base-content" title={name()}>
{name()}
</span>
</div>
</TableCell>
<TableCell
class={`${getPlatformTableCellClass()} hidden text-base-content md:table-cell`}
<>
<TableRow
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
aria-controls={isExpanded() ? detailRowId() : undefined}
aria-expanded={isExpanded() ? 'true' : 'false'}
data-kubernetes-cluster-row={cluster.id}
onClick={() => drawer.toggle(cluster)}
onKeyDown={drawer.handleActivationKey(cluster)}
tabIndex={0}
>
{context()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass()} hidden font-mono text-[11px] text-base-content md:table-cell`}
>
{version()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} text-base-content tabular-nums`}
>
{counts().nodes}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} hidden text-base-content tabular-nums md:table-cell`}
>
{counts().pods}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} hidden text-base-content tabular-nums md:table-cell`}
>
{counts().deployments}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} text-base-content`}
>
{formatPercent(cluster.cpu?.current)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} text-base-content`}
>
{formatPercent(cluster.memory?.current)}
</TableCell>
</TableRow>
<TableCell class={getPlatformTableCellClass()}>
<div class="flex min-w-0 items-center gap-2">
<StatusDot
size="sm"
variant={indicator().variant}
title={cluster.status || 'unknown'}
ariaHidden
/>
<span
class="truncate font-semibold text-base-content"
title={name()}
>
{name()}
</span>
</div>
</TableCell>
<TableCell
class={`${getPlatformTableCellClass()} hidden text-base-content md:table-cell`}
>
{context()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass()} hidden font-mono text-[11px] text-base-content md:table-cell`}
>
{version()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} text-base-content tabular-nums`}
>
{counts().nodes}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} hidden text-base-content tabular-nums md:table-cell`}
>
{counts().pods}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} hidden text-base-content tabular-nums md:table-cell`}
>
{counts().deployments}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} text-base-content`}
>
{formatPercent(cluster.cpu?.current)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} text-base-content`}
>
{formatPercent(cluster.memory?.current)}
</TableCell>
</TableRow>
<PlatformResourceDetailTableRow
resource={cluster}
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={8}
resolveResourceLabel={resolveResourceLabel}
onClose={() => drawer.close(cluster)}
/>
</>
);
}}
</For>
@@ -28,6 +28,12 @@ import {
getPlatformTableHeadClass,
type PlatformResourceStatusFilter,
} from '@/features/platformPage/sharedPlatformPage';
import {
PlatformResourceDetailTableRow,
createPlatformResourceDetailState,
createPlatformResourceLabelResolver,
getPlatformResourceDetailRowClass,
} from '@/features/platformPage/PlatformResourceDetailTableRow';
import type { Resource } from '@/types/resource';
// Kubernetes nodes carry richer Kubelet/runtime metadata than a generic
@@ -93,6 +99,8 @@ export const KubernetesNodesTable: Component<{
initialStatus: 'all' as PlatformResourceStatusFilter,
filter: filterPlatformResources,
});
const drawer = createPlatformResourceDetailState({ idPrefix: 'kubernetes-node-drawer' });
const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.resources);
return (
<Show
@@ -190,81 +198,101 @@ export const KubernetesNodesTable: Component<{
const hasMemoryMetric = () =>
memoryTotal() > 0 || memoryPercentOnly() !== undefined;
const canRenderMetrics = () => indicator().variant !== 'muted';
const detailRowId = () => drawer.detailRowId(node);
const isExpanded = () => drawer.isExpanded(node);
return (
<TableRow class="text-[11px] sm:text-xs">
<TableCell class={getPlatformTableCellClass()}>
<div class="flex min-w-0 items-center gap-2">
<StatusDot
size="sm"
variant={indicator().variant}
title={node.status || 'unknown'}
ariaHidden
/>
<span class="truncate font-semibold text-base-content" title={name()}>
{name()}
</span>
</div>
</TableCell>
<TableCell
class={`${getPlatformTableCellClass()} hidden text-base-content md:table-cell`}
<>
<TableRow
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
aria-controls={isExpanded() ? detailRowId() : undefined}
aria-expanded={isExpanded() ? 'true' : 'false'}
data-kubernetes-node-row={node.id}
onClick={() => drawer.toggle(node)}
onKeyDown={drawer.handleActivationKey(node)}
tabIndex={0}
>
{cluster()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass()} hidden text-base-content md:table-cell`}
>
{formatRoles(meta()?.roles)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass()} hidden font-mono text-[11px] text-base-content md:table-cell`}
>
{kubelet()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass()} hidden font-mono text-[11px] text-base-content md:table-cell`}
>
<span class="truncate inline-block max-w-[10rem]" title={runtime()}>
{runtime()}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} 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={`${getPlatformTableCellClass('right')} w-[20%] md:w-auto`}
>
<Show
when={canRenderMetrics() && hasMemoryMetric()}
fallback={metricFallback()}
<TableCell class={getPlatformTableCellClass()}>
<div class="flex min-w-0 items-center gap-2">
<StatusDot
size="sm"
variant={indicator().variant}
title={node.status || 'unknown'}
ariaHidden
/>
<span class="truncate font-semibold text-base-content" title={name()}>
{name()}
</span>
</div>
</TableCell>
<TableCell
class={`${getPlatformTableCellClass()} hidden text-base-content md:table-cell`}
>
<StackedMemoryBar
used={memoryUsed()}
total={memoryTotal()}
percentOnly={memoryPercentOnly()}
{cluster()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass()} hidden text-base-content md:table-cell`}
>
{formatRoles(meta()?.roles)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass()} hidden font-mono text-[11px] text-base-content md:table-cell`}
>
{kubelet()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass()} hidden font-mono text-[11px] text-base-content md:table-cell`}
>
<span class="truncate inline-block max-w-[10rem]" title={runtime()}>
{runtime()}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} w-[20%] md:w-auto`}
>
<ResponsiveMetricCell
class="w-full"
value={cpuPercent() ?? 0}
type="cpu"
resourceId={metricsKey()}
isRunning={canRenderMetrics() && cpuPercent() !== undefined}
showMobile={false}
/>
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} text-base-content tabular-nums`}
>
<span class="md:hidden">{compactCapacityLabel()}</span>
<span class="hidden md:inline">{capacityLabel()}</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} hidden text-base-content md:table-cell`}
>
{formatUptime(node.uptime)}
</TableCell>
</TableRow>
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} w-[20%] md:w-auto`}
>
<Show
when={canRenderMetrics() && hasMemoryMetric()}
fallback={metricFallback()}
>
<StackedMemoryBar
used={memoryUsed()}
total={memoryTotal()}
percentOnly={memoryPercentOnly()}
/>
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} text-base-content tabular-nums`}
>
<span class="md:hidden">{compactCapacityLabel()}</span>
<span class="hidden md:inline">{capacityLabel()}</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} hidden text-base-content md:table-cell`}
>
{formatUptime(node.uptime)}
</TableCell>
</TableRow>
<PlatformResourceDetailTableRow
resource={node}
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={9}
resolveResourceLabel={resolveResourceLabel}
onClose={() => drawer.close(node)}
/>
</>
);
}}
</For>
@@ -0,0 +1,94 @@
import { Show, createSignal, type Component, type JSX } from 'solid-js';
import { ResourceDetailDrawer } from '@/components/Infrastructure/ResourceDetailDrawer';
import { TableCell, TableRow } from '@/components/shared/Table';
import type { Resource } from '@/types/resource';
import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity';
type ResourceLike = Pick<Resource, 'id'>;
export type PlatformResourceDetailState = {
expandedResourceId: () => string | null;
isExpanded: (resource: ResourceLike) => boolean;
detailRowId: (resource: ResourceLike) => string;
toggle: (resource: ResourceLike) => void;
close: (resource?: ResourceLike) => void;
handleActivationKey: (
resource: ResourceLike,
) => JSX.EventHandler<HTMLTableRowElement, KeyboardEvent>;
};
export const PLATFORM_RESOURCE_DETAIL_ROW_CLASS =
'cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface';
export const getPlatformResourceDetailRowClass = (expanded: boolean): string =>
`${PLATFORM_RESOURCE_DETAIL_ROW_CLASS}${expanded ? ' bg-surface-hover' : ''}`;
export function createPlatformResourceDetailState(options: {
idPrefix: string;
}): PlatformResourceDetailState {
const [expandedResourceId, setExpandedResourceId] = createSignal<string | null>(null);
const isExpanded = (resource: ResourceLike): boolean => expandedResourceId() === resource.id;
const detailRowId = (resource: ResourceLike): string => `${options.idPrefix}-${resource.id}`;
const toggle = (resource: ResourceLike) => {
setExpandedResourceId((current) => (current === resource.id ? null : resource.id));
};
const close = (resource?: ResourceLike) => {
if (!resource || isExpanded(resource)) {
setExpandedResourceId(null);
}
};
const handleActivationKey =
(resource: ResourceLike): JSX.EventHandler<HTMLTableRowElement, KeyboardEvent> =>
(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
toggle(resource);
};
return {
expandedResourceId,
isExpanded,
detailRowId,
toggle,
close,
handleActivationKey,
};
}
export function createPlatformResourceLabelResolver(resources: () => readonly Resource[]) {
return (resourceId: string): string | undefined => {
const resource = resources().find((candidate) => candidate.id === resourceId);
return resource ? getPreferredInfrastructureDisplayName(resource) : undefined;
};
}
export const PlatformResourceDetailTableRow: Component<{
resource: Resource;
open: boolean;
detailRowId: string;
colSpan: number;
resolveResourceLabel?: (resourceId: string) => string | null | undefined;
onClose?: () => void;
}> = (props) => (
<Show when={props.open}>
<TableRow
data-inline-detail-for={props.resource.id}
data-inline-platform-resource-detail-for={props.resource.id}
>
<TableCell
id={props.detailRowId}
colspan={props.colSpan}
class="border-b border-border bg-surface-alt p-0"
>
<div class="px-2 py-3 sm:px-4 sm:py-4" onClick={(event) => event.stopPropagation()}>
<ResourceDetailDrawer
resource={props.resource}
resolveResourceLabel={props.resolveResourceLabel}
onClose={props.onClose}
/>
</div>
</TableCell>
</TableRow>
</Show>
);
@@ -0,0 +1,195 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Resource } from '@/types/resource';
import { KubernetesClustersTable } from '@/features/kubernetes/KubernetesClustersTable';
import { KubernetesNodesTable } from '@/features/kubernetes/KubernetesNodesTable';
import { TrueNASSystemsTable } from '@/features/truenas/TrueNASSystemsTable';
import { VsphereHostsTable } from '@/features/vmware/VsphereHostsTable';
vi.mock('@/components/Infrastructure/ResourceDetailDrawer', () => ({
ResourceDetailDrawer: (props: { resource: { id: string }; onClose?: () => void }) => (
<div data-testid="resource-detail-drawer" data-resource-id={props.resource.id}>
<button type="button" aria-label="Close resource drawer" onClick={() => props.onClose?.()} />
</div>
),
}));
vi.mock('@/components/shared/responsive', () => ({
ResponsiveMetricCell: () => <div data-testid="responsive-metric-cell" />,
}));
vi.mock('@/components/Workloads/StackedMemoryBar', () => ({
StackedMemoryBar: () => <div data-testid="stacked-memory-bar" />,
}));
const makeResource = ({
id,
type,
...overrides
}: Partial<Resource> & Pick<Resource, 'id' | 'type'>): Resource => ({
id,
type,
name: id,
displayName: id,
platformId: 'homelab',
platformType: 'truenas',
sourceType: 'api',
status: 'online',
lastSeen: 1_700_000_000_000,
cpu: { current: 42 },
memory: { total: 32_000, used: 16_000, free: 16_000, current: 50 },
disk: { total: 80_000, used: 40_000, free: 40_000, current: 50 },
...overrides,
});
const expectRowOpensResourceDrawer = async (row: HTMLTableRowElement, resourceId: string) => {
expect(row).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByTestId('resource-detail-drawer')).not.toBeInTheDocument();
await fireEvent.click(row);
expect(row).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByTestId('resource-detail-drawer')).toHaveAttribute(
'data-resource-id',
resourceId,
);
await fireEvent.click(screen.getByRole('button', { name: 'Close resource drawer' }));
expect(row).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByTestId('resource-detail-drawer')).not.toBeInTheDocument();
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('platform resource table drawers', () => {
it('opens canonical resource details from TrueNAS system rows', async () => {
const system = makeResource({
id: 'agent:truenas-main',
type: 'agent',
name: 'truenas-main',
platformType: 'truenas',
});
render(() => (
<TrueNASSystemsTable
systems={[system]}
scope={[system]}
emptyIcon={<span />}
emptyTitle="No systems"
emptyDescription="No systems"
showToolbar={false}
/>
));
const row = screen.getByText('truenas-main').closest('tr');
expect(row).toBeTruthy();
await expectRowOpensResourceDrawer(row!, system.id);
});
it('opens canonical resource details from vSphere host rows', async () => {
const host = makeResource({
id: 'vmware-host:esxi-01',
type: 'agent',
name: 'esxi-01',
platformType: 'vmware-vsphere',
vmware: {
managedObjectId: 'host-12',
datacenterName: 'DC1',
clusterName: 'Cluster A',
powerState: 'POWERED_ON',
vcenterHost: 'vcenter.local',
},
});
const vm = makeResource({
id: 'vmware-vm:app-01',
type: 'vm',
name: 'app-01',
platformType: 'vmware-vsphere',
vmware: {
runtimeHostId: 'host-12',
},
});
render(() => (
<VsphereHostsTable
hosts={[host]}
scope={[host, vm]}
emptyIcon={<span />}
emptyTitle="No hosts"
emptyDescription="No hosts"
showToolbar={false}
/>
));
const row = screen.getByText('esxi-01').closest('tr');
expect(row).toBeTruthy();
await expectRowOpensResourceDrawer(row!, host.id);
});
it('opens canonical resource details from Kubernetes node rows', async () => {
const node = makeResource({
id: 'k8s-node:worker-01',
type: 'k8s-node',
name: 'worker-01',
platformType: 'kubernetes',
sourceType: 'hybrid',
kubernetes: {
clusterId: 'prod-west',
clusterName: 'prod-west',
nodeName: 'worker-01',
kubeletVersion: 'v1.31.3',
containerRuntimeVersion: 'containerd://1.7.22',
},
});
render(() => (
<KubernetesNodesTable
resources={[node]}
emptyIcon={<span />}
emptyTitle="No nodes"
emptyDescription="No nodes"
showToolbar={false}
/>
));
const row = screen.getByText('worker-01').closest('tr');
expect(row).toBeTruthy();
await expectRowOpensResourceDrawer(row!, node.id);
});
it('opens canonical resource details from Kubernetes cluster rows', async () => {
const cluster = makeResource({
id: 'k8s-cluster:prod-west',
type: 'k8s-cluster',
name: 'prod-west',
platformType: 'kubernetes',
sourceType: 'hybrid',
kubernetes: {
clusterId: 'prod-west',
clusterName: 'prod-west',
context: 'prod-west-admin',
version: 'v1.31.3',
},
});
render(() => (
<KubernetesClustersTable
clusters={[cluster]}
scope={[cluster]}
emptyIcon={<span />}
emptyTitle="No clusters"
emptyDescription="No clusters"
showToolbar={false}
/>
));
const row = screen.getByText('prod-west').closest('tr');
expect(row).toBeTruthy();
await expectRowOpensResourceDrawer(row!, cluster.id);
});
});
@@ -28,6 +28,12 @@ import {
getPlatformTableHeadClass,
type PlatformResourceStatusFilter,
} from '@/features/platformPage/sharedPlatformPage';
import {
PlatformResourceDetailTableRow,
createPlatformResourceDetailState,
createPlatformResourceLabelResolver,
getPlatformResourceDetailRowClass,
} from '@/features/platformPage/PlatformResourceDetailTableRow';
import type { Resource } from '@/types/resource';
// TrueNAS systems are storage appliances, not generic compute hosts.
@@ -100,6 +106,8 @@ export const TrueNASSystemsTable: Component<{
initialStatus: 'all' as PlatformResourceStatusFilter,
filter: filterPlatformResources,
});
const drawer = createPlatformResourceDetailState({ idPrefix: 'truenas-system-drawer' });
const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope);
// Single-system canonical mock is the common case today; counts span
// the full TrueNAS resource scope per row. When multi-system support
@@ -217,89 +225,109 @@ export const TrueNASSystemsTable: Component<{
const hasMemoryMetric = () =>
memoryTotal() > 0 || memoryPercentOnly() !== undefined;
const canRenderMetrics = () => indicator().variant !== 'muted';
const detailRowId = () => drawer.detailRowId(system);
const isExpanded = () => drawer.isExpanded(system);
return (
<TableRow class="text-[11px] sm:text-xs">
<TableCell class={getPlatformTableCellClass()}>
<div class="flex min-w-0 items-center gap-2">
<StatusDot
size="sm"
variant={indicator().variant}
title={system.status || 'unknown'}
ariaHidden
/>
<span class="truncate font-semibold text-base-content" title={name()}>
{name()}
</span>
</div>
</TableCell>
<TableCell
class={`${getPlatformTableCellClass()} hidden font-mono text-[11px] text-base-content md:table-cell`}
<>
<TableRow
class={`${getPlatformResourceDetailRowClass(isExpanded())} text-[11px] sm:text-xs`}
aria-controls={isExpanded() ? detailRowId() : undefined}
aria-expanded={isExpanded() ? 'true' : 'false'}
data-truenas-system-row={system.id}
onClick={() => drawer.toggle(system)}
onKeyDown={drawer.handleActivationKey(system)}
tabIndex={0}
>
{version()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} hidden text-base-content md:table-cell`}
>
{formatUptime(system.uptime)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} 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={`${getPlatformTableCellClass('right')} w-[20%] md:w-auto`}
>
<Show
when={canRenderMetrics() && hasMemoryMetric()}
fallback={metricFallback()}
<TableCell class={getPlatformTableCellClass()}>
<div class="flex min-w-0 items-center gap-2">
<StatusDot
size="sm"
variant={indicator().variant}
title={system.status || 'unknown'}
ariaHidden
/>
<span class="truncate font-semibold text-base-content" title={name()}>
{name()}
</span>
</div>
</TableCell>
<TableCell
class={`${getPlatformTableCellClass()} hidden font-mono text-[11px] text-base-content md:table-cell`}
>
<StackedMemoryBar
used={memoryUsed()}
total={memoryTotal()}
percentOnly={memoryPercentOnly()}
{version()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} hidden text-base-content md:table-cell`}
>
{formatUptime(system.uptime)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} w-[20%] md:w-auto`}
>
<ResponsiveMetricCell
class="w-full"
value={cpuPercent() ?? 0}
type="cpu"
resourceId={metricsKey()}
isRunning={canRenderMetrics() && cpuPercent() !== undefined}
showMobile={false}
/>
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} text-base-content`}
>
<span class="md:hidden">{formatPercent(storagePercent())}</span>
<span class="hidden md:inline">{storageFullLabel()}</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} hidden text-base-content md:table-cell`}
>
{formatTemperature(system.temperature)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} hidden text-base-content tabular-nums md:table-cell`}
>
{c.pools}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} hidden text-base-content tabular-nums md:table-cell`}
>
{c.datasets}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} hidden text-base-content tabular-nums md:table-cell`}
>
{c.disks}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} hidden text-base-content tabular-nums md:table-cell`}
>
{c.apps}
</TableCell>
</TableRow>
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} w-[20%] md:w-auto`}
>
<Show
when={canRenderMetrics() && hasMemoryMetric()}
fallback={metricFallback()}
>
<StackedMemoryBar
used={memoryUsed()}
total={memoryTotal()}
percentOnly={memoryPercentOnly()}
/>
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} text-base-content`}
>
<span class="md:hidden">{formatPercent(storagePercent())}</span>
<span class="hidden md:inline">{storageFullLabel()}</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} hidden text-base-content md:table-cell`}
>
{formatTemperature(system.temperature)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} hidden text-base-content tabular-nums md:table-cell`}
>
{c.pools}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} hidden text-base-content tabular-nums md:table-cell`}
>
{c.datasets}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} hidden text-base-content tabular-nums md:table-cell`}
>
{c.disks}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} hidden text-base-content tabular-nums md:table-cell`}
>
{c.apps}
</TableCell>
</TableRow>
<PlatformResourceDetailTableRow
resource={system}
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={11}
resolveResourceLabel={resolveResourceLabel}
onClose={() => drawer.close(system)}
/>
</>
);
}}
</For>
@@ -28,6 +28,12 @@ import {
getPlatformTableHeadClass,
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,
@@ -88,6 +94,8 @@ export const VsphereHostsTable: Component<{
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>();
@@ -184,88 +192,108 @@ export const VsphereHostsTable: Component<{
const hasMemoryMetric = () =>
memoryTotal() > 0 || memoryPercentOnly() !== undefined;
const canRenderMetrics = () => indicator().variant !== 'muted';
const detailRowId = () => drawer.detailRowId(host);
const isExpanded = () => drawer.isExpanded(host);
return (
<TableRow class="text-[11px] sm:text-xs">
<TableCell class={getPlatformTableCellClass()}>
<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={`${getPlatformTableCellClass()} hidden text-base-content md:table-cell`}
<>
<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}
>
{datacenter()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass()} hidden text-base-content md:table-cell`}
>
{cluster()}
</TableCell>
<TableCell class={`${getPlatformTableCellClass()} 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={`${getPlatformTableCellClass('right')} 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={`${getPlatformTableCellClass('right')} w-[20%] md:w-auto`}
>
<Show
when={canRenderMetrics() && hasMemoryMetric()}
fallback={metricFallback()}
<TableCell class={getPlatformTableCellClass()}>
<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={`${getPlatformTableCellClass()} hidden text-base-content md:table-cell`}
>
<StackedMemoryBar
used={memoryUsed()}
total={memoryTotal()}
percentOnly={memoryPercentOnly()}
{datacenter()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass()} hidden text-base-content md:table-cell`}
>
{cluster()}
</TableCell>
<TableCell class={`${getPlatformTableCellClass()} 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={`${getPlatformTableCellClass('right')} w-[20%] md:w-auto`}
>
<ResponsiveMetricCell
class="w-full"
value={cpuPercent() ?? 0}
type="cpu"
resourceId={metricsKey()}
isRunning={canRenderMetrics() && cpuPercent() !== undefined}
showMobile={false}
/>
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} hidden text-base-content tabular-nums md:table-cell`}
>
{datastoreCount()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} text-base-content tabular-nums`}
>
{vmCount()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass()} 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>
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} w-[20%] md:w-auto`}
>
<Show
when={canRenderMetrics() && hasMemoryMetric()}
fallback={metricFallback()}
>
<StackedMemoryBar
used={memoryUsed()}
total={memoryTotal()}
percentOnly={memoryPercentOnly()}
/>
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} hidden text-base-content tabular-nums md:table-cell`}
>
{datastoreCount()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass('right')} text-base-content tabular-nums`}
>
{vmCount()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClass()} 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>