Show a Docker container's health check in its drawer (#2534)

Follow-up to #2526. The containers table now says Unhealthy or Exited
(139), but the row's drawer still disagreed with it. A running container
whose health check was failing read **Observed state: Online**, and a
crashed one read **Offline**. The drawer never mentioned the health
check, and it could show uptime that a stopped container had left
behind.

For a Docker container, the drawer's Runtime context now uses the same
presentation as the table (`dockerContainerStatePresentation.ts`):

- **Observed state** is Docker's own state, such as Running, or Exited
(137) in red.
- **Health check** is a new row for a running container: Failing (red),
Starting (amber) or Passing.
- **Uptime** covers only the current run. A stopped container shows
neither a stale health check nor leftover uptime.

Other resources are unchanged.

Verification:
- I opened the drawers at 1440px and on a 390px phone, using edited API
data: one container with a failing health check, healthy containers, and
a crashed container carrying a stale unhealthy status and leftover
uptime. I compared each against main.
- Tests cover the helpers and both drawer cases, and the receipt is
included.
This commit is contained in:
rcourtman
2026-10-06 00:17:11 +01:00
committed by GitHub
6 changed files with 216 additions and 11 deletions
@@ -6026,6 +6026,12 @@ container that just restarted; narrower rows keep it in the drawer. The Ports
cell drops the all-interfaces address and folds the IPv4/IPv6 pair Docker
reports for one mapping, keeping a specific bind address; the title, drawer
and search keep the full form.
The resource drawer's Runtime context agrees with that row for a Docker
container: Observed state is Docker's own state (Running, Exited (137)) rather
than the unified online/offline status, a running container's health check
gets its own row (Failing, Starting, Passing), and Uptime is the current run,
so a stopped container shows neither a stale health check nor leftover
uptime. Both surfaces read `dockerContainerStatePresentation.ts`.
ResourceDetailDrawer headings wrap long display names rather than ellipsising
them. Overview rows may still truncate identity; keyboard expansion exposes the
@@ -0,0 +1,37 @@
{
"version": 1,
"base_sha": "1dcf9418aae87f9427dbfc6bcae67dbde114bf8a",
"verified_at": "2026-10-05T20:23:25Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx",
"frontend-modern/src/features/docker/dockerContainerStatePresentation.ts"
],
"content_sha256": {
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "7a2358a79d72268504dc5bad44537f860709b24ed97f7b2c83b5a56e223ac162",
"frontend-modern/src/features/docker/dockerContainerStatePresentation.ts": "8e1daeb5d0210bd31ff0aafede49dac59bbd3de52ccc5582d9cf48b2e5153967"
},
"routes": [
"/docker/containers"
],
"viewports": [
{
"width": 1440,
"height": 1000
},
{
"width": 390,
"height": 1000
}
],
"states": [
"Docker containers with the API response edited so gitea's health check fails and branch-portal-2 exited with code 137 while carrying a stale unhealthy status and leftover uptime (websocket held)",
"gitea's row reads Unhealthy and its drawer Runtime context reads Observed state Running, Health check Failing in red with 'Its health check is failing' on hover, Uptime 2d 5h; main's drawer read Observed state Online with no health check or uptime",
"nginx and customer-portal (healthy) read Running, Health check Passing, with uptime",
"branch-portal-2 reads Exited (137) in red with no Health check or Uptime row; main read Observed state Offline",
"Phone at 390: the same rows after tapping a row open"
],
"interactions": [
"Opened row drawers by click at 1440 and by tap at 390, on main (reverse-applied patch) and on the change"
]
}
@@ -12,6 +12,12 @@ import {
import { TagBadges } from '@/components/shared/TagBadges';
import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format';
import { getDockerImageRegistryLink } from '@/features/docker/dockerImageReference';
import {
getDockerContainerHealthCheckPresentation,
getDockerContainerRunStatePresentation,
getDockerContainerUptimeSeconds,
type DockerContainerStatePresentation,
} from '@/features/docker/dockerContainerStatePresentation';
import { formatInteger } from './resourceDetailMappers';
import { buildKubernetesControllerSection } from './resourceDetailDrawerKubernetesModel';
import type { UseResourceDetailDrawerStateResult } from './useResourceDetailDrawerState';
@@ -72,6 +78,20 @@ const richRow = (
options: Pick<DetailRow, 'title' | 'tone' | 'wrap' | 'valueClass'> = {},
): DetailRow => ({ label, value, valueContent, ...options });
// A Docker container's runtime rows match its table row: Docker's own state,
// the health check on a row of its own while it runs, and uptime only for the
// current run (Docker keeps the last value on a stopped container).
const dockerRuntimeRow = (
label: string,
presentation: DockerContainerStatePresentation | undefined,
) =>
presentation
? makeDetailRow(label, presentation.label, {
title: presentation.title,
tone: presentation.tone ?? 'default',
})
: null;
const dockerSection = (docker: NonNullable<Resource['docker']>): DetailSection => {
const labels = docker.labels ?? {};
const labelEntries = Object.entries(labels);
@@ -192,6 +212,9 @@ const dockerSection = (docker: NonNullable<Resource['docker']>): DetailSection =
export const InlineResourceSummaryTables: Component<ResourceSummaryPresentationProps> = (props) => {
const sections = (): DetailSection[] => {
const docker = dockerContainerMeta(props.resource);
const uptimeSeconds = docker
? getDockerContainerUptimeSeconds(props.resource)
: props.resource.uptime;
const identityRows = compactDetailRows([
...props.drawer.primaryIdentityRows().map((row) => makeDetailRow(row.label, row.value)),
props.showPlatformId ? makeDetailRow('Platform ID', props.resource.platformId) : null,
@@ -271,12 +294,21 @@ export const InlineResourceSummaryTables: Component<ResourceSummaryPresentationP
label: 'Runtime context',
testId: 'resource-runtime-context-section',
rows: compactDetailRows([
makeDetailRow('Observed state', props.resource.status || 'unknown', {
valueClass: 'capitalize',
}),
props.resource.uptime
? makeDetailRow('Uptime', formatUptime(props.resource.uptime))
docker
? dockerRuntimeRow(
'Observed state',
getDockerContainerRunStatePresentation(props.resource),
)
: makeDetailRow('Observed state', props.resource.status || 'unknown', {
valueClass: 'capitalize',
}),
docker
? dockerRuntimeRow(
'Health check',
getDockerContainerHealthCheckPresentation(props.resource),
)
: null,
uptimeSeconds ? makeDetailRow('Uptime', formatUptime(uptimeSeconds)) : null,
props.resource.lastSeen
? makeDetailRow('Last seen', props.drawer.lastSeen() || '—', {
title: props.drawer.lastSeenAbsolute(),
@@ -294,6 +294,57 @@ describe('ResourceDetailDrawer for Docker containers', () => {
expect(within(section).getByText(/traefik\.enable/)).toBeInTheDocument();
});
it('names a failing health check apart from the running state', () => {
render(() => (
<ResourceDetailDrawer
presentation="table-row"
resource={resource({
id: 'app-container-api',
name: 'edge-api',
uptime: 3600,
docker: {
containerId: 'def456abc123',
containerState: 'running',
health: 'unhealthy',
uptimeSeconds: 3600,
},
})}
/>
));
const runtime = screen.getByTestId('resource-runtime-context-section');
expect(within(runtime).getByText('Running')).toBeInTheDocument();
const health = within(runtime).getByText('Failing');
expect(health.closest('td')).toHaveClass('text-rose-700');
expect(health.closest('td')).toHaveAttribute('title', 'Its health check is failing');
expect(within(runtime).getByText('Uptime')).toBeInTheDocument();
});
it('shows a stopped container its exit code without stale health or uptime', () => {
render(() => (
<ResourceDetailDrawer
presentation="table-row"
resource={resource({
id: 'app-container-worker',
name: 'edge-worker',
uptime: 184_082,
docker: {
containerId: 'abc999def000',
containerState: 'exited',
exitCode: 1,
health: 'unhealthy',
uptimeSeconds: 184_082,
},
})}
/>
));
const runtime = screen.getByTestId('resource-runtime-context-section');
expect(within(runtime).getByText('Exited (1)').closest('td')).toHaveClass('text-rose-700');
expect(within(runtime).queryByText('Health check')).not.toBeInTheDocument();
expect(within(runtime).queryByText('Uptime')).not.toBeInTheDocument();
});
it('links update targets to a reliable public registry tags page', () => {
render(() => (
<ResourceDetailDrawer
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import type { Resource } from '@/types/resource';
import {
getDockerContainerHealthCheckPresentation,
getDockerContainerRunStatePresentation,
getDockerContainerStatePresentation,
getDockerContainerUptimeSeconds,
} from '../dockerContainerStatePresentation';
@@ -122,3 +124,37 @@ describe('getDockerContainerUptimeSeconds', () => {
).toBeUndefined();
});
});
describe('drawer runtime rows', () => {
it('splits a running container into its Docker state and its health check', () => {
const unhealthy = container({ containerState: 'running', health: 'unhealthy' });
expect(getDockerContainerRunStatePresentation(unhealthy)).toEqual({
label: 'Running',
title: 'Running',
tone: null,
});
expect(getDockerContainerHealthCheckPresentation(unhealthy)).toEqual({
label: 'Failing',
title: 'Its health check is failing',
tone: 'danger',
});
expect(
getDockerContainerHealthCheckPresentation(
container({ containerState: 'running', health: 'healthy' }),
),
).toMatchObject({ label: 'Passing', tone: null });
expect(
getDockerContainerHealthCheckPresentation(container({ containerState: 'running' })),
).toBeUndefined();
});
it('shows no health check for a stopped container and keeps its exit code', () => {
const crashed = container({ containerState: 'exited', exitCode: 1, health: 'unhealthy' });
expect(getDockerContainerRunStatePresentation(crashed)).toEqual({
label: 'Exited (1)',
title: 'Exited with code 1',
tone: 'danger',
});
expect(getDockerContainerHealthCheckPresentation(crashed)).toBeUndefined();
});
});
@@ -18,16 +18,26 @@ export interface DockerContainerStatePresentation {
const normalizedToken = (value: unknown): string =>
(asTrimmedString(value) ?? '').toLowerCase().replace(/[\s_-]/g, '');
const presentationTone = (variant: string): DockerContainerStateTone | null =>
variant === 'danger' || variant === 'warning' || variant === 'muted' ? variant : null;
const containerStateTitle = (resource: Resource, label: string): string => {
const state = dockerContainerRunState(resource);
const health = normalizedToken(resource.docker?.health);
const exitCode = resource.docker?.exitCode;
if (state === 'running') {
if (health === 'unhealthy') return 'Running, but its health check is failing';
if (health === 'starting') return 'Running, and its health check has not passed yet';
if (health === 'healthy') return 'Running, and its health check is passing';
return 'Running, with no health check';
}
return runStateTitle(resource) ?? label;
};
// Docker's own state words, for a surface that names the health check on a
// row of its own (the resource drawer).
const runStateTitle = (resource: Resource): string | undefined => {
const state = dockerContainerRunState(resource);
const exitCode = resource.docker?.exitCode;
if (state === 'exited' && typeof exitCode === 'number') {
return `Exited with code ${exitCode}`;
}
@@ -35,20 +45,53 @@ const containerStateTitle = (resource: Resource, label: string): string => {
if (state === 'oomkilled') return 'Killed after running out of memory';
if (state === 'dead') return 'Docker failed to stop or remove it';
if (state === 'removing') return 'Docker is removing it';
return label;
return undefined;
};
export const getDockerContainerStatePresentation = (
resource: Resource,
): DockerContainerStatePresentation => {
const status = mapDockerContainerStatus(resource);
const tone =
status.variant === 'danger' || status.variant === 'warning' || status.variant === 'muted'
? status.variant
: null;
const tone = presentationTone(status.variant);
return { label: status.label, title: containerStateTitle(resource, status.label), tone };
};
// The container's state without its health check (Running, Exited (139),
// Paused): the drawer shows the health check on its own row.
export const getDockerContainerRunStatePresentation = (
resource: Resource,
): DockerContainerStatePresentation => {
const status = mapDockerContainerStatus({
...resource,
docker: { ...resource.docker, health: undefined },
});
return {
label: status.label,
title: runStateTitle(resource) ?? status.label,
tone: presentationTone(status.variant),
};
};
// The health check of a running container. Docker keeps the last result on a
// stopped container (#1724), so a stopped one has none to show.
export const getDockerContainerHealthCheckPresentation = (
resource: Resource,
): DockerContainerStatePresentation | undefined => {
const state = dockerContainerRunState(resource);
if (state && state !== 'running') return undefined;
const health = normalizedToken(resource.docker?.health);
if (health === 'unhealthy') {
return { label: 'Failing', title: 'Its health check is failing', tone: 'danger' };
}
if (health === 'starting') {
return { label: 'Starting', title: 'Its health check has not passed yet', tone: 'warning' };
}
if (health === 'healthy') {
return { label: 'Passing', title: 'Its health check is passing', tone: null };
}
return undefined;
};
// Uptime is only the current run: Docker keeps the last value on a stopped
// container, which would read as time spent running.
const UPTIME_STATES = new Set(['running', 'paused']);