diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index a06fd0ec7f..bb84bc1597 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index b1400c2b87..c67c85e27e 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 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 6a1f1e2152..b009a40656 100644 --- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts @@ -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(/', + ); + } + }); + 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