diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 0db618b95a..1cbf0b721e 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/browser-verification/3d1515cf5eafc2e6.json b/frontend-modern/browser-verification/3d1515cf5eafc2e6.json new file mode 100644 index 0000000000..cd2d8f6cda --- /dev/null +++ b/frontend-modern/browser-verification/3d1515cf5eafc2e6.json @@ -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" + ] +} diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx index 2cdbf67ff4..86783aec53 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx @@ -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 => ({ 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): DetailSection => { const labels = docker.labels ?? {}; const labelEntries = Object.entries(labels); @@ -192,6 +212,9 @@ const dockerSection = (docker: NonNullable): DetailSection = export const InlineResourceSummaryTables: Component = (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 { expect(within(section).getByText(/traefik\.enable/)).toBeInTheDocument(); }); + it('names a failing health check apart from the running state', () => { + render(() => ( + + )); + + 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(() => ( + + )); + + 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(() => ( { ).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(); + }); +}); diff --git a/frontend-modern/src/features/docker/dockerContainerStatePresentation.ts b/frontend-modern/src/features/docker/dockerContainerStatePresentation.ts index 2602966ac6..7aa1cef255 100644 --- a/frontend-modern/src/features/docker/dockerContainerStatePresentation.ts +++ b/frontend-modern/src/features/docker/dockerContainerStatePresentation.ts @@ -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']);