mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
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:
@@ -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(),
|
||||
|
||||
+51
@@ -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
|
||||
|
||||
+36
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user