From 6e710cd4aa195a438f340099f264da8d64a4e2d8 Mon Sep 17 00:00:00 2001 From: "courtmanr@gmail.com" Date: Thu, 30 Jul 2026 13:55:43 +0100 Subject: [PATCH] feat(docker): show container update target details --- .../Infrastructure/ResourceDetailSummary.tsx | 71 ++++++++++++++++ ...urceDetailDrawer.docker-container.test.tsx | 43 ++++++++++ .../__tests__/dockerImageReference.test.ts | 37 +++++++++ .../features/docker/dockerImageReference.ts | 80 +++++++++++++++++++ 4 files changed, 231 insertions(+) create mode 100644 frontend-modern/src/features/docker/__tests__/dockerImageReference.test.ts create mode 100644 frontend-modern/src/features/docker/dockerImageReference.ts diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx index b22b9a22e8..91aa8599eb 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx @@ -6,6 +6,7 @@ import { TagBadges } from '@/components/shared/TagBadges'; import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format'; import { formatInteger } from './resourceDetailMappers'; import type { UseResourceDetailDrawerStateResult } from './useResourceDetailDrawerState'; +import { getDockerImageRegistryLink } from '@/features/docker/dockerImageReference'; interface ResourceSummaryPresentationProps { resource: Resource; @@ -25,6 +26,7 @@ const dockerContainerMeta = (resource: Resource): NonNullable dockerByteTotal(props.docker.blockIo?.readBytes); const blockWriteBytes = () => dockerByteTotal(props.docker.blockIo?.writeBytes); const restartCount = () => props.docker.restartCount; + const updateStatus = () => props.docker.updateStatus; + const imageRegistryLink = () => + updateStatus()?.updateAvailable ? getDockerImageRegistryLink(props.docker.image) : null; + const updateState = () => { + const update = updateStatus(); + if (!update) return ''; + if (trimmedDockerValue(update.error)) return 'Check failed'; + return update.updateAvailable ? 'Available' : 'Current'; + }; return ( + + + Image update + + {updateState()} + + + + + {(digest) => ( + + Current digest + + {digest()} + + + )} + + + {(digest) => ( + + Target digest + + {digest()} + + + )} + + + {(link) => ( + + Release information + + + {link().label} + + + + )} + Restarts 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 6dd4347a0c..7089cb1888 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 @@ -226,6 +226,12 @@ describe('ResourceDetailDrawer for Docker containers', () => { containerId: 'abc123def456', containerState: 'running', image: 'ghcr.io/example/edge-web:2026.05', + updateStatus: { + updateAvailable: true, + currentDigest: 'sha256:current-digest', + latestDigest: 'sha256:target-digest', + lastChecked: '2026-05-24T13:00:00Z', + }, restartCount: 7, createdAt: '2026-04-13T01:14:01Z', startedAt: '2026-04-13T01:15:01Z', @@ -253,6 +259,13 @@ describe('ResourceDetailDrawer for Docker containers', () => { const section = screen.getByTestId('resource-docker-container-section'); expect(within(section).getByText('Image')).toBeInTheDocument(); expect(within(section).getByText('ghcr.io/example/edge-web:2026.05')).toBeInTheDocument(); + expect(within(section).getByText('Image update')).toBeInTheDocument(); + expect(within(section).getByText('Available')).toHaveClass('text-sky-700'); + expect(within(section).getByText('Current digest')).toBeInTheDocument(); + expect(within(section).getByText('sha256:current-digest')).toBeInTheDocument(); + expect(within(section).getByText('Target digest')).toBeInTheDocument(); + expect(within(section).getByText('sha256:target-digest')).toBeInTheDocument(); + expect(within(section).queryByText('Release information')).not.toBeInTheDocument(); expect(within(section).getByText('Restarts')).toBeInTheDocument(); expect(within(section).getByText('7')).toHaveClass('text-red-600'); expect(within(section).getByText('Created')).toBeInTheDocument(); @@ -281,6 +294,36 @@ describe('ResourceDetailDrawer for Docker containers', () => { expect(within(section).getByText(/traefik\.enable/)).toBeInTheDocument(); }); + it('links update targets to a reliable public registry tags page', () => { + render(() => ( + + )); + + const section = screen.getByTestId('resource-docker-container-section'); + expect(within(section).getByText('Release information')).toBeInTheDocument(); + expect(within(section).getByRole('link', { name: 'View image tags' })).toHaveAttribute( + 'href', + 'https://hub.docker.com/_/nginx/tags', + ); + }); + it('does not render the container section for non-container resources', () => { render(() => ( { + it.each([ + ['nginx:latest', 'https://hub.docker.com/_/nginx/tags'], + ['library/redis:7.4', 'https://hub.docker.com/_/redis/tags'], + ['docker.io/library/postgres@sha256:abc', 'https://hub.docker.com/_/postgres/tags'], + ['index.docker.io/example/widget:1.2', 'https://hub.docker.com/r/example/widget/tags'], + [ + 'registry-1.docker.io/example/nested/widget:stable', + 'https://hub.docker.com/r/example/nested/widget/tags', + ], + [ + 'quay.io/prometheus/node-exporter:v1.9.1', + 'https://quay.io/repository/prometheus/node-exporter?tab=tags', + ], + ])('maps %s to its public tags page', (image, href) => { + expect(getDockerImageRegistryLink(image)).toEqual({ + href, + label: 'View image tags', + }); + }); + + it.each([ + undefined, + '', + ' ', + 'ghcr.io/example/widget:latest', + 'registry.example.com/example/widget:latest', + 'localhost:5000/example/widget:latest', + 'invalid image:latest', + String.raw`docker.io\example\widget:latest`, + ])('does not invent a public link for %s', (image) => { + expect(getDockerImageRegistryLink(image)).toBeNull(); + }); +}); diff --git a/frontend-modern/src/features/docker/dockerImageReference.ts b/frontend-modern/src/features/docker/dockerImageReference.ts new file mode 100644 index 0000000000..b2f0286d1a --- /dev/null +++ b/frontend-modern/src/features/docker/dockerImageReference.ts @@ -0,0 +1,80 @@ +import { asTrimmedString } from '@/utils/stringUtils'; + +export type DockerImageRegistryLink = { + href: string; + label: string; +}; + +const DOCKER_HUB_REGISTRIES = new Set(['docker.io', 'index.docker.io', 'registry-1.docker.io']); + +const encodeRepositoryPath = (repository: string): string => + repository + .split('/') + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)) + .join('/'); + +const stripImageTagAndDigest = (image: string): string => { + const withoutDigest = image.split('@', 1)[0] ?? ''; + const lastSlash = withoutDigest.lastIndexOf('/'); + const lastColon = withoutDigest.lastIndexOf(':'); + return lastColon > lastSlash ? withoutDigest.slice(0, lastColon) : withoutDigest; +}; + +const hasExplicitRegistry = (firstSegment: string): boolean => + firstSegment === 'localhost' || firstSegment.includes('.') || firstSegment.includes(':'); + +/** + * Returns a public tags page only when the registry has a stable, predictable + * browser URL. Private and unknown registries deliberately return null rather + * than producing a misleading or credential-leaking link. + */ +export function getDockerImageRegistryLink( + rawImage: string | null | undefined, +): DockerImageRegistryLink | null { + const image = asTrimmedString(rawImage); + if (!image || /[\s\\]/.test(image)) return null; + + const reference = stripImageTagAndDigest(image); + const parts = reference.split('/').filter(Boolean); + if (parts.length === 0) return null; + + const firstSegment = (parts[0] ?? '').toLowerCase(); + const explicitRegistry = hasExplicitRegistry(firstSegment); + const registry = explicitRegistry ? firstSegment : 'docker.io'; + const repositoryParts = explicitRegistry ? parts.slice(1) : parts; + if (repositoryParts.length === 0) return null; + + if (DOCKER_HUB_REGISTRIES.has(registry)) { + const normalizedRepository = + repositoryParts.length === 1 + ? ['library', repositoryParts[0] ?? ''] + : repositoryParts[0]?.toLowerCase() === 'library' + ? ['library', ...repositoryParts.slice(1)] + : repositoryParts; + const repository = encodeRepositoryPath(normalizedRepository.join('/')); + if (!repository) return null; + + if (normalizedRepository[0] === 'library' && normalizedRepository.length === 2) { + return { + href: `https://hub.docker.com/_/${encodeURIComponent(normalizedRepository[1] ?? '')}/tags`, + label: 'View image tags', + }; + } + return { + href: `https://hub.docker.com/r/${repository}/tags`, + label: 'View image tags', + }; + } + + if (registry === 'quay.io') { + const repository = encodeRepositoryPath(repositoryParts.join('/')); + if (!repository) return null; + return { + href: `https://quay.io/repository/${repository}?tab=tags`, + label: 'View image tags', + }; + } + + return null; +}