diff --git a/frontend-modern/src/features/docker/DockerContainersTable.tsx b/frontend-modern/src/features/docker/DockerContainersTable.tsx index 98b67f76db..2d9fa9c6f4 100644 --- a/frontend-modern/src/features/docker/DockerContainersTable.tsx +++ b/frontend-modern/src/features/docker/DockerContainersTable.tsx @@ -1,11 +1,13 @@ -import { For, Show, createMemo, type Component } from 'solid-js'; +import { For, Show, createMemo, type Component, type JSX } from 'solid-js'; import { useSearchParams } from '@solidjs/router'; import type { FilterDef } from '@/components/shared/FilterBar'; import { UpdateButton } from '@/components/shared/ContainerUpdateBadge'; import { ResponsiveMetricCell } from '@/components/shared/responsive'; import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar'; +import { getWorkloadTableLayoutMode } from '@/components/Workloads/guestRowModel'; import { TableCard } from '@/components/shared/TableCard'; import { TableCardHeader } from '@/components/shared/TableCardHeader'; +import { useBreakpoint } from '@/hooks/useBreakpoint'; import { Table, TableBody, @@ -47,6 +49,12 @@ import { mapDockerContainerStatus, type DockerResourceStatusFilter, } from './dockerPageModel'; +import { + getDockerContainerColumnWidthStyle, + getDockerContainerTableMinWidthClass, + getDockerContainerVisibleColumnsForLayout, + type DockerContainerTableColumn, +} from './dockerContainerTableModel'; import type { DockerContainerUpdateStatus } from '@/types/api'; import type { Resource } from '@/types/resource'; @@ -182,6 +190,8 @@ const containerUpdateAction = ( }; export const DockerContainersTable: Component = (props) => { + const breakpoint = useBreakpoint(); + const layoutMode = createMemo(() => getWorkloadTableLayoutMode(breakpoint.width())); const tableState = createPlatformTableFilterState({ resources: () => props.resources, initialStatus: 'all' as DockerResourceStatusFilter, @@ -241,7 +251,11 @@ export const DockerContainersTable: Component = (props) } return kinds.size > 1; }); - const drawerColSpan = createMemo(() => (showRuntimeColumn() ? 13 : 12)); + const visibleColumns = createMemo(() => + getDockerContainerVisibleColumnsForLayout(layoutMode(), showRuntimeColumn()), + ); + const visibleColumnIds = createMemo(() => visibleColumns().map((column) => column.id)); + const drawerColSpan = createMemo(() => visibleColumns().length); const drawer = createPlatformResourceDetailState({ idPrefix: 'docker-container-drawer' }); const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.resources); @@ -287,69 +301,32 @@ export const DockerContainersTable: Component = (props) > - +
+ + + {(column) => ( + + )} + + - - Container - - - - - - - Image - - - State - - - - CPU - - - Mem - - - - Restarts - - - - - - Updates - + + {(column) => ( + + {column.label}}> + Mem + + + + )} + @@ -379,6 +356,173 @@ export const DockerContainersTable: Component = (props) const action = containerUpdateAction(resource); const detailRowId = () => drawer.detailRowId(resource); const isExpanded = () => drawer.isExpanded(resource); + const renderColumnCell = (column: DockerContainerTableColumn): JSX.Element => { + switch (column.id) { + case 'container': + return ( + + ); + case 'host': + return ( + + + {host()} + + + ); + case 'runtime': + return ( + + + {runtime()} + + + ); + case 'image': + return ( + + + {image()} + + + ); + case 'state': + return ( + + + {state()} + + + ); + case 'health': + return ( + + + {health()} + + + ); + case 'cpu': + return ( + + + + ); + case 'memory': + return ( + + + + + + ); + case 'restarts': + return ( + + —} + > + RESTART_ATTENTION_THRESHOLD + ? 'font-medium text-red-600 dark:text-red-400' + : '' + }`} + > + {resource.docker?.restartCount} + + + + ); + case 'ports': + return ( + + + {ports()} + + + ); + case 'networks': + return ( + + + {networks()} + + + ); + case 'mounts': + return ( + + + {mounts()} + + + ); + case 'updates': + return ( + + + {updates()} + + } + > + {(updateAction) => ( + + )} + + + ); + default: + column.id satisfies never; + return <>; + } + }; return ( <> @@ -391,129 +535,7 @@ export const DockerContainersTable: Component = (props) onKeyDown={drawer.handleActivationKey(resource)} tabIndex={0} > - - - - - - - - {image()} - - - - {state()} - - - - - - - - - - - - —} - > - RESTART_ATTENTION_THRESHOLD - ? 'font-medium text-red-600 dark:text-red-400' - : '' - }`} - > - {resource.docker?.restartCount} - - - - - - - - - {updates()} - - } - > - {(updateAction) => ( - - )} - - + {(column) => renderColumnCell(column)} JSX.Element) => )); +const setViewportWidth = (width: number) => { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + writable: true, + value: width, + }); +}; + afterEach(() => { + setViewportWidth(1024); cleanup(); vi.clearAllMocks(); }); describe('Docker native tables', () => { it('renders Docker container API fields', () => { + setViewportWidth(1500); + renderInRouter(() => ( { + it('keeps the mobile container table on identity, state, live metrics, and update action', () => { + const columns = getDockerContainerVisibleColumnsForLayout('mobile', true); + const ids = columns.map((column) => column.id); + + expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'updates']); + expect(getDockerContainerTableMinWidthClass()).toBe('min-w-full'); + expect(getDockerContainerColumnWidthStyle('container', 'mobile', ids)).toEqual({ + width: '32%', + }); + expect(getDockerContainerColumnWidthStyle('memory', 'mobile', ids)).toEqual({ + width: '22%', + }); + }); + + it('adds host and restart signal before slower forensic fields on tablet', () => { + expect( + getDockerContainerVisibleColumnsForLayout('tablet', true).map((column) => column.id), + ).toEqual(['container', 'host', 'state', 'cpu', 'memory', 'restarts', 'updates']); + }); + + it('keeps compact desktop scan-focused and hides wide forensic columns', () => { + const columns = getDockerContainerVisibleColumnsForLayout('compact', true); + const ids = columns.map((column) => column.id); + + expect(ids).toEqual([ + 'container', + 'host', + 'runtime', + 'image', + 'state', + 'cpu', + 'memory', + 'restarts', + 'ports', + 'updates', + ]); + expect(ids).not.toContain('health'); + expect(ids).not.toContain('networks'); + expect(ids).not.toContain('mounts'); + }); + + it('shows runtime only for mixed Docker and Podman fleets', () => { + const compactIds = getDockerContainerVisibleColumnsForLayout('compact', false).map( + (column) => column.id, + ); + const wideIds = getDockerContainerVisibleColumnsForLayout('wide', false).map( + (column) => column.id, + ); + + expect(compactIds).not.toContain('runtime'); + expect(wideIds).not.toContain('runtime'); + expect(wideIds).toContain('networks'); + expect(wideIds).toContain('mounts'); + }); +}); diff --git a/frontend-modern/src/features/docker/dockerContainerTableModel.ts b/frontend-modern/src/features/docker/dockerContainerTableModel.ts new file mode 100644 index 0000000000..c89a04a913 --- /dev/null +++ b/frontend-modern/src/features/docker/dockerContainerTableModel.ts @@ -0,0 +1,154 @@ +import type { JSX } from 'solid-js'; + +import type { WorkloadTableLayoutMode } from '@/components/Workloads/guestRowModel'; +import type { PlatformTableColumnKind } from '@/features/platformPage/columnAlignment'; + +export type DockerContainerTableColumnId = + | 'container' + | 'host' + | 'runtime' + | 'image' + | 'state' + | 'health' + | 'cpu' + | 'memory' + | 'restarts' + | 'ports' + | 'networks' + | 'mounts' + | 'updates'; + +export type DockerContainerTableColumn = { + id: DockerContainerTableColumnId; + label: string; + kind: PlatformTableColumnKind; +}; + +const DOCKER_CONTAINER_TABLE_LAYOUT_ORDER: Record = { + mobile: 0, + tablet: 1, + compact: 2, + wide: 3, +}; + +const DOCKER_CONTAINER_COLUMN_MIN_LAYOUT: Record< + DockerContainerTableColumnId, + WorkloadTableLayoutMode +> = { + container: 'mobile', + state: 'mobile', + cpu: 'mobile', + memory: 'mobile', + updates: 'mobile', + host: 'tablet', + restarts: 'tablet', + image: 'compact', + runtime: 'compact', + ports: 'compact', + health: 'wide', + networks: 'wide', + mounts: 'wide', +}; + +const DOCKER_CONTAINER_COLUMNS: DockerContainerTableColumn[] = [ + { id: 'container', label: 'Container', kind: 'name' }, + { id: 'host', label: 'Host', kind: 'text' }, + { id: 'runtime', label: 'Runtime', kind: 'text' }, + { id: 'image', label: 'Image', kind: 'text' }, + { id: 'state', label: 'State', kind: 'text' }, + { id: 'health', label: 'Health', kind: 'text' }, + { id: 'cpu', label: 'CPU', kind: 'metric-bar' }, + { id: 'memory', label: 'Memory', kind: 'metric-bar' }, + { id: 'restarts', label: 'Restarts', kind: 'numeric-value' }, + { id: 'ports', label: 'Ports', kind: 'text' }, + { id: 'networks', label: 'Networks', kind: 'text' }, + { id: 'mounts', label: 'Mounts', kind: 'text' }, + { id: 'updates', label: 'Updates', kind: 'badge' }, +]; + +const DOCKER_CONTAINER_DESKTOP_WIDTHS: Record = { + container: 16, + host: 8, + runtime: 7, + image: 16, + state: 6, + health: 6, + cpu: 9, + memory: 10, + restarts: 6, + ports: 10, + networks: 8, + mounts: 9, + updates: 7, +}; + +const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record< + Exclude, + Partial> +> = { + mobile: { + container: 32, + state: 14, + cpu: 18, + memory: 22, + updates: 14, + }, + tablet: { + container: 28, + host: 16, + state: 10, + cpu: 15, + memory: 18, + restarts: 6, + updates: 7, + }, + compact: { + container: 18, + host: 10, + runtime: 8, + image: 20, + state: 7, + cpu: 11, + memory: 12, + restarts: 7, + ports: 12, + updates: 8, + }, +}; + +const formatPercentage = (value: number): string => `${Number(value.toFixed(4))}%`; + +export const getDockerContainerVisibleColumnsForLayout = ( + layoutMode: WorkloadTableLayoutMode, + includeRuntime: boolean, +): DockerContainerTableColumn[] => { + const layoutRank = DOCKER_CONTAINER_TABLE_LAYOUT_ORDER[layoutMode]; + return DOCKER_CONTAINER_COLUMNS.filter((column) => { + if (column.id === 'runtime' && !includeRuntime) return false; + return ( + DOCKER_CONTAINER_TABLE_LAYOUT_ORDER[DOCKER_CONTAINER_COLUMN_MIN_LAYOUT[column.id]] <= + layoutRank + ); + }); +}; + +export const getDockerContainerColumnWidthStyle = ( + columnId: DockerContainerTableColumnId, + layoutMode: WorkloadTableLayoutMode, + visibleColumnIds: readonly DockerContainerTableColumnId[], +): JSX.CSSProperties => { + const weights = + layoutMode === 'wide' + ? DOCKER_CONTAINER_DESKTOP_WIDTHS + : DOCKER_CONTAINER_RESPONSIVE_WIDTHS[layoutMode]; + const columnWeight = weights[columnId] ?? 0; + const totalWeight = visibleColumnIds.reduce((total, id) => total + (weights[id] ?? 0), 0); + const width = totalWeight > 0 ? (columnWeight / totalWeight) * 100 : 0; + + return { width: formatPercentage(width) }; +}; + +// The Docker container table already has a row detail drawer for forensic +// fields. The overview should fit its page at every layout and reveal columns +// by priority instead of forcing a desktop scrollbar. +export const getDockerContainerTableMinWidthClass = (): 'min-w-full' => 'min-w-full';