From 3c49dd2ee93326261f1a3e976b8ee0ae62bfc3e5 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 11 Jun 2026 20:39:05 +0100 Subject: [PATCH] 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. --- .../features/docker/DockerContainersTable.tsx | 128 +++++++++++++++--- .../features/docker/DockerServicesTable.tsx | 18 ++- .../features/docker/DockerVolumesTable.tsx | 21 ++- .../__tests__/DockerNativeTables.test.tsx | 106 ++++++++++++++- .../docker/__tests__/dockerPageModel.test.ts | 37 +++++ .../src/features/docker/dockerPageModel.ts | 19 +++ 6 files changed, 307 insertions(+), 22 deletions(-) diff --git a/frontend-modern/src/features/docker/DockerContainersTable.tsx b/frontend-modern/src/features/docker/DockerContainersTable.tsx index 64067d31cd..98b67f76db 100644 --- a/frontend-modern/src/features/docker/DockerContainersTable.tsx +++ b/frontend-modern/src/features/docker/DockerContainersTable.tsx @@ -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 = () => ( +
+ +
+); + +// 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 = (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(); + 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 = (props) > - +
@@ -266,11 +298,13 @@ export const DockerContainersTable: Component = (props) > Host - + + + Image @@ -282,6 +316,17 @@ export const DockerContainersTable: Component = (props) > Health + + CPU + + + Mem + + @@ -316,6 +361,16 @@ export const DockerContainersTable: Component = (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 = (props) {host()} - + + + @@ -368,10 +425,49 @@ export const DockerContainersTable: Component = (props) > {health()} + + + + + + + + - {dockerNumberValue(resource.docker?.restartCount)} + —} + > + RESTART_ATTENTION_THRESHOLD + ? 'font-medium text-red-600 dark:text-red-400' + : '' + }`} + > + {resource.docker?.restartCount} + + + ({ 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 }) => ( +
+ ), +})); + +vi.mock('@/components/Workloads/StackedMemoryBar', () => ({ + StackedMemoryBar: (props: { used: number; total: number; percentOnly?: number }) => ( +
+ ), +})); + 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={} 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(() => ( + } + 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(); diff --git a/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts b/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts index 32141c7fb6..4ce4aed05c 100644 --- a/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts +++ b/frontend-modern/src/features/docker/__tests__/dockerPageModel.test.ts @@ -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', diff --git a/frontend-modern/src/features/docker/dockerPageModel.ts b/frontend-modern/src/features/docker/dockerPageModel.ts index 27e56f9bed..73ed4bc38e 100644 --- a/frontend-modern/src/features/docker/dockerPageModel.ts +++ b/frontend-modern/src/features/docker/dockerPageModel.ts @@ -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 & { 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)