From 9ec1503aa232d1880f48d25977dae1c1de16517e Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:57:33 +0100 Subject: [PATCH] Keep Docker image names readable by their repository and tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Image references that share a registry and namespace differ only at the end, but table cells truncated them from the end. At 390 and 700px three Images rows read "ghcr.io/pulse-demo…", Swarm services read "registry.…" from 390 to 700px, and the Containers Image column hid the repository name up to 1440px, so an update badge or a size could not be traced to its image without opening the row. Real fleets are worse: every lscr.io/linuxserver image shares a 20-character prefix. splitDockerImageReference splits before the repository name (a digest never moves the split), and DockerImageReferenceText renders the head as the part that truncates and keeps name:tag visible, mirroring KubernetesNameText, with the full reference on the title. The Images name cell, the Containers Image column and the Services Image column use it. The two halves are flex items, which a browser copy would join with a line break, so a selection inside one reference copies without it. Sorting, search and the stored value are unchanged, and a reference without a path renders as before. --- .../subsystems/frontend-primitives.md | 7 +- .../internal/subsystems/unified-resources.md | 9 ++ .../a68fe9ea6a927e83.json | 116 ++++++++++++++++++ .../features/docker/DockerContainersTable.tsx | 5 +- .../docker/DockerImageReferenceText.tsx | 43 +++++++ .../src/features/docker/DockerImagesTable.tsx | 1 + .../docker/DockerNativeTableShared.tsx | 10 +- .../features/docker/DockerServicesTable.tsx | 5 +- .../__tests__/DockerNativeTables.test.tsx | 66 +++++++++- .../__tests__/dockerImageReference.test.ts | 25 +++- .../features/docker/dockerImageReference.ts | 15 +++ .../platformOverviewLayout.guardrails.test.ts | 11 ++ 12 files changed, 302 insertions(+), 11 deletions(-) create mode 100644 frontend-modern/browser-verification/a68fe9ea6a927e83.json create mode 100644 frontend-modern/src/features/docker/DockerImageReferenceText.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 700a8fd417..e64d7a1de1 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2350,7 +2350,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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index fc0d77b8dc..f30da6f5b7 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -893,6 +893,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 diff --git a/frontend-modern/browser-verification/a68fe9ea6a927e83.json b/frontend-modern/browser-verification/a68fe9ea6a927e83.json new file mode 100644 index 0000000000..75435a373b --- /dev/null +++ b/frontend-modern/browser-verification/a68fe9ea6a927e83.json @@ -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" + ] +} diff --git a/frontend-modern/src/features/docker/DockerContainersTable.tsx b/frontend-modern/src/features/docker/DockerContainersTable.tsx index a54d51275e..b174bf48ba 100644 --- a/frontend-modern/src/features/docker/DockerContainersTable.tsx +++ b/frontend-modern/src/features/docker/DockerContainersTable.tsx @@ -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['networks']>[number]; type DockerMount = NonNullable['mounts']>[number]; @@ -584,9 +585,7 @@ export const DockerContainersTable: Component = (pro case 'image': return ( - - {image()} - + ); case 'state': diff --git a/frontend-modern/src/features/docker/DockerImageReferenceText.tsx b/frontend-modern/src/features/docker/DockerImageReferenceText.tsx new file mode 100644 index 0000000000..5fc58b112e --- /dev/null +++ b/frontend-modern/src/features/docker/DockerImageReferenceText.tsx @@ -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 ( + + {parts().head} + + {parts().tail} + + + ); +}; + +export default DockerImageReferenceText; diff --git a/frontend-modern/src/features/docker/DockerImagesTable.tsx b/frontend-modern/src/features/docker/DockerImagesTable.tsx index b470bbbbb0..cf323da779 100644 --- a/frontend-modern/src/features/docker/DockerImagesTable.tsx +++ b/frontend-modern/src/features/docker/DockerImagesTable.tsx @@ -216,6 +216,7 @@ export const DockerImagesTable: Component< > = (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 ? : undefined} + ); diff --git a/frontend-modern/src/features/docker/DockerServicesTable.tsx b/frontend-modern/src/features/docker/DockerServicesTable.tsx index 596f0d8d22..907c04d6f2 100644 --- a/frontend-modern/src/features/docker/DockerServicesTable.tsx +++ b/frontend-modern/src/features/docker/DockerServicesTable.tsx @@ -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<{ - - {image()} - + { expect(screen.getByTitle('Healthy')).toHaveClass('bg-emerald-500'); }); + it('keeps the repository and tag of a registry-prefixed image name visible', () => { + const { container } = render(() => ( + } + 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('[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(() => ( { 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(); diff --git a/frontend-modern/src/features/docker/__tests__/dockerImageReference.test.ts b/frontend-modern/src/features/docker/__tests__/dockerImageReference.test.ts index d615cacef1..4c36abd648 100644 --- a/frontend-modern/src/features/docker/__tests__/dockerImageReference.test.ts +++ b/frontend-modern/src/features/docker/__tests__/dockerImageReference.test.ts @@ -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', ':', '—', '/app', 'team/'])( + 'leaves %s whole when it has no repository path to split', + (reference) => { + expect(splitDockerImageReference(reference)).toEqual({ head: reference, tail: '' }); + }, + ); +}); diff --git a/frontend-modern/src/features/docker/dockerImageReference.ts b/frontend-modern/src/features/docker/dockerImageReference.ts index b2f0286d1a..a423655eda 100644 --- a/frontend-modern/src/features/docker/dockerImageReference.ts +++ b/frontend-modern/src/features/docker/dockerImageReference.ts @@ -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) }; +} diff --git a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts index 61a4dd9678..23ebb153f9 100644 --- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts @@ -401,6 +401,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(/', + ); + } + }); + 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