feat(docker): show container update target details

This commit is contained in:
courtmanr@gmail.com
2026-07-30 13:55:43 +01:00
parent ff0a7a4827
commit 6e710cd4aa
4 changed files with 231 additions and 0 deletions
@@ -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<Resource['docker']
!docker.containerId &&
!docker.containerState &&
!docker.image &&
!docker.updateStatus &&
!docker.startedAt &&
!docker.finishedAt &&
!docker.blockIo &&
@@ -77,6 +79,15 @@ const DockerContainerSummarySection: Component<{ docker: NonNullable<Resource['d
const blockReadBytes = () => 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 (
<tbody
@@ -99,6 +110,66 @@ const DockerContainerSummarySection: Component<{ docker: NonNullable<Resource['d
</td>
</tr>
</Show>
<Show when={updateState()}>
<tr>
<td class="px-2 py-1 text-muted">Image update</td>
<td
class={`px-2 py-1 text-right font-medium ${
updateStatus()?.updateAvailable
? 'text-sky-700 dark:text-sky-300'
: updateStatus()?.error
? 'text-red-600 dark:text-red-400'
: 'text-base-content'
}`}
title={updateStatus()?.error}
>
{updateState()}
</td>
</tr>
</Show>
<Show when={trimmedDockerValue(updateStatus()?.currentDigest)}>
{(digest) => (
<tr>
<td class="px-2 py-1 text-muted">Current digest</td>
<td
class="break-all px-2 py-1 text-right font-mono text-[10px] font-medium text-base-content"
title={digest()}
>
{digest()}
</td>
</tr>
)}
</Show>
<Show when={trimmedDockerValue(updateStatus()?.latestDigest)}>
{(digest) => (
<tr>
<td class="px-2 py-1 text-muted">Target digest</td>
<td
class="break-all px-2 py-1 text-right font-mono text-[10px] font-medium text-base-content"
title={digest()}
>
{digest()}
</td>
</tr>
)}
</Show>
<Show when={imageRegistryLink()}>
{(link) => (
<tr>
<td class="px-2 py-1 text-muted">Release information</td>
<td class="px-2 py-1 text-right font-medium">
<a
href={link().href}
target="_blank"
rel="noopener noreferrer"
class="text-sky-700 hover:underline dark:text-sky-300"
>
{link().label}
</a>
</td>
</tr>
)}
</Show>
<Show when={typeof restartCount() === 'number'}>
<tr>
<td class="px-2 py-1 text-muted">Restarts</td>
@@ -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(() => (
<ResourceDetailDrawer
presentation="table-row"
resource={resource({
id: 'app-container-web',
name: 'edge-web',
docker: {
containerId: 'abc123def456',
containerState: 'running',
image: 'docker.io/library/nginx:latest',
updateStatus: {
updateAvailable: true,
currentDigest: 'sha256:current',
latestDigest: 'sha256:target',
lastChecked: '2026-05-24T13:00:00Z',
},
},
})}
/>
));
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(() => (
<ResourceDetailDrawer
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { getDockerImageRegistryLink } from '../dockerImageReference';
describe('getDockerImageRegistryLink', () => {
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();
});
});
@@ -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;
}