mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-15 16:42:15 +00:00
Add platform table resource drawers
This commit is contained in:
@@ -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>
|
||||
);
|
||||
+195
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user