docker(tables): restore v5 information to the Docker page tables

The v6 Docker page regressed against its v5 ancestor (same disease as
the physical disks table, #1471): container CPU/memory flowed all the
way into the unified payload but no column rendered them, so a container
pegged at 75% CPU looked identical to an idle one. Labels (compose
project/service, traefik rules) and the Swarm stack name shipped in the
payload but were unrendered and unsearchable.

- Add always-visible CPU and Memory metric-bar columns to the containers
  table (ResponsiveMetricCell / StackedMemoryBar, same primitives as the
  hosts table above it).
- Flag crash-loopers: restart counts above 5 render red, matching the
  v5 threshold.
- Demote the Runtime column to mixed-runtime fleets only; it repeated
  host-level data on every container row (the #1471 Source-badge
  pattern) and the Host column sits right next to it.
- Add a Stack column to the Swarm services table, derived from the
  serialized stack field's canonical com.docker.stack.namespace label.
- Make container/service labels and stack names searchable via the
  Docker search haystack (v5 searched labels).
- Render volume Created as relative time with the absolute timestamp in
  the tooltip instead of a raw ISO 8601 string.
This commit is contained in:
rcourtman
2026-06-11 20:39:05 +01:00
parent 2fcf79f32a
commit 3c49dd2ee9
6 changed files with 307 additions and 22 deletions
@@ -2,6 +2,8 @@ import { For, Show, createMemo, type Component } 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 { TableCard } from '@/components/shared/TableCard';
import { TableCardHeader } from '@/components/shared/TableCardHeader';
import {
@@ -13,6 +15,7 @@ import {
TableRow,
} from '@/components/shared/Table';
import { asTrimmedString } from '@/utils/stringUtils';
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
PLATFORM_TABLE_BODY_CLASS,
@@ -34,7 +37,6 @@ import {
DockerResourceNameCell,
dockerHostName,
dockerJoinValues,
dockerNumberValue,
dockerTextValue,
type DockerNativeTableProps,
} from './DockerNativeTableShared';
@@ -88,6 +90,25 @@ const runtimeSummary = (resource: Resource): string => {
const containerState = (resource: Resource): string =>
dockerTextValue(resource.docker?.containerState || resource.status);
const isContainerRunning = (resource: Resource): boolean =>
(asTrimmedString(resource.docker?.containerState || resource.status) ?? '').toLowerCase() ===
'running';
const finiteMetric = (value: number | undefined): number | undefined =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
const metricFallback = () => (
<div class="flex justify-center">
<span class="text-xs text-muted" aria-hidden="true">
—
</span>
</div>
);
// v5 flagged crash-loopers in the restarts column; a container that restarted
// more than this many times needs an operator's eye even while "running".
const RESTART_ATTENTION_THRESHOLD = 5;
const updateStatusLabel = (resource: Resource): string => {
const update = resource.docker?.updateStatus;
if (!update) return '—';
@@ -210,6 +231,17 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
setHostFilter('');
};
const sortedRows = createMemo(() => [...scopedRows()].sort(compareDockerContainers));
// Runtime is host-level data repeated on every container row; it only
// informs when the fleet actually mixes runtimes (docker vs podman).
const showRuntimeColumn = createMemo(() => {
const kinds = new Set<string>();
for (const resource of props.resources) {
const kind = (asTrimmedString(resource.docker?.runtime) ?? '').toLowerCase();
if (kind) kinds.add(kind);
}
return kinds.size > 1;
});
const drawerColSpan = createMemo(() => (showRuntimeColumn() ? 13 : 12));
const drawer = createPlatformResourceDetailState({ idPrefix: 'docker-container-drawer' });
const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.resources);
@@ -255,7 +287,7 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
>
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
<TableCardHeader title={props.title ?? 'Containers'} />
<Table class="min-w-[790px] table-fixed text-xs md:min-w-[1680px]">
<Table class="min-w-[1010px] table-fixed text-xs md:min-w-[1900px]">
<TableHeader>
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} w-[220px]`}>
@@ -266,11 +298,13 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
>
Host
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[130px]`}
>
Runtime
</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>
@@ -282,6 +316,17 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
>
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]`}
>
@@ -316,6 +361,16 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
const health = () => dockerTextValue(resource.docker?.health);
const runtime = () => runtimeSummary(resource);
const host = () => dockerHostName(resource);
const running = () => isContainerRunning(resource);
const metricsKey = () => buildMetricKeyForUnifiedResource(resource);
const cpuPercent = () => finiteMetric(resource.cpu?.current);
const memoryUsed = () => finiteMetric(resource.memory?.used) ?? 0;
const memoryTotal = () => finiteMetric(resource.memory?.total) ?? 0;
const memoryPercentOnly = () =>
memoryTotal() > 0 ? undefined : finiteMetric(resource.memory?.current);
const hasMemoryMetric = () =>
memoryTotal() > 0 || memoryPercentOnly() !== undefined;
const restartCount = () => resource.docker?.restartCount ?? 0;
const ports = () => dockerContainerPortsSummary(resource);
const networks = () => networksSummary(resource);
const mounts = () => mountsSummary(resource);
@@ -344,13 +399,15 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
{host()}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span class="block max-w-full truncate" title={runtime()}>
{runtime()}
</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`}
>
@@ -368,10 +425,49 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
>
{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`}
>
{dockerNumberValue(resource.docker?.restartCount)}
<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`}
@@ -423,7 +519,7 @@ export const DockerContainersTable: Component<DockerNativeTableProps> = (props)
resource={resource}
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={11}
colSpan={drawerColSpan()}
resolveResourceLabel={resolveResourceLabel}
onClose={() => drawer.close(resource)}
/>
@@ -25,6 +25,7 @@ import {
import type { Resource } from '@/types/resource';
import {
compareDockerServices,
dockerServiceStack,
filterDockerResources,
mapDockerServiceStatus,
type DockerResourceStatusFilter,
@@ -154,11 +155,16 @@ export const DockerServicesTable: Component<{
Host get middle slices for rollout state, port lists, and
hostnames. Mobile widths are unchanged.
*/}
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[18%]`}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[16%]`}>
Service
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[22%]`}
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[9%]`}
>
Stack
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[19%]`}
>
Image
</TableHead>
@@ -196,6 +202,7 @@ export const DockerServicesTable: Component<{
<For each={sortedRows()}>
{(service) => {
const name = () => asTrimmedString(service.name) || service.id;
const stack = () => dockerServiceStack(service) || '—';
const image = () => asTrimmedString(service.docker?.image) || '—';
const mode = () => asTrimmedString(service.docker?.mode) || '—';
const host = () => asTrimmedString(service.docker?.hostname) || '—';
@@ -216,6 +223,13 @@ export const DockerServicesTable: Component<{
</span>
</div>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
<span class="truncate inline-block max-w-[8rem]" title={stack()}>
{stack()}
</span>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
@@ -34,6 +34,23 @@ import {
type DockerNativeTableProps,
} from './DockerNativeTableShared';
import { filterDockerResources, type DockerResourceStatusFilter } from './dockerPageModel';
import { formatRelativeTime } from '@/utils/format';
const parseCreatedAt = (value: string | undefined): number | null => {
if (!value) return null;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
};
const createdRelative = (value: string | undefined): string => {
const parsed = parseCreatedAt(value);
return parsed === null ? dockerTextValue(value) : formatRelativeTime(parsed);
};
const createdAbsolute = (value: string | undefined): string => {
const parsed = parseCreatedAt(value);
return parsed === null ? dockerTextValue(value) : new Date(parsed).toLocaleString();
};
export const DockerVolumesTable: Component<DockerNativeTableProps> = (props) => {
const tableState = createPlatformTableFilterState({
@@ -164,9 +181,9 @@ export const DockerVolumesTable: Component<DockerNativeTableProps> = (props) =>
>
<span
class="inline-block max-w-[12rem] truncate"
title={dockerTextValue(resource.docker?.createdAt)}
title={createdAbsolute(resource.docker?.createdAt)}
>
{dockerTextValue(resource.docker?.createdAt)}
{createdRelative(resource.docker?.createdAt)}
</span>
</TableCell>
<TableCell
@@ -36,6 +36,29 @@ vi.mock('@/stores/systemSettings', () => ({
shouldHideDockerUpdateActions: () => false,
}));
// jsdom has no ResizeObserver; stub the shared metric cells like the
// DockerHostsTable tests do.
vi.mock('@/components/shared/responsive', () => ({
ResponsiveMetricCell: (props: { type: string; isRunning?: boolean; resourceId?: string }) => (
<div
data-testid={`responsive-${props.type}-metric`}
data-resource-id={props.resourceId ?? ''}
data-running={String(props.isRunning)}
/>
),
}));
vi.mock('@/components/Workloads/StackedMemoryBar', () => ({
StackedMemoryBar: (props: { used: number; total: number; percentOnly?: number }) => (
<div
data-testid="stacked-memory-bar"
data-used={String(props.used)}
data-total={String(props.total)}
data-percent-only={String(props.percentOnly ?? '')}
/>
),
}));
const makeResource = ({
id,
type,
@@ -78,6 +101,8 @@ describe('Docker native tables', () => {
type: 'app-container',
name: 'edge-web',
status: 'running',
cpu: { current: 42 },
memory: { current: 50, used: 512 * 1024 * 1024, total: 1024 * 1024 * 1024 },
docker: {
hostname: 'edge-01',
runtime: 'docker',
@@ -105,6 +130,21 @@ describe('Docker native tables', () => {
},
},
}),
makeResource({
id: 'container-2',
type: 'app-container',
name: 'edge-cache',
status: 'running',
docker: {
hostname: 'edge-02',
runtime: 'podman',
runtimeVersion: '5.2.1',
image: 'redis:7.4',
containerState: 'running',
health: 'healthy',
restartCount: 7,
},
}),
]}
emptyIcon={<span />}
emptyTitle="No containers"
@@ -115,18 +155,74 @@ describe('Docker native tables', () => {
expect(screen.getByText('Container')).toBeInTheDocument();
expect(screen.getByText('Runtime')).toBeInTheDocument();
expect(screen.getByText('CPU')).toBeInTheDocument();
expect(screen.getByText('Memory')).toBeInTheDocument();
expect(screen.getByText('Restarts')).toBeInTheDocument();
expect(screen.getByText('Updates')).toBeInTheDocument();
expect(screen.getByText('edge-web')).toBeInTheDocument();
expect(screen.getByText('edge-01')).toBeInTheDocument();
expect(screen.getByText('docker 27.5.1')).toBeInTheDocument();
expect(screen.getByText('podman 5.2.1')).toBeInTheDocument();
expect(screen.getByText('nginx:latest')).toBeInTheDocument();
expect(screen.getByText('healthy')).toBeInTheDocument();
expect(screen.getAllByText('healthy').length).toBe(2);
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('0.0.0.0:8080->80/tcp')).toBeInTheDocument();
expect(screen.getByText('frontend 172.18.0.2')).toBeInTheDocument();
expect(screen.getByText('volume:/usr/share/nginx/html (rw)')).toBeInTheDocument();
expect(screen.getByText('Available')).toBeInTheDocument();
// A crash-looper (restarts above the v5 attention threshold) is flagged.
expect(screen.getByText('7')).toHaveClass('text-red-600');
expect(screen.getByText('2')).not.toHaveClass('text-red-600');
// Both rows get a CPU cell; only the row with memory data gets a bar.
expect(screen.getAllByTestId('responsive-cpu-metric').length).toBe(2);
expect(screen.getByTestId('stacked-memory-bar')).toHaveAttribute(
'data-used',
String(512 * 1024 * 1024),
);
});
it('hides the Runtime column when every container reports the same runtime', () => {
renderInRouter(() => (
<DockerContainersTable
resources={[
makeResource({
id: 'container-1',
type: 'app-container',
name: 'edge-web',
status: 'running',
docker: {
hostname: 'edge-01',
runtime: 'docker',
runtimeVersion: '27.5.1',
image: 'nginx:latest',
containerState: 'running',
},
}),
makeResource({
id: 'container-2',
type: 'app-container',
name: 'edge-cache',
status: 'running',
docker: {
hostname: 'edge-02',
runtime: 'docker',
runtimeVersion: '27.5.1',
image: 'redis:7.4',
containerState: 'running',
},
}),
]}
emptyIcon={<span />}
emptyTitle="No containers"
emptyDescription="No containers"
showToolbar={false}
/>
));
expect(screen.queryByText('Runtime')).not.toBeInTheDocument();
expect(screen.queryByText('docker 27.5.1')).not.toBeInTheDocument();
});
it('renders actionable Docker container updates with native agent and container IDs', async () => {
@@ -279,7 +375,10 @@ describe('Docker native tables', () => {
expect(screen.getByText('app-data')).toBeInTheDocument();
expect(screen.getByText('local')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('2026-05-24T13:00:00Z')).toBeInTheDocument();
// Created renders as a relative time with the absolute timestamp in the
// tooltip, not as a raw ISO string.
expect(screen.queryByText('2026-05-24T13:00:00Z')).not.toBeInTheDocument();
expect(screen.getByText(/ago$/)).toBeInTheDocument();
expect(screen.getByText('/var/lib/docker/volumes/app-data/_data')).toBeInTheDocument();
});
@@ -611,6 +710,7 @@ describe('Docker native tables', () => {
mode: 'replicated',
desiredTasks: 4,
runningTasks: 2,
labels: { 'com.docker.stack.namespace': 'shop' },
endpointPorts: [
{ protocol: 'tcp', targetPort: 8080, publishedPort: 18080, publishMode: 'ingress' },
],
@@ -629,6 +729,8 @@ describe('Docker native tables', () => {
));
expect(screen.getByText('Update')).toBeInTheDocument();
expect(screen.getByText('Stack')).toBeInTheDocument();
expect(screen.getByText('shop')).toBeInTheDocument();
expect(screen.getByText('checkout-api')).toBeInTheDocument();
expect(screen.getByText('registry.example.com/checkout-api:2026.05')).toBeInTheDocument();
expect(screen.getByText('replicated')).toBeInTheDocument();
@@ -790,6 +790,43 @@ describe('dockerPageModel', () => {
]);
});
it('matches container labels and Swarm stack names', () => {
const labelled: Resource[] = [
...rows,
makeResource({
id: 'compose-db',
type: 'app-container',
status: 'online',
docker: {
containerState: 'running',
labels: {
'com.docker.compose.project': 'orion',
'com.docker.compose.service': 'database',
},
},
}),
makeResource({
id: 'svc-stacked',
type: 'docker-service',
status: 'online',
docker: {
serviceName: 'shop-api',
labels: { 'com.docker.stack.namespace': 'shopstack' },
},
}),
];
expect(filterDockerResources(labelled, 'orion', 'all').map((r) => r.id)).toEqual([
'compose-db',
]);
expect(filterDockerResources(labelled, 'database', 'all').map((r) => r.id)).toEqual([
'compose-db',
]);
expect(filterDockerResources(labelled, 'shopstack', 'all').map((r) => r.id)).toEqual([
'svc-stacked',
]);
});
it('still combines status and search', () => {
expect(filterDockerResources(rows, 'payments-worker', 'degraded').map((r) => r.id)).toEqual([
'svc-payments',
@@ -377,6 +377,20 @@ export function buildDockerNetworkAttachmentRows(
.sort((left, right) => compareDockerContainers(left.resource, right.resource));
}
// The API serializes a `stack` field for Swarm services, but the frontend
// ResourceDockerMeta type does not declare it yet; until it does, fall back
// to the canonical Swarm stack label every stack deploy stamps on the
// service.
export const dockerServiceStack = (resource: Resource): string => {
const docker = resource.docker as
| (NonNullable<Resource['docker']> & { stack?: string })
| undefined;
return (
asTrimmedString(docker?.stack) ||
asTrimmedString(docker?.labels?.['com.docker.stack.namespace'])
);
};
// Builds the lowercase search haystack a Docker page table consults when
// filtering rows. The shared platformPage helper carries only generic Resource
// fields; docker.* lookups live here so the cross-platform helper does not
@@ -453,6 +467,11 @@ export function dockerResourceSearchHaystack(resource: Resource): string {
]) ?? []),
...(docker?.repoTags ?? []),
...(docker?.repoDigests ?? []),
// Labels carry the operator-meaningful container organizers (compose
// project/service, Swarm stack, traefik rules); v5 searched them and the
// unified payload still ships them.
...(docker?.labels ? Object.entries(docker.labels).flatMap(([key, value]) => [key, value]) : []),
dockerServiceStack(resource),
...(resource.tags ?? []),
]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)