diff --git a/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx index 991df706d4..b7646ac028 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx @@ -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(); @@ -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 ( - - -
- - - {name()} - -
-
- - - - {counts().nodes} - - - - - {formatPercent(cluster.cpu?.current)} - - - {formatPercent(cluster.memory?.current)} - -
+ +
+ + + {name()} + +
+
+ + + + {counts().nodes} + + + + + {formatPercent(cluster.cpu?.current)} + + + {formatPercent(cluster.memory?.current)} + + + drawer.close(cluster)} + /> + ); }} diff --git a/frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx index ef64d1c0f5..03c0c5124f 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesNodesTable.tsx @@ -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 ( memoryTotal() > 0 || memoryPercentOnly() !== undefined; const canRenderMetrics = () => indicator().variant !== 'muted'; + const detailRowId = () => drawer.detailRowId(node); + const isExpanded = () => drawer.isExpanded(node); return ( - - -
- - - {name()} - -
-
- - - - - - - - - +
+ + + {name()} + +
+
+
- - - {compactCapacityLabel()} - - - - + + + + + + + + {compactCapacityLabel()} + + + + + drawer.close(node)} + /> + ); }} diff --git a/frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx b/frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx new file mode 100644 index 0000000000..21473185bb --- /dev/null +++ b/frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx @@ -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; + +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; +}; + +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(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 => + (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) => ( + + + +
event.stopPropagation()}> + +
+
+
+
+); diff --git a/frontend-modern/src/features/platformPage/__tests__/platformResourceTableDrawers.test.tsx b/frontend-modern/src/features/platformPage/__tests__/platformResourceTableDrawers.test.tsx new file mode 100644 index 0000000000..8abc29bae3 --- /dev/null +++ b/frontend-modern/src/features/platformPage/__tests__/platformResourceTableDrawers.test.tsx @@ -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 }) => ( +
+
+ ), +})); + +vi.mock('@/components/shared/responsive', () => ({ + ResponsiveMetricCell: () =>
, +})); + +vi.mock('@/components/Workloads/StackedMemoryBar', () => ({ + StackedMemoryBar: () =>
, +})); + +const makeResource = ({ + id, + type, + ...overrides +}: Partial & Pick): 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(() => ( + } + 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(() => ( + } + 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(() => ( + } + 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(() => ( + } + emptyTitle="No clusters" + emptyDescription="No clusters" + showToolbar={false} + /> + )); + + const row = screen.getByText('prod-west').closest('tr'); + expect(row).toBeTruthy(); + await expectRowOpensResourceDrawer(row!, cluster.id); + }); +}); diff --git a/frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx b/frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx index 34a6d735f1..f38cd8bca6 100644 --- a/frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx +++ b/frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx @@ -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 ( - - -
- - - {name()} - -
-
- - - - - - - +
+ + + {name()} + +
+
+
+ + + + + + + + {formatPercent(storagePercent())} + + + + + + + + + drawer.close(system)} + /> + ); }} diff --git a/frontend-modern/src/features/vmware/VsphereHostsTable.tsx b/frontend-modern/src/features/vmware/VsphereHostsTable.tsx index dedd2d92fd..8cf0c1a565 100644 --- a/frontend-modern/src/features/vmware/VsphereHostsTable.tsx +++ b/frontend-modern/src/features/vmware/VsphereHostsTable.tsx @@ -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(); @@ -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 ( - - -
- - - {name()} - -
-
- - - - - - - - +
+ + + {name()} + +
+
+
+ + + + + + + + + {vmCount()} + + + + drawer.close(host)} + /> + ); }}