Fix Docker container table overflow

This commit is contained in:
rcourtman
2026-06-12 09:22:21 +01:00
parent 9068f3bc6c
commit f6a13341a0
4 changed files with 437 additions and 186 deletions
@@ -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<DockerNativeTableProps> = (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<DockerNativeTableProps> = (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<DockerNativeTableProps> = (props)
>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<TableCardHeader title={props.title ?? 'Containers'} />
<Table class="min-w-[1010px] table-fixed text-xs md:min-w-[1900px]">
<Table class={`${getDockerContainerTableMinWidthClass()} table-fixed text-xs`}>
<colgroup>
<For each={visibleColumns()}>
{(column) => (
<col
style={getDockerContainerColumnWidthStyle(
column.id,
layoutMode(),
visibleColumnIds(),
)}
/>
)}
</For>
</colgroup>
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} w-[220px]`}>
Container
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[140px]`}
>
Host
</TableHead>
<Show when={showRuntimeColumn()}>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[130px]`}
>
Runtime
</TableHead>
</Show>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} w-[260px]`}>
Image
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} w-[100px]`}>
State
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[100px]`}
>
Health
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('metric-bar')} w-[110px] md:w-[140px]`}
>
CPU
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('metric-bar')} w-[110px] md:w-[140px]`}
>
<span class="md:hidden">Mem</span>
<span class="hidden md:inline">Memory</span>
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} w-[110px]`}
>
Restarts
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[190px]`}
>
Ports
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[150px]`}
>
Networks
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[180px]`}
>
Mounts
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} w-[130px]`}>
Updates
</TableHead>
<For each={visibleColumns()}>
{(column) => (
<TableHead class={getPlatformTableHeadClassForKind(column.kind)}>
<Show when={column.id === 'memory'} fallback={<>{column.label}</>}>
<span class="md:hidden">Mem</span>
<span class="hidden md:inline">Memory</span>
</Show>
</TableHead>
)}
</For>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
@@ -379,6 +356,173 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (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 (
<DockerResourceNameCell resource={resource} indicator={indicator} />
);
case 'host':
return (
<TableCell
class={`${getPlatformTableCellClassForKind(column.kind)} text-base-content`}
>
<span class="block max-w-full truncate" title={host()}>
{host()}
</span>
</TableCell>
);
case 'runtime':
return (
<TableCell
class={`${getPlatformTableCellClassForKind(column.kind)} text-base-content`}
>
<span class="block max-w-full truncate" title={runtime()}>
{runtime()}
</span>
</TableCell>
);
case 'image':
return (
<TableCell
class={`${getPlatformTableCellClassForKind(column.kind)} text-base-content`}
>
<span class="block max-w-full truncate" title={image()}>
{image()}
</span>
</TableCell>
);
case 'state':
return (
<TableCell
class={`${getPlatformTableCellClassForKind(column.kind)} text-base-content`}
>
<span class="block max-w-full truncate" title={state()}>
{state()}
</span>
</TableCell>
);
case 'health':
return (
<TableCell
class={`${getPlatformTableCellClassForKind(column.kind)} text-base-content`}
>
<span class="block max-w-full truncate" title={health()}>
{health()}
</span>
</TableCell>
);
case 'cpu':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<ResponsiveMetricCell
class="w-full"
value={cpuPercent() ?? 0}
type="cpu"
resourceId={metricsKey()}
isRunning={running() && cpuPercent() !== undefined}
showMobile={false}
/>
</TableCell>
);
case 'memory':
return (
<TableCell class={getPlatformTableCellClassForKind(column.kind)}>
<Show
when={running() && hasMemoryMetric()}
fallback={metricFallback()}
>
<StackedMemoryBar
used={memoryUsed()}
total={memoryTotal()}
percentOnly={memoryPercentOnly()}
/>
</Show>
</TableCell>
);
case 'restarts':
return (
<TableCell
class={`${getPlatformTableCellClassForKind(column.kind)} text-base-content`}
>
<Show
when={typeof resource.docker?.restartCount === 'number'}
fallback={<span>—</span>}
>
<span
class={`tabular-nums ${
restartCount() > RESTART_ATTENTION_THRESHOLD
? 'font-medium text-red-600 dark:text-red-400'
: ''
}`}
>
{resource.docker?.restartCount}
</span>
</Show>
</TableCell>
);
case 'ports':
return (
<TableCell
class={`${getPlatformTableCellClassForKind(column.kind)} text-base-content`}
>
<span
class="block max-w-full truncate font-mono text-[11px]"
title={ports()}
>
{ports()}
</span>
</TableCell>
);
case 'networks':
return (
<TableCell
class={`${getPlatformTableCellClassForKind(column.kind)} text-base-content`}
>
<span class="block max-w-full truncate" title={networks()}>
{networks()}
</span>
</TableCell>
);
case 'mounts':
return (
<TableCell
class={`${getPlatformTableCellClassForKind(column.kind)} text-base-content`}
>
<span class="block max-w-full truncate" title={mounts()}>
{mounts()}
</span>
</TableCell>
);
case 'updates':
return (
<TableCell
class={`${getPlatformTableCellClassForKind(column.kind)} text-base-content`}
>
<Show
when={action}
fallback={
<span class="block max-w-full truncate" title={updateTitle()}>
{updates()}
</span>
}
>
{(updateAction) => (
<UpdateButton
agentId={updateAction().agentId}
containerId={updateAction().containerId}
containerName={updateAction().containerName}
updateStatus={updateAction().updateStatus}
/>
)}
</Show>
</TableCell>
);
default:
column.id satisfies never;
return <></>;
}
};
return (
<>
@@ -391,129 +535,7 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
onKeyDown={drawer.handleActivationKey(resource)}
tabIndex={0}
>
<DockerResourceNameCell resource={resource} indicator={indicator} />
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span class="block max-w-full truncate" title={host()}>
{host()}
</span>
</TableCell>
<Show when={showRuntimeColumn()}>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span class="block max-w-full truncate" title={runtime()}>
{runtime()}
</span>
</TableCell>
</Show>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span class="block max-w-full truncate" title={image()}>
{image()}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
{state()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
{health()}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('metric-bar')} w-[110px] md:w-auto`}
>
<ResponsiveMetricCell
class="w-full"
value={cpuPercent() ?? 0}
type="cpu"
resourceId={metricsKey()}
isRunning={running() && cpuPercent() !== undefined}
showMobile={false}
/>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('metric-bar')} w-[110px] md:w-auto`}
>
<Show
when={running() && hasMemoryMetric()}
fallback={metricFallback()}
>
<StackedMemoryBar
used={memoryUsed()}
total={memoryTotal()}
percentOnly={memoryPercentOnly()}
/>
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
<Show
when={typeof resource.docker?.restartCount === 'number'}
fallback={<span>—</span>}
>
<span
class={`tabular-nums ${
restartCount() > RESTART_ATTENTION_THRESHOLD
? 'font-medium text-red-600 dark:text-red-400'
: ''
}`}
>
{resource.docker?.restartCount}
</span>
</Show>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span
class="block max-w-full truncate font-mono text-[11px]"
title={ports()}
>
{ports()}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span class="block max-w-full truncate" title={networks()}>
{networks()}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span class="block max-w-full truncate" title={mounts()}>
{mounts()}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<Show
when={action}
fallback={
<span class="block max-w-full truncate" title={updateTitle()}>
{updates()}
</span>
}
>
{(updateAction) => (
<UpdateButton
agentId={updateAction().agentId}
containerId={updateAction().containerId}
containerName={updateAction().containerName}
updateStatus={updateAction().updateStatus}
/>
)}
</Show>
</TableCell>
<For each={visibleColumns()}>{(column) => renderColumnCell(column)}</For>
</TableRow>
<PlatformResourceDetailTableRow
resource={resource}
@@ -86,13 +86,24 @@ const renderInRouter = (component: () => JSX.Element) =>
</Router>
));
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(() => (
<DockerContainersTable
resources={[
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import {
getDockerContainerColumnWidthStyle,
getDockerContainerTableMinWidthClass,
getDockerContainerVisibleColumnsForLayout,
} from '../dockerContainerTableModel';
describe('dockerContainerTableModel', () => {
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');
});
});
@@ -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<WorkloadTableLayoutMode, number> = {
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<DockerContainerTableColumnId, number> = {
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<WorkloadTableLayoutMode, 'wide'>,
Partial<Record<DockerContainerTableColumnId, number>>
> = {
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';