mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-15 08:33:03 +00:00
feat(docker): show container update target details
This commit is contained in:
@@ -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>
|
||||
|
||||
+43
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user