From 2a8a91646d7917e4fa5d4bbbdc409e7d09624c92 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 10 Oct 2026 07:04:16 +0100 Subject: [PATCH] Show drawer identity values whole instead of cutting them The inline drawer is where a truncated table cell sends the reader for the full value, but its own identity rows rendered as single-line truncating spans. On a 390px phone the Primary ID, Parent, Hostname, Discovery and Machine ID rows ended in an ellipsis, and the distinguishing part of these values sits at the end: two pods of one ReplicaSet both read "checkout-api-6c746d5bcf-..." and a Kubernetes parent read "k8s-cluster-afcb623d93ff2...". The full value lived only in a title, and phones cannot hover. Container image references lost their tag the same way. Docker label chips were hand-rolled inline-flex spans with truncate. An inline-flex box draws no ellipsis, so long labels were cut with no sign at all: 161px shown of up to 343px on a phone, and two chips were still cut at 1440px. Identity rows (the primary identity rows, Platform ID, Primary IP, image, Podman pod and ID, Compose project and service) now use the detail row's existing wrap mode, and label chips use MetadataBadge's wrap mode, so operators can read, compare and copy the whole value. Descriptive rows keep the single-line default. Measured across 83 drawers on a mock estate, cut values in Docker and Kubernetes drawers went from 148 to 0 at 390px, 28 to 0 at 768px and 7 to 0 at 1440px; at 1440px only the rows that were cut before changed height. The mobile drawer spec now checks a Kubernetes pod drawer as well as a container drawer, and fails any drawer value cell that ends in an ellipsis, not only content cut past a clipping edge. --- .../71dcd81b3295a0b5.json | 45 +++ .../Infrastructure/ResourceDetailSummary.tsx | 32 +- ...ourceDetailSummary.identityValues.test.tsx | 148 +++++++++ tests/integration/tests/04-mobile.spec.ts | 285 +++++++++++------- 4 files changed, 382 insertions(+), 128 deletions(-) create mode 100644 frontend-modern/browser-verification/71dcd81b3295a0b5.json create mode 100644 frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailSummary.identityValues.test.tsx diff --git a/frontend-modern/browser-verification/71dcd81b3295a0b5.json b/frontend-modern/browser-verification/71dcd81b3295a0b5.json new file mode 100644 index 0000000000..90c5388fe2 --- /dev/null +++ b/frontend-modern/browser-verification/71dcd81b3295a0b5.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "base_sha": "410d84c69444ce4b59d00432b0b5510ce3016d0e", + "verified_at": "2026-10-08T07:54:48Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "a6c8fe542971abd4938326622957ddf7b7dc3b8432010261e8a3342c48095ec8" + }, + "routes": [ + "/docker", + "/kubernetes/overview", + "/kubernetes/workloads", + "/proxmox/overview" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 768, + "height": 1024 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private Vite dev server from this change's tree (origin/main d2ac0c5112 plus the change; the base then moved to 410d84c694 across 883 commits, none of which touch ResourceDetailSummary.tsx, DetailSectionTable, MetadataBadge, Table or index.css, and the only dependency changes are an icon library and a patch-level prettier bump) against a private mock-mode backend built from the same base (4 PVE nodes, 2 Docker hosts with 12 containers, 1 Kubernetes cluster with 3 nodes, 4 deployments and pods)", + "Every inline drawer on the four routes opened in turn (83 drawers per width: 12 Docker containers, 22 Kubernetes overview rows, 19 Kubernetes workload rows, 4 Proxmox nodes and 26 guests) and every value and label cell measured for clipping (scrollWidth over clientWidth with hidden overflow, split into ellipsis and silent cuts)", + "Before, on main's ResourceDetailSummary.tsx: 390px had 148 cut values in Docker and Kubernetes drawers (Primary ID ellipsized in all 12 containers and 40 Kubernetes rows, Parent 40, Hostname 16, Discovery 23, Machine ID 2, Image 3, Labels chips silently cut in all 12 containers with no ellipsis, 161px shown of up to 343px); 768px had 28 (Labels 2, Kubernetes Primary ID 26); 1440px had 7 (Labels 5, Kubernetes Primary ID 2)", + "After: zero cut values in Docker and Kubernetes drawers at 390, 768 and 1440; pod names such as checkout-api-6c746d5bcf-c7z2p, Primary IDs, parent cluster IDs, image tags and every label chip read in full, wrapping inside the drawer", + "Proxmox node and guest drawers (NodeDrawerOverview, GuestDrawerOverview, not touched) show the same residual cuts before and after: node Pulse coverage and CPU model, guest System and the Last completed backup label", + "1440x900 A/B by swapping main's file under the same Vite session: 12 of 15 sampled drawers have identical row heights; the 3 that changed were the ones clipped before (two Kubernetes node Primary ID rows 20px to 34.3px; the customer-portal Labels row 288px to 320px with its two cut chips, 274px of 300px and of 279px, now whole); section cards keep the same three-across 424px layout" + ], + "interactions": [ + "Opened and closed each row's detail disclosure on /docker, /kubernetes/overview, /kubernetes/workloads and /proxmox/overview", + "Resized to 390x844, 768x1024 and 1440x900 and re-ran the clip sweep at each width, before and after the change", + "Swapped main's ResourceDetailSummary.tsx in and out under the running Vite server and compared drawer row heights at 1440x900" + ] +} diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx index 2194f2126a..ddb078b0a8 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx @@ -80,6 +80,14 @@ const richRow = ( options: Pick = {}, ): DetailRow => ({ label, value, valueContent, ...options }); +// The drawer is where a truncated table cell sends the operator for the full +// value, so identifying values (IDs, names, image references, addresses) wrap +// here instead of ending in an ellipsis. Their distinguishing part is often at +// the end (a pod suffix, a hash, an image tag), and a phone cannot hover the +// title to read it. +const identityRow = (label: string, value?: string | null) => + makeDetailRow(label, value, { wrap: true }); + // A Docker container's runtime rows match its table row: Docker's own state, // the health check on a row of its own while it runs, and uptime only for the // current run (Docker keeps the last value on a stopped container). @@ -126,7 +134,7 @@ const dockerSection = (docker: NonNullable, now: number): De : null; const rows = compactDetailRows([ - makeDetailRow('Image', image, { title: image }), + identityRow('Image', image), makeDetailRow('Image update', updateState, { title: update?.error, tone: update?.updateAvailable ? 'accent' : update?.error ? 'danger' : 'default', @@ -173,13 +181,13 @@ const dockerSection = (docker: NonNullable, now: number): De title: new Date(finishedAt).toLocaleString(), }) : null, - makeDetailRow('Podman pod', podmanPodName), - makeDetailRow('Podman pod ID', podmanPodId), + identityRow('Podman pod', podmanPodName), + identityRow('Podman pod ID', podmanPodId), typeof docker.podman?.infra === 'boolean' ? makeDetailRow('Podman infra', docker.podman.infra ? 'Yes' : 'No') : null, - makeDetailRow('Compose project', composeProject), - makeDetailRow('Compose service', composeService), + identityRow('Compose project', composeProject), + identityRow('Compose service', composeService), makeDetailRow('Auto-update', autoUpdatePolicy), makeDetailRow('User namespace', userNamespace), blockReadBytes !== null ? makeDetailRow('Block I/O read', formatBytes(blockReadBytes)) : null, @@ -193,13 +201,15 @@ const dockerSection = (docker: NonNullable, now: number): De
{([key, value]) => ( - {key} : {value} - + )}
, @@ -226,8 +236,8 @@ export const InlineResourceSummaryTables: Component makeDetailRow(row.label, row.value)), - props.showPlatformId ? makeDetailRow('Platform ID', props.resource.platformId) : null, + ...props.drawer.primaryIdentityRows().map((row) => identityRow(row.label, row.value)), + props.showPlatformId ? identityRow('Platform ID', props.resource.platformId) : null, props.drawer.identityIpValues().length > 0 ? richRow( 'IP Addresses', @@ -289,7 +299,7 @@ export const InlineResourceSummaryTables: Component> = {}, +) => + ({ + primaryIdentityRows: () => [], + identityIpValues: () => [], + identityAliasValues: () => [], + aliasPreviewValues: () => [], + hasAliasOverflow: () => false, + identityCardHasRichData: () => true, + sourceSummary: () => null, + lastSeen: () => '', + lastSeenAbsolute: () => '', + ...overrides, + }) as unknown as UseResourceDetailDrawerStateResult; + +const pod: Resource = { + id: 'pod-1', + name: 'checkout-api-6c746d5bcf-c7z2p', + displayName: 'checkout-api-6c746d5bcf-c7z2p', + platformId: 'k8s-production-1-agent', + platformType: 'kubernetes', + sourceType: 'agent', + type: 'pod', + status: 'online', + uptime: 3_600, + lastSeen: Date.parse('2026-10-08T07:00:00Z'), +}; + +const container: Resource = { + id: 'container-1', + name: 'customer-portal', + displayName: 'customer-portal', + platformId: 'docker-host-1', + platformType: 'docker', + sourceType: 'agent', + type: 'app-container', + status: 'running', + lastSeen: Date.parse('2026-10-08T07:00:00Z'), + docker: { + image: 'ghcr.io/pulse-demo/customer-portal:2026.04', + labels: { + 'com.docker.compose.project': 'auth-service-01', + 'io.containers.capabilities': 'CHOWN,DAC_OVERRIDE,SETUID,SETGID,NET_BIND_SERVICE', + }, + podman: { + podName: 'customer-portal-pod', + podId: '5ff108b94376a2c1d7e0f4b9c3a85e6d1f2b7c94a0e3d6f8b1c5a7e9d2f4b6a8', + }, + }, +}; + +// The span that holds a plain detail row's value, found by its full text. +const valueSpan = (section: HTMLElement, value: string): HTMLElement => { + const span = within(section).getByText(value); + expect(span.tagName).toBe('SPAN'); + return span; +}; + +const expectWrapped = (span: HTMLElement) => { + expect(span.className).toContain('whitespace-normal'); + expect(span.className).not.toContain('truncate'); +}; + +afterEach(cleanup); + +describe('resource drawer identity values', () => { + it('shows identity rows whole, since their distinguishing part is at the end', () => { + render(() => ( + [ + { label: 'Hostname', value: 'checkout-api-6c746d5bcf-c7z2p' }, + { + label: 'Primary ID', + value: 'pod:k8s:k8s-production-1:pod:nginx-d2541-3-ec73ea11c6', + }, + { label: 'Parent', value: 'k8s-cluster-afcb623d93ff2ae7' }, + { label: 'Discovery', value: 'pod:nginx-d2541-3-ec73ea11c6' }, + ], + identityIpValues: () => ['2001:db8:85a3:0:0:8a2e:370:7334'], + })} + showPlatformId + /> + )); + + const identity = screen.getByTestId('resource-identity-section'); + for (const value of [ + 'checkout-api-6c746d5bcf-c7z2p', + 'pod:k8s:k8s-production-1:pod:nginx-d2541-3-ec73ea11c6', + 'k8s-cluster-afcb623d93ff2ae7', + 'pod:nginx-d2541-3-ec73ea11c6', + 'k8s-production-1-agent', + ]) { + expectWrapped(valueSpan(identity, value)); + } + expectWrapped( + valueSpan( + screen.getByTestId('resource-current-state-section'), + '2001:db8:85a3:0:0:8a2e:370:7334', + ), + ); + + // Descriptive rows keep the single-line default. + const runtime = screen.getByTestId('resource-runtime-context-section'); + expect(valueSpan(runtime, 'online').className).toContain('truncate'); + }); + + it('wraps container image references, pod and compose names, and label chips', () => { + render(() => ( + + )); + + const section = screen.getByTestId('resource-docker-container-section'); + for (const value of [ + 'ghcr.io/pulse-demo/customer-portal:2026.04', + 'customer-portal-pod', + '5ff108b94376a2c1d7e0f4b9c3a85e6d1f2b7c94a0e3d6f8b1c5a7e9d2f4b6a8', + 'auth-service-01', + ]) { + expectWrapped(valueSpan(section, value)); + } + + // An inline-flex chip draws no ellipsis, so a truncating one would cut a + // long label silently; the shared badge wraps it instead. + const chip = within(section).getByTitle( + 'io.containers.capabilities: CHOWN,DAC_OVERRIDE,SETUID,SETGID,NET_BIND_SERVICE', + ); + expect(chip.textContent).toBe( + 'io.containers.capabilities: CHOWN,DAC_OVERRIDE,SETUID,SETGID,NET_BIND_SERVICE', + ); + expect(chip.className).toContain('wrap-anywhere'); + expect(chip.className).toContain('max-w-full'); + expect(chip.className).not.toContain('truncate'); + }); +}); diff --git a/tests/integration/tests/04-mobile.spec.ts b/tests/integration/tests/04-mobile.spec.ts index 6e1dccce53..780c95a0c1 100644 --- a/tests/integration/tests/04-mobile.spec.ts +++ b/tests/integration/tests/04-mobile.spec.ts @@ -80,6 +80,106 @@ const gotoMobileRoute = async ( throw lastError; }; +// Runs in the page against one inline drawer section and lists every value +// that is cut: content crossing a clipping box, or a value cell that ends in +// an ellipsis. The drawer is where a truncated table cell sends the reader +// for the full value, so neither is allowed there. +const findCutDrawerValues = (section: Element): string[] => { + // Below lg the inline drawer's content shell clips anything past it. + let shell: HTMLElement | null = section.parentElement; + while (shell && window.getComputedStyle(shell).overflowX !== "clip") { + shell = shell.parentElement; + } + if (!shell) return ["no clipping drawer content shell"]; + + const offenders: string[] = []; + const walker = document.createTreeWalker( + section, + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, + ); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + let rect: DOMRect; + let inlineLevel: boolean; + if (node.nodeType === Node.TEXT_NODE) { + if (!node.textContent?.trim()) continue; + const range = document.createRange(); + range.selectNodeContents(node); + rect = range.getBoundingClientRect(); + inlineLevel = true; + } else { + rect = (node as Element).getBoundingClientRect(); + inlineLevel = window + .getComputedStyle(node as Element) + .display.startsWith("inline"); + } + const left = rect.left; + let right = rect.right; + // Inline content that truncates with an ellipsis ends inside the block + // whose line it sits on; the value-cell check below reports that case. + // The fixed-layout detail cell also declares an ellipsis, but that only + // marks its own line: a block child such as a chip row crosses the cell + // edge with no ellipsis and is silently cut. + if (inlineLevel) { + let line = node.parentElement; + while ( + line && + line !== shell && + ["inline", "contents"].includes(window.getComputedStyle(line).display) + ) { + line = line.parentElement; + } + const lineStyle = line ? window.getComputedStyle(line) : null; + if ( + line && + lineStyle?.textOverflow === "ellipsis" && + lineStyle.overflowX !== "visible" && + ["block", "inline-block", "table-cell", "list-item", "flow-root"].includes( + lineStyle.display, + ) + ) { + right = Math.min(right, line.getBoundingClientRect().right); + } + } + // Any clipping box on the way up (a chip, the detail cell, the table + // wrapper, the drawer shell) hides whatever still crosses either edge. + for ( + let box: HTMLElement | null = node.parentElement; + box; + box = box === shell ? null : box.parentElement + ) { + const style = window.getComputedStyle(box); + if (style.overflowX === "visible" || style.display === "contents") { + continue; + } + const edges = box.getBoundingClientRect(); + const past = Math.max(right - edges.right, edges.left - left); + if (past > 1) { + offenders.push( + `${node.textContent?.trim().slice(0, 60)} (${Math.round(past)}px past ${box.tagName.toLowerCase()})`, + ); + break; + } + } + } + + for (const cell of Array.from(section.querySelectorAll("tr > td:last-child"))) { + for (const element of [cell, ...Array.from(cell.querySelectorAll("*"))]) { + const style = window.getComputedStyle(element); + if ( + style.textOverflow === "ellipsis" && + style.overflowX !== "visible" && + element.scrollWidth > element.clientWidth + 1 + ) { + offenders.push( + `${element.textContent?.trim().slice(0, 60)} (ends in an ellipsis)`, + ); + break; + } + } + } + return offenders; +}; + test.describe("Mobile viewport flows", () => { test.beforeEach(async ({ page }) => { await ensureAuthenticated(page); @@ -401,129 +501,80 @@ test.describe("Mobile viewport flows", () => { ); }); - test("Drawer identity chips wrap inside the drawer content instead of being clipped", async ({ + test("Drawer identity values show in full inside the drawer instead of being cut", async ({ page, }) => { await waitForDefaultMockRuntimeReady(page); - await page.goto("/docker"); + const requireRows = ["1", "true", "yes", "on"].includes( + String(process.env.PULSE_E2E_REQUIRE_DEFAULT_MOCK_READY || "") + .trim() + .toLowerCase(), + ); - // Container drawers carry long generated aliases such as - // app-container- that are wider than the identity value cell. - const containerRow = page.locator("tr[data-docker-container-row]").first(); - if ( - ["1", "true", "yes", "on"].includes( - String(process.env.PULSE_E2E_REQUIRE_DEFAULT_MOCK_READY || "") - .trim() - .toLowerCase(), - ) - ) { - // CI runs the default mock estate, which always has Docker containers. - await expect(containerRow).toBeVisible({ timeout: 15_000 }); - } else { - const containerRowVisible = await containerRow - .waitFor({ state: "visible", timeout: 15_000 }) - .then( - () => true, - () => false, - ); - if (!containerRowVisible) { - test.skip(true, "No Docker container rows available to expand"); + // Identity values differ at their ends. Container drawers carry generated + // IDs (app-container:-), image tags and long label chips; + // pods from one ReplicaSet differ only in their name suffix, primary ID + // hash and the parent cluster ID. A phone cannot hover a title to read + // the rest, so the drawer must show each value whole. + const drawers = [ + { + route: "/docker", + row: "tr[data-docker-container-row]", + sections: [ + "resource-identity-section", + "resource-docker-container-section", + ], + identityLabel: "Aliases", + }, + { + route: "/kubernetes", + row: "tr[data-kubernetes-pod-row]", + sections: ["resource-identity-section"], + identityLabel: "Parent", + }, + ]; + let checked = 0; + for (const drawer of drawers) { + await page.goto(drawer.route); + const row = page.locator(drawer.row).first(); + if (requireRows) { + // CI runs the default mock estate, which always has containers and pods. + await expect(row).toBeVisible({ timeout: 15_000 }); + } else { + const rowVisible = await row + .waitFor({ state: "visible", timeout: 15_000 }) + .then( + () => true, + () => false, + ); + if (!rowVisible) continue; } + // On phones the whole row is the disclosure target. + await row.locator("td").first().click(); + await expect( + row.getByRole("button", { name: /^Collapse details for / }), + ).toHaveAttribute("aria-expanded", "true"); + if (requireRows) { + // The default mock's first container and pod carry these long rows. + await expect( + page + .locator('[data-testid="resource-identity-section"]') + .first() + .getByText(drawer.identityLabel, { exact: true }), + ).toBeVisible(); + } + + for (const testId of drawer.sections) { + const section = page.locator(`[data-testid="${testId}"]`).first(); + await expect(section).toBeVisible(); + const cutOff = await section.evaluate(findCutDrawerValues); + expect(cutOff, `${drawer.route} ${testId}`).toEqual([]); + } + checked += 1; + } + if (checked === 0) { + test.skip(true, "No container or pod rows available to expand"); } - // On phones the whole row is the disclosure target. - await containerRow.locator("td").first().click(); - await expect( - containerRow.getByRole("button", { name: /^Collapse details for / }), - ).toHaveAttribute("aria-expanded", "true"); - - const identity = page - .locator('[data-testid="resource-identity-section"]') - .first(); - await expect(identity).toBeVisible(); - await expect(identity.getByText("Aliases")).toBeVisible(); - - const cutOff = await identity.evaluate((section) => { - // Below lg the inline drawer's content shell clips anything past it. - let shell: HTMLElement | null = section.parentElement; - while (shell && window.getComputedStyle(shell).overflowX !== "clip") { - shell = shell.parentElement; - } - if (!shell) return ["no clipping drawer content shell"]; - - const offenders: string[] = []; - const walker = document.createTreeWalker( - section, - NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, - ); - for (let node = walker.nextNode(); node; node = walker.nextNode()) { - let rect: DOMRect; - let inlineLevel: boolean; - if (node.nodeType === Node.TEXT_NODE) { - if (!node.textContent?.trim()) continue; - const range = document.createRange(); - range.selectNodeContents(node); - rect = range.getBoundingClientRect(); - inlineLevel = true; - } else { - rect = (node as Element).getBoundingClientRect(); - inlineLevel = window - .getComputedStyle(node as Element) - .display.startsWith("inline"); - } - const left = rect.left; - let right = rect.right; - // Inline content that truncates with an ellipsis is meant to end - // inside the block whose line it sits on (a truncating value span or - // label cell). The fixed-layout detail cell also declares an ellipsis, - // but that only marks its own line: a block child such as the chip - // row crosses the cell edge with no ellipsis and is silently cut. - if (inlineLevel) { - let line = node.parentElement; - while ( - line && - line !== shell && - ["inline", "contents"].includes( - window.getComputedStyle(line).display, - ) - ) { - line = line.parentElement; - } - const lineStyle = line ? window.getComputedStyle(line) : null; - if ( - line && - lineStyle?.textOverflow === "ellipsis" && - lineStyle.overflowX !== "visible" && - ["block", "inline-block", "table-cell", "list-item", "flow-root"].includes( - lineStyle.display, - ) - ) { - right = Math.min(right, line.getBoundingClientRect().right); - } - } - // Any clipping box on the way up (the detail cell, the table wrapper, - // the drawer shell) hides whatever still crosses either of its edges. - for ( - let box: HTMLElement | null = node.parentElement; - box; - box = box === shell ? null : box.parentElement - ) { - const style = window.getComputedStyle(box); - if (style.overflowX === "visible" || style.display === "contents") { - continue; - } - const edges = box.getBoundingClientRect(); - const past = Math.max(right - edges.right, edges.left - left); - if (past > 1) { - offenders.push( - `${node.textContent?.trim().slice(0, 60)} (${Math.round(past)}px past ${box.tagName.toLowerCase()})`, - ); - break; - } - } - } - return offenders; - }); - expect(cutOff).toEqual([]); }); test("Infrastructure landing loads without horizontal overflow at mobile viewport", async ({