diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 276c781253..002c667242 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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. diff --git a/frontend-modern/browser-tests/proxmox-docker-rows.cjs b/frontend-modern/browser-tests/proxmox-docker-rows.cjs index a99573c803..a8d285cb42 100644 --- a/frontend-modern/browser-tests/proxmox-docker-rows.cjs +++ b/frontend-modern/browser-tests/proxmox-docker-rows.cjs @@ -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'); diff --git a/frontend-modern/browser-verification/eb8c958def6282ab.json b/frontend-modern/browser-verification/eb8c958def6282ab.json new file mode 100644 index 0000000000..642ae1592f --- /dev/null +++ b/frontend-modern/browser-verification/eb8c958def6282ab.json @@ -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)" + ] +} diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.docker-container.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.docker-container.test.tsx index 7e4e80e3d5..812769d11c 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.docker-container.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.docker-container.test.tsx @@ -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'); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx index e63425c02a..da3472401d 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx @@ -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(() => ( + + )); + // 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(() => ); + // Other resources keep the shared run-status dot. + expect(screen.queryByRole('img', { name: 'Unhealthy' })).toBeNull(); + }); +}); diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts index f80753c422..91cb75ef5e 100644 --- a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts @@ -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