Colour a container drawer's status dot by its health, as the row does (#2935)

A running container failing its health check showed a red dot and
"Unhealthy" in its table row but a green dot in the expanded drawer
header, which used the generic run-status mapper for every resource.

The drawer header now uses the container row's mapper for app
containers, so run state and health check decide the dot and its label
in both places. Other resources keep the shared run-status dot. Sol
reviewed the desktop and phone screenshots and found the dot plus the
lifted "Health check: Failing" row enough, without another label.


Stacked on #2934 (same drawer); it carries that branch's commits until
#2934 lands.
This commit is contained in:
rcourtman
2026-10-14 14:53:22 +01:00
committed by GitHub
6 changed files with 88 additions and 1 deletions
@@ -2390,6 +2390,11 @@ address from the resource itself, state used amounts only for an online or
running resource whose memory observation (if any) is current, and lift an
offline resource's state with its `lastSeen`. A row an open alert of the
resource's own already states (`getAlertStatedRowLabels`) stays in Details.
The drawer header's status dot for an `app-container` uses the container
row's mapper (`mapDockerContainerStatus`), so its run state and health check
decide the colour and label in both places: a running container failing its
health check is red "Unhealthy" in the row and the header alike. Other
resources keep `getAgentStatusIndicator` on `status`.
Those open alerts come from the websocket `activeAlerts` map through
`getAlertsForUnifiedResource` in `frontend-modern/src/utils/alerts.ts`, which
the Docker host drawer (`DockerHostDrawerOverview.tsx`) uses the same way.
@@ -67,6 +67,8 @@ const observations = (mode) => [
runtimeVersion: '27.5.1',
image: 'nginx:latest',
containerState: 'running',
// Running but failing its health check: row and drawer must both say so.
health: 'unhealthy',
restartCount: 7,
updateStatus: {
updateAvailable: true,
@@ -371,6 +373,17 @@ const observations = (mode) => [
const detail = page.getByTestId('resource-drawer-exceptions');
await detail.getByText('Restarts', { exact: true }).waitFor();
await detail.getByText('7', { exact: true }).waitFor();
// The drawer header dot agrees with the row's: unhealthy is red, not green.
const headerDot = page.getByRole('img', { name: 'Unhealthy' }).first();
await headerDot.waitFor();
const rowDot = row.locator('[title="Unhealthy"]').first();
const colours = await Promise.all(
[headerDot, rowDot].map((dot) =>
dot.evaluate((el) => getComputedStyle(el).backgroundColor),
),
);
assert.equal(colours[0], colours[1], JSON.stringify(colours));
assert.match(colours[0], /^rgb\((2[0-9]{2}), ([0-9]{1,2}|1[0-2][0-9]), /);
},
);
await capture('container-detail');
@@ -0,0 +1,33 @@
{
"version": 1,
"base_sha": "e72df20395b0659aadd08deacd4a1549cda9b26b",
"verified_at": "2026-10-14T13:37:16Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts"
],
"content_sha256": {
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "095e0365fc6bf30c8a5b5d88ac03616576bd66a71496d21a158330817dbdff5d"
},
"routes": [
"browser-tests/proxmox-docker-rows.html (production DockerContainersTable row and ResourceDetailDrawer, Chromium via Playwright 1.56.1 on pulse-dev)"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"running container failing its health check: row dot and drawer header dot both red \"Unhealthy\" (old code: header green)",
"exited container row unchanged",
"reviewed by Sol from the desktop and phone screenshots"
],
"interactions": [
"row expand by pointer (desktop) and tap (phone)"
]
}
@@ -326,6 +326,9 @@ describe('ResourceDetailDrawer for Docker containers', () => {
/>
));
// The header dot agrees with the row: running but unhealthy is red, not green.
const dot = screen.getByRole('img', { name: 'Unhealthy' });
expect(dot).toHaveClass('bg-red-500');
const runtime = screen.getByTestId('resource-runtime-context-section');
expect(within(runtime).getByText('Running')).toBeInTheDocument();
// The failing check is lifted into Worth checking, in front of the charts.
@@ -357,6 +360,8 @@ describe('ResourceDetailDrawer for Docker containers', () => {
/>
));
// A failed exit is red in the header, and its stale health check says nothing.
expect(screen.getByRole('img', { name: 'Exited (1)' })).toHaveClass('bg-red-500');
const runtime = screen.getByTestId('resource-runtime-context-section');
const lifted = screen.getByTestId('resource-drawer-exceptions');
expect(within(lifted).getByText('Exited (1)').closest('td')).toHaveClass('text-rose-700');
@@ -1773,3 +1773,26 @@ describe('ResourceDetailDrawer Overview alert sources', () => {
expect(resourceDetailDrawerOverviewSource).not.toContain('message: alert.message,');
});
});
describe('ResourceDetailDrawer header status dot', () => {
it("reads a container's run state and health check, as its row does", () => {
const { unmount } = render(() => (
<ResourceDetailDrawer
resource={baseResource({
id: 'app-container:edge-web',
type: 'app-container',
name: 'edge-web',
status: 'online',
docker: { containerId: 'abc', containerState: 'running', health: 'unhealthy' },
})}
/>
));
// Running but failing its check is red with the row's label, not green.
expect(screen.getByRole('img', { name: 'Unhealthy' })).toHaveClass('bg-red-500');
unmount();
render(() => <ResourceDetailDrawer resource={baseResource({ status: 'online' })} />);
// Other resources keep the shared run-status dot.
expect(screen.queryByRole('img', { name: 'Unhealthy' })).toBeNull();
});
});
@@ -6,6 +6,7 @@ import type { ResourceVMwareMeta } from '@/types/resource';
import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format';
import { useRelativeTimeNow } from '@/utils/relativeTimeClock';
import { getAgentStatusIndicator } from '@/utils/status';
import { mapDockerContainerStatus } from '@/features/docker/dockerPageModel';
import {
dedupeResourceBadges,
getInfrastructureSystemIdentityBadges,
@@ -113,7 +114,14 @@ export const useResourceDetailDrawerDerivedState = (
const kubernetesClusterName = createMemo(() => getPreferredResourceClusterName(resource()) ?? '');
const resolveResourceLabel = (resourceId: string): string =>
resolveResourceLabelInput?.(resourceId)?.trim() || resourceId;
const statusIndicator = createMemo(() => getAgentStatusIndicator({ status: resource().status }));
// A container's dot reads its run state and health check, the same mapper
// its table row uses, so a running but unhealthy container is not green here
// while red in the row above.
const statusIndicator = createMemo(() =>
resource().type === 'app-container'
? mapDockerContainerStatus(resource())
: getAgentStatusIndicator({ status: resource().status }),
);
const healthIssue = createMemo(() => getResourceHealthIssuePresentation(resource()));
// An open drawer keeps its rows while a resource's timestamps stay the same,
// which is exactly when a silent resource's Last seen must keep aging, so