Keep Docker image names readable by their repository and tag (#2521)

## Problem

Image references that share a registry and namespace differ only at the
end, but table cells truncated them from the end:

- **Images (name column):** at 390 and 700px, three rows read
`ghcr.io/pulse-demo…`. From 390 to 900px the repository name was hidden
in 3–4 of 8 rows.
- **Swarm services (Image column):** from 390 to 700px, two of three
rows read `registry.…`.
- **Containers (Image column):** the repository name was hidden in up to
7 of 12 rows at 1024px, and in 3 rows even at 1440px.

So an update badge, a size or a service couldn't be traced to its image
without opening the row. Real fleets are worse: every
`lscr.io/linuxserver/*` image shares a 20-character prefix.

## Change

- **`splitDockerImageReference`** (in `dockerImageReference.ts`) splits
a reference just before its repository name. A digest never moves the
split, and a reference without a path stays whole.
- **`DockerImageReferenceText`** mirrors `KubernetesNameText`: the head
truncates, `name:tag` stays visible, and the full reference stays on the
title.
- **Where it's used:** the Images name cell (via `DockerResourceNameCell
imageReference`), the Containers Image column and the Services Image
column.
- **Copying:** head and tail are flex items, and a browser copy would
join them with a line break. So a selection inside one reference copies
without it; wider selections are left to the browser.
- **Unchanged:** sorting, search and the stored value.

This is a readability fix to existing columns, in the same class as
#2509, #2514 and #2516, so there is no demand-ledger entry.

## Verification

I ran a mock-mode private stack on main 800329cbd8, comparing before and
after at 12–18 widths from 390 to 1600px.

- **Visible text** (each character checked against its clipping box):
  - Rows that read identically fall to 0 on every table at every width.
- Rows hiding the repository name fall to 0–1, and to 0 from 900px on
Images.
- **Clip sweep:**
  - No column other than Image changes, and no metric sublabel is lost.
- Image cells with clipped text are never more numerous than on main at
any width, and fewer at most widths.
- Element counts rise only because a truncated reference now has two
clipping spans; the head truncates by design.
- **Clipboard:** a keyboard copy of one reference reads back
`ghcr.io/pulse-demo/backup-coordinator:2026.04` from
`navigator.clipboard`.
- **Interactions:** sort by Image, and row expand and collapse, work on
all three tables. No console errors.
- **Tests:**
- New tests: the split cases, a rendered Images-table test covering the
tail, the title and the copy scoping, and a source guardrail for the
three image cells.
  - The new and updated tests fail against main's source.
- 544 test files pass across `src/features`, `src/hooks` and
`src/components/{shared,Workloads,Infrastructure}`.

The browser receipt is
`frontend-modern/browser-verification/a68fe9ea6a927e83.json`.

**Known residual:** when the head truncates completely there's no
leading ellipsis (as with `KubernetesNameText`). When it truncates
partly, the gap after its ellipsis can be up to one character wide.
This commit is contained in:
rcourtman
2026-10-05 16:44:03 +01:00
committed by GitHub
12 changed files with 302 additions and 11 deletions
@@ -2355,7 +2355,12 @@ not a replacement status card, CTA band, or page-local nested card.
truncate` span ignores the column width: it spills past a narrower cell
with no ellipsis, wastes room in a wider one, and sits about 2 pixels above
the row baseline. `platformOverviewLayout.guardrails.test.ts` holds the
Docker, Kubernetes, Proxmox and vSphere table sources to that rule.
Docker, Kubernetes, Proxmox and vSphere table sources to that rule. An
identity value whose distinguishing part sits at its end (a generated
Kubernetes name, a registry-prefixed Docker image reference) instead splits
into a truncating head and a kept tail (`KubernetesNameText`,
`DockerImageReferenceText`), with the full value on the title, so rows that
share a prefix stay distinguishable in a narrow cell.
When a platform table row itself owns that keyboard- and touch-operable
disclosure, `SummaryRowActionButton` removes the redundant visible chevron
below the shared mobile breakpoint while preserving the button for screen
@@ -923,6 +923,15 @@ the title; task starts use the shared relative-time primitive with their exact
timestamp retained. Native table/model/guardrail tests and the production
`browser-tests/proxmox-docker-rows.cjs` exercise these surfaces without executing
a container action or claiming native appliance or release acceptance.
Image references (the Images name, the Containers Image column and the Services
Image column) render through `DockerImageReferenceText`, which splits before
the repository name (`splitDockerImageReference`), because references that
share a registry and namespace differ only at the end: a narrow cell truncates
the head and keeps `name:tag` (`ghcr.io/pul…backup-coordinator:2026.04`), with
the full reference on the title. End truncation left rows that all read
`ghcr.io/pulse-demo…`. A selection inside one reference copies without the
line break the two flex items would add. Sorting and the stored value are
unchanged.
### PBS host history correlation
@@ -0,0 +1,116 @@
{
"version": 1,
"base_sha": "800329cbd83c48fdb7402a18633189f2c8a2d6ff",
"verified_at": "2026-10-05T14:57:23Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/docker/DockerContainersTable.tsx",
"frontend-modern/src/features/docker/DockerImageReferenceText.tsx",
"frontend-modern/src/features/docker/DockerImagesTable.tsx",
"frontend-modern/src/features/docker/DockerNativeTableShared.tsx",
"frontend-modern/src/features/docker/DockerServicesTable.tsx",
"frontend-modern/src/features/docker/dockerImageReference.ts"
],
"content_sha256": {
"frontend-modern/src/features/docker/DockerContainersTable.tsx": "07345ed1b0efb0f22d9c4d47648505d0564745cd088f9a6c095eabbf8d5f005e",
"frontend-modern/src/features/docker/DockerImageReferenceText.tsx": "0ae194ed57a9e9397e9091323fdb10de0b96ede1db52fb4b4a080c2b9fbce794",
"frontend-modern/src/features/docker/DockerImagesTable.tsx": "c336bed9851f8b9e4ca52b365bc6000926f4c98baf580234dcd46edf26978222",
"frontend-modern/src/features/docker/DockerNativeTableShared.tsx": "ae9b54af27933bff2859e2ed5e6b31f82e396007b971861bf4638dbf463f2f06",
"frontend-modern/src/features/docker/DockerServicesTable.tsx": "9f89bd4555185b5b30f3df9618835b83f2cae3525ec766f62744fa93cabae59a",
"frontend-modern/src/features/docker/dockerImageReference.ts": "d9e940885e1bd69b6586dbb5c9e250a0b441440002615151874bd92aea9e3bce"
},
"routes": [
"/docker/images",
"/docker/containers",
"/docker/swarm"
],
"viewports": [
{
"width": 390,
"height": 900
},
{
"width": 430,
"height": 900
},
{
"width": 480,
"height": 900
},
{
"width": 540,
"height": 900
},
{
"width": 573,
"height": 900
},
{
"width": 600,
"height": 900
},
{
"width": 640,
"height": 900
},
{
"width": 700,
"height": 900
},
{
"width": 740,
"height": 900
},
{
"width": 768,
"height": 900
},
{
"width": 800,
"height": 900
},
{
"width": 900,
"height": 900
},
{
"width": 960,
"height": 900
},
{
"width": 1024,
"height": 900
},
{
"width": 1100,
"height": 900
},
{
"width": 1280,
"height": 900
},
{
"width": 1440,
"height": 900
},
{
"width": 1600,
"height": 900
}
],
"states": [
"Private mock stack (PULSE_MOCK_MODE, random metrics off) serving this tree on main 800329cbd8; before = main, after = main plus this change, measured in the same session",
"Visible-text probe per image cell (each character checked against its clipping box): Images name rows that read identically fell from 3 to 0 at 390 and 700, rows whose repository name was hidden fell from 3-4 to 0-1 between 390 and 900 and to 0 from 900; Swarm services read registry.\u2026 for two of three rows from 390 to 700 on main and \u2026backen\u2026 / \u2026fronten\u2026 patched; the Containers Image column (grouped and ungrouped, Host and Engine shown) hid the repository name in up to 7 of 12 rows at 1024 and 3 at 1440 on main, and in at most 1 patched",
"Clip sweep over every cell and its overflow-hidden or ellipsis descendants on all four tables: no column other than Image changes at any width and no metric sublabel is lost; Image cells with any clipped text are never more than on main at any width (fewer at most widths); element counts rise only because a truncated reference now has two clipping spans, the head by design",
"References without a path (postgres:16.4, traefik:v3.1) render as before; the full reference stays on the title of every reference element (8/8 images, 3/3 services, 12/12 containers)",
"Clipboard: a keyboard copy of one reference reads ghcr.io/pulse-demo/backup-coordinator:2026.04 from navigator.clipboard; a whole-row selection keeps the browser's own text",
"Before and after element screenshots: Images at 390, 700 and 1100, Swarm services at 600 and 1024, ungrouped Containers at 1024"
],
"interactions": [
"Loaded each route fresh, waited for the row count to settle, resized through every viewport before measuring",
"Sorted by Image on all three tables (aria-sort ascending, same order as main) and reset",
"Expanded and collapsed the first row on all three tables at 390, 700 and 1100 (containers at 1100 and 1440); no console errors",
"Selected one image reference and a whole row, pressed the copy shortcut, and read the clipboard",
"Switched the Docker containers table to the ungrouped view so the Host and Engine columns render"
]
}
@@ -87,6 +87,7 @@ import type { DockerContainerUpdateStatus } from '@/types/api';
import type { Resource } from '@/types/resource';
import { DockerContainerLifecycleControls } from './DockerContainerLifecycleControls';
import { CONTAINER_CPU_CAPACITY_DESCRIPTION } from './dockerCpuPresentation';
import { DockerImageReferenceText } from './DockerImageReferenceText';
type DockerNetwork = NonNullable<NonNullable<Resource['docker']>['networks']>[number];
type DockerMount = NonNullable<NonNullable<Resource['docker']>['mounts']>[number];
@@ -584,9 +585,7 @@ export const DockerContainersTable: Component<DockerContainersTableProps> = (pro
case 'image':
return (
<TableCell class={`${getPlatformTableCellClassForKind(column.kind)} text-base-content`}>
<span class="block max-w-full truncate" title={image()}>
{image()}
</span>
<DockerImageReferenceText reference={image()} class="max-w-full" />
</TableCell>
);
case 'state':
@@ -0,0 +1,43 @@
import type { Component } from 'solid-js';
import { splitDockerImageReference } from './dockerImageReference';
/**
* An image reference in a narrow cell. References that share a registry and
* namespace (ghcr.io/pulse-demo/…, lscr.io/linuxserver/…) differ only in the
* repository name and tag at the end, so the head truncates and the tail stays
* visible: ghcr.io/pul…backup-coordinator:2026.04. The tail never outgrows the
* cell: when even it does not fit, it truncates too once the head is gone. The
* full reference stays on the title, and the two spans read as one reference
* to assistive technology. Mirrors KubernetesNameText.
*/
export const DockerImageReferenceText: Component<{ reference: string; class?: string }> = (
props,
) => {
const parts = () => splitDockerImageReference(props.reference);
// The head and tail are flex items, so a plain copy inserts a line break
// between them and a pasted `docker pull` breaks. A selection inside one
// reference copies without it; wider selections copy as the browser builds
// them.
const copyWithoutBreak = (event: ClipboardEvent & { currentTarget: HTMLSpanElement }) => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0 || !event.clipboardData) return;
if (!event.currentTarget.contains(selection.getRangeAt(0).commonAncestorContainer)) return;
event.clipboardData.setData('text/plain', selection.toString().replace(/\r?\n/g, ''));
event.preventDefault();
};
return (
<span
class={`flex min-w-0 ${props.class ?? ''}`}
title={props.reference}
data-docker-image-reference
onCopy={copyWithoutBreak}
>
<span class="truncate">{parts().head}</span>
<span class="max-w-full shrink-0 truncate" data-docker-image-reference-tail>
{parts().tail}
</span>
</span>
);
};
export default DockerImageReferenceText;
@@ -216,6 +216,7 @@ export const DockerImagesTable: Component<
>
<DockerResourceNameCell
resource={resource}
imageReference
detailToggle={
<PlatformResourceDetailToggleButton
expanded={isExpanded()}
@@ -11,6 +11,7 @@ import {
} from '@/features/platformPage/sharedPlatformPage';
import type { Resource } from '@/types/resource';
import type { DockerResourceStatusFilter } from './dockerPageModel';
import { DockerImageReferenceText } from './DockerImageReferenceText';
export type DockerNativeTableProps = {
resources: Resource[];
@@ -69,6 +70,9 @@ export const DockerResourceNameCell: Component<{
// etc.) rather than from the generic resource.status triad.
indicator?: StatusIndicator;
detailToggle?: JSX.Element;
// Image rows name the resource by its reference, so a narrow cell keeps the
// repository and tag visible instead of the shared registry prefix.
imageReference?: boolean;
}> = (props) => {
const resolvedIndicator = (): StatusIndicator =>
props.indicator ?? getSimpleStatusIndicator(props.resource.status);
@@ -88,8 +92,10 @@ export const DockerResourceNameCell: Component<{
name={name()}
url={props.resource.customUrl}
class="min-w-0 max-[359px]:[&>a]:hidden"
nameClass="truncate font-semibold text-base-content"
/>
nameClass={`${props.imageReference ? 'flex min-w-0' : 'truncate'} font-semibold text-base-content`}
>
{props.imageReference ? <DockerImageReferenceText reference={name()} /> : undefined}
</ResourceNameWithWebInterfaceLink>
</div>
</TableCell>
);
@@ -32,6 +32,7 @@ import {
mapDockerServiceStatus,
type DockerResourceStatusFilter,
} from './dockerPageModel';
import { DockerImageReferenceText } from './DockerImageReferenceText';
// Docker Swarm services are cluster-scoped declarations, not running
// processes — they have no CPU / Memory / Disk / Disk I/O / Uptime /
@@ -338,9 +339,7 @@ export const DockerServicesTable: Component<{
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content md:table-cell`}
>
<span class="block truncate" title={image()}>
{image()}
</span>
<DockerImageReferenceText reference={image()} class="max-w-full" />
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} platform-table-phone-hidden text-base-content`}
@@ -1114,6 +1114,64 @@ describe('Docker native tables', () => {
expect(screen.getByTitle('Healthy')).toHaveClass('bg-emerald-500');
});
it('keeps the repository and tag of a registry-prefixed image name visible', () => {
const { container } = render(() => (
<DockerImagesTable
resources={[
makeResource({
id: 'image-ghcr',
type: 'docker-image',
name: 'ghcr.io/pulse-demo/backup-coordinator:2026.04',
}),
makeResource({ id: 'image-hub', type: 'docker-image', name: 'postgres:16.4' }),
]}
emptyIcon={<span />}
emptyTitle="No images"
emptyDescription="No images"
showToolbar={false}
/>
));
// Images that share a registry and namespace differ only at the end, so a
// narrow name cell truncates the shared head and keeps the tail visible.
const references = [
...container.querySelectorAll<HTMLElement>('[data-docker-image-reference]'),
];
const prefixed = references.find(
(element) => element.title === 'ghcr.io/pulse-demo/backup-coordinator:2026.04',
);
expect(prefixed).toHaveTextContent('ghcr.io/pulse-demo/backup-coordinator:2026.04');
expect(prefixed?.querySelector('[data-docker-image-reference-tail]')).toHaveTextContent(
'backup-coordinator:2026.04',
);
const bare = references.find((element) => element.title === 'postgres:16.4');
expect(bare).toHaveTextContent('postgres:16.4');
expect(bare?.querySelector('[data-docker-image-reference-tail]')?.textContent).toBe('');
// Head and tail are flex items, so a browser copy would put a line break
// between them; a selection inside one reference copies as the reference.
const copy = (selected: Node) => {
const range = document.createRange();
range.selectNodeContents(selected);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
const setData = vi.fn();
const event = new Event('copy', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: { setData } });
prefixed!.dispatchEvent(event);
return { setData, prevented: event.defaultPrevented };
};
const inside = copy(prefixed!);
expect(inside.setData).toHaveBeenCalledWith(
'text/plain',
'ghcr.io/pulse-demo/backup-coordinator:2026.04',
);
expect(inside.prevented).toBe(true);
const wider = copy(container);
expect(wider.setData).not.toHaveBeenCalled();
expect(wider.prevented).toBe(false);
});
it('renders Docker image API fields', () => {
render(() => (
<DockerImagesTable
@@ -1560,7 +1618,13 @@ describe('Docker native tables', () => {
expect(screen.getByText('Stack')).toBeInTheDocument();
expect(screen.getByText('shop')).toBeInTheDocument();
expect(screen.getByText('checkout-api')).toBeInTheDocument();
expect(screen.getByText('registry.example.com/checkout-api:2026.05')).toBeInTheDocument();
// The image keeps its repository and tag visible when the cell truncates;
// the full reference stays on the title.
const serviceImage = screen.getByTitle('registry.example.com/checkout-api:2026.05');
expect(serviceImage).toHaveTextContent('registry.example.com/checkout-api:2026.05');
expect(serviceImage.querySelector('[data-docker-image-reference-tail]')).toHaveTextContent(
'checkout-api:2026.05',
);
expect(screen.getByText('replicated')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { getDockerImageRegistryLink } from '../dockerImageReference';
import { getDockerImageRegistryLink, splitDockerImageReference } from '../dockerImageReference';
describe('getDockerImageRegistryLink', () => {
it.each([
@@ -35,3 +35,26 @@ describe('getDockerImageRegistryLink', () => {
expect(getDockerImageRegistryLink(image)).toBeNull();
});
});
describe('splitDockerImageReference', () => {
it.each([
[
'ghcr.io/pulse-demo/backup-coordinator:2026.04',
'ghcr.io/pulse-demo/',
'backup-coordinator:2026.04',
],
['lscr.io/linuxserver/sonarr:latest', 'lscr.io/linuxserver/', 'sonarr:latest'],
['registry.example.com:5000/team/app:1.2', 'registry.example.com:5000/team/', 'app:1.2'],
['louislam/uptime-kuma:1.23.16', 'louislam/', 'uptime-kuma:1.23.16'],
['quay.io/org/app@sha256:abc/def', 'quay.io/org/', 'app@sha256:abc/def'],
])('keeps the repository and tag of %s in the tail', (reference, head, tail) => {
expect(splitDockerImageReference(reference)).toEqual({ head, tail });
});
it.each(['postgres:16.4', 'sha256:0123456789ab', '<none>:<none>', '—', '/app', 'team/'])(
'leaves %s whole when it has no repository path to split',
(reference) => {
expect(splitDockerImageReference(reference)).toEqual({ head: reference, tail: '' });
},
);
});
@@ -78,3 +78,18 @@ export function getDockerImageRegistryLink(
return null;
}
/**
* Splits an image reference before its repository name, so a narrow cell can
* truncate the shared registry and namespace and keep the part that tells
* images apart: `ghcr.io/pulse-demo/` + `backup-coordinator:2026.04`. A digest
* never moves the split. A reference without a path keeps everything in the
* head, like a Kubernetes name without a tail.
*/
export function splitDockerImageReference(reference: string): { head: string; tail: string } {
const digestStart = reference.indexOf('@');
const path = digestStart >= 0 ? reference.slice(0, digestStart) : reference;
const cut = path.lastIndexOf('/');
if (cut <= 0 || cut === path.length - 1) return { head: reference, tail: '' };
return { head: reference.slice(0, cut + 1), tail: reference.slice(cut + 1) };
}
@@ -410,6 +410,17 @@ describe('platform overview layout guardrails', () => {
);
});
it('keeps Docker image references readable by their repository and tag', () => {
// References that share a registry and namespace differ only at the end, so
// every image cell truncates the shared head and keeps name:tag visible.
expect(dockerImagesTableSource).toMatch(/<DockerResourceNameCell[\s\S]{0,80}?imageReference/);
for (const source of [dockerContainersTableSource, dockerServicesTableSource]) {
expect(source).toContain(
'<DockerImageReferenceText reference={image()} class="max-w-full" />',
);
}
});
it('keeps platform summary cells truncating inside their own column', () => {
// A capped inline-block (max-w-52 truncate) ignored the column width, so a
// long value spilled past a narrow cell without an ellipsis and sat above