From da19341fcd5c0409f3178a200164a252632db533 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:42:22 +0100 Subject: [PATCH] fix(web): keep guest filesystem mount paths readable Render complete wrapping paths above usage using an opt-in shared detail-row layout. Preserve compact rows and unknown-usage semantics. Include the typed regression fixture, registered primitive and Workloads guardrails, substantive owning contracts and the content-bound five-layout browser receipt in the same candidate commit. Recompose the rejected unshared candidate without changing its browser-tested runtime bytes. Addresses #2121. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 7 ++ .../subsystems/performance-and-scalability.md | 7 ++ frontend-modern/browser-verification.json | 50 +++++++----- .../Workloads/GuestDrawerOverview.tsx | 1 + .../GuestDrawerOverview.filesystems.test.tsx | 78 +++++++++++++++++++ ...loadsSurface.performance.contract.test.tsx | 10 +++ .../components/shared/DetailSectionTable.tsx | 18 ++++- .../SharedPrimitives.guardrails.test.ts | 10 +++ .../__tests__/DetailSectionTable.test.tsx | 28 +++++++ .../components/shared/detailSectionModel.ts | 4 +- 10 files changed, 190 insertions(+), 23 deletions(-) create mode 100644 frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 4743a5e16..c4b3f14ce 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -4856,6 +4856,13 @@ row closes the panel instead of presenting a separate text button; consumers may own the platform-specific section data, but they must not recreate local `DetailField` grids or route platform-neutral detail tables through a provider-named primitive. +Long identifying labels can opt into `DetailRow.layout: 'stacked'`: the shared +renderer places the complete label above its value and progress bar in one +full-width cell on both narrow and desktop layouts. The label wraps even an +unbroken path segment rather than inheriting table nowrap/ellipsis; it must be +readable without hover or horizontal scrolling. Compact two-cell rows remain +the default. This is a presentation choice only: value formatting, unknown +usage, tone and accessible progress metadata retain their existing semantics. Platform row-detail disclosure controls are also registry-backed templates. `frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx` owns `PlatformResourceDetailToggleButton`, which composes diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 9361cbd02..7a8e38fe0 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -2301,6 +2301,13 @@ Within that compact technical layout, `GuestDrawerOverview.tsx` declares the panel and keeps the short tag list in the narrower slot. Future section-order changes in that overview must preserve the filesystem-first ordering rather than letting the shorter tag list take the wide column. +Panel width alone does not make mount paths readable. Filesystem disk rows must +opt into the shared `layout: 'stacked'` presentation so their full mount path +wraps above usage and progress at desktop and phone widths. Shared-prefix paths +must remain distinguishable without hover. Workloads supplies this row metadata, +not local table CSS or a duplicate renderer; non-filesystem rows keep the compact +default. Verification covers the opt-in at this composition boundary and actual +long-path wrapping in the shared renderer's browser proof. Guest, node, and Docker-host drawer headers follow the same frontend-primitives dependency boundary for collapse: Workloads owns which inline row is selected and the close handler, while diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index d2bc7ad83..253db6dc4 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,40 +1,54 @@ { "version": 1, - "base_sha": "014190ea4c630397e13088e3888bffb93012b8f3", - "verified_at": "2026-09-22T11:10:32.326040Z", + "base_sha": "cc1dca44d0dfd6b85eefa0c4b5fd0593cb0bcb8d", + "verified_at": "2026-09-22T13:31:09.864671Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx" + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx", + "frontend-modern/src/components/shared/DetailSectionTable.tsx", + "frontend-modern/src/components/shared/detailSectionModel.ts" ], "content_sha256": { - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "b463452e00e796a0d9ff3639f21ff7ac0d7219539cfacf50f6101282d829f78a" + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "713c26cde7852d7499138684a142e623b99f591d6b1f60c5eb639efef25376ff", + "frontend-modern/src/components/shared/DetailSectionTable.tsx": "f14935b93d3062e22215f55dcf8725589265b69a60a03dae10949b374a1e2cb8", + "frontend-modern/src/components/shared/detailSectionModel.ts": "0669d3afc5bc3d0809c3c2ee4204913c28aa87703c218ee3110b626e5792d086" }, "routes": [ - "/proxmox/backups (production component fixture)", - "/browser-tests/pbs-host-history-correlation.html" + "GuestDrawerOverview production-component fixture /proof.html" ], "viewports": [ + { + "width": 1600, + "height": 1100 + }, { "width": 1280, - "height": 900 + "height": 1100 + }, + { + "width": 768, + "height": 1100 }, { "width": 390, - "height": 844 + "height": 1100 + }, + { + "width": 320, + "height": 1100 } ], "states": [ - "Two datastores on one PBS, with a single agent represented by guest and standalone rows", - "History on tank, archive, then tank again, with populated synthetic CPU/memory/disk/network history", - "Fresh resource snapshots with changing usage and alternating datastore array order" + "Guest with System, Memory, Filesystems and Tags sections", + "Shared-prefix mount paths ending in Films and Series", + "Long path with spaces and 140-character unbroken path segment", + "1280px desktop with a constrained 650px pane; tablet/phone touch emulation" ], "interactions": [ - "Keyboard-expand each datastore row and select History", - "Check both datastore identities before each switch", - "Refresh three snapshots at each viewport; assert updated usage, retained drawer DOM, selected History tab and seven chart paths", - "Assert all six history requests use vm/proxmox:100, never the PBS service target", - "Inspect desktop and mobile screenshots; no uncaught page errors" + "Read all paths without hovering or scrolling horizontally", + "Assert text ranges fit all five label boxes at all five layouts, no page overflow or uncaught errors, and five progress bars", + "Inspect before/after desktop, constrained desktop and smallest-phone screenshots" ], - "command": "pulse-worker-browser .pbs-proof/check.cjs (assigned workspace root; invokes scripts/check-pbs-host-history-correlation.cjs in the disposable source copy)", - "notes": "Chromium 141.0.7390.37 / installed Playwright 1.56.1. Production source SHA-256 matched the assigned tree; package-lock SHA-256 87e579d28080ad1fb42992dd5b398c373aa08aff63bbf3f0ed63ac8b4a8f89a0, matching offline dependency snapshot dac6a9b347a1c76ad3bf0f5eadc17b84d468ce0d3878354e5d39e745101ccb53. Before the fix, a reversed datastore array overwrote the archive row to tank, reproduced in Chromium and the new component test. Isolating the row datastore object corrects it. Synthetic metrics/capability/AI responses, not live backend or reporter data. History reset to Summary was not reproduced, and this is not whole-issue resolution. Final screenshots visually inspected. Logs retained in web-product-h8pgc3la/pbs-browser-final.log; disposable copy retained in that output directory." + "command": "pulse-worker-browser .proof-filesystems/after.cjs", + "notes": "Chromium 141.0.7390.37 / Playwright 1.56.1. Synthetic guest, real GuestDrawerOverview, shared renderer and application CSS. Before repair, desktop labels are 112px with ellipsis; after repair complete paths wrap above usage. No live backend, published-build qualification or reporter confirmation. Other detail rows retain their compact layout." } diff --git a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx index 805d23940..39e66ca4a 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx @@ -197,6 +197,7 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { .join(' · '), { title: disk.labelTitle, + layout: 'stacked', wrap: true, progress: disk.progressValue === null diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx new file mode 100644 index 000000000..acaa858c3 --- /dev/null +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx @@ -0,0 +1,78 @@ +import { cleanup, render, screen } from '@solidjs/testing-library'; +import type { ComponentProps } from 'solid-js'; +import { afterEach, describe, expect, it } from 'vitest'; +import { GuestDrawerOverview } from '../GuestDrawerOverview'; + +const paths = ['/mnt/Plex/Media/Animation/Films', '/mnt/Plex/Media/Animation/Series']; +const props: ComponentProps = { + guest: { + id: 'guest-101', + name: 'media', + vmid: 101, + type: 'lxc', + status: 'running', + cpus: 4, + node: 'pve-a', + instance: 'lab', + cpu: 0, + disk: { total: 10 * 1024 ** 3, used: 5 * 1024 ** 3, usage: 50 }, + networkIn: 0, + networkOut: 0, + diskRead: 0, + diskWrite: 0, + uptime: 3600, + template: false, + lastBackup: 0, + tags: ['media'], + lock: '', + lastSeen: '2026-09-22T13:00:00Z', + memory: { total: 8 * 1024 ** 3, used: 2 * 1024 ** 3, free: 6 * 1024 ** 3, usage: 25 }, + disks: paths.map((mountpoint, index) => ({ + mountpoint, + type: `mp${index}`, + total: 10 * 1024 ** 3, + used: 5 * 1024 ** 3, + usage: index === 0 ? 50 : -1, + })), + }, + guestOsSummary: 'Debian 13', + agentHeading: 'Agent', + agentLabel: 'Connected', + agentTitle: 'Connected', + hasAgentInfo: true, + hasFilesystemDetails: true, + hasNetworkInterfaces: false, + hasOsInfo: true, + hasWorkloadActionAgent: false, + showInGuestAgentInstallCue: false, + ipAddresses: ['192.0.2.1'], + networkInterfaces: [], + normalizedTags: ['media'], + backupPresentation: null, + workloadActionAgentTitle: '', +}; + +describe('GuestDrawerOverview filesystem labels', () => { + afterEach(cleanup); + + it('shows shared-prefix paths above usage, including unknown usage, without relying on hover', () => { + render(() => ); + for (const path of paths) { + const label = screen.getByText(path); + expect(label.tagName).toBe('SPAN'); + expect(label).toHaveClass('whitespace-normal', '[overflow-wrap:anywhere]'); + expect(label.closest('td')).toHaveAttribute('colspan', '2'); + } + expect( + screen.getByRole('progressbar', { name: `Filesystem ${paths[0]} utilization` }), + ).toHaveAttribute('aria-valuenow', '50'); + expect( + screen.queryByRole('progressbar', { name: `Filesystem ${paths[1]} utilization` }), + ).toBeNull(); + expect(screen.getByText(/— · \?\/10.0 GB · MP1/)).toBeInTheDocument(); + expect(screen.getByText('CPUs').closest('tr')).toHaveClass('lg:grid-cols-[7rem_minmax(0,1fr)]'); + expect(screen.getByText('Values').closest('tr')).toHaveClass( + 'lg:grid-cols-[7rem_minmax(0,1fr)]', + ); + }); +}); diff --git a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx index c6a335da0..2bd43bdb7 100644 --- a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx @@ -1653,6 +1653,16 @@ describe('Workloads performance contract', () => { expect(filesystemsSectionIndex).toBeGreaterThan(-1); expect(tagsSectionIndex).toBeGreaterThan(-1); expect(filesystemsSectionIndex).toBeLessThan(tagsSectionIndex); + // Ordering cannot prevent the compact label column from clipping paths. + // Filesystem rows delegate full-path presentation to the shared renderer. + const filesystemSectionSource = guestDrawerOverviewSource.slice( + filesystemsSectionIndex, + tagsSectionIndex, + ); + expect(filesystemSectionSource).toContain("layout: 'stacked'"); + expect(filesystemSectionSource).toContain('wrap: true'); + expect(guestDrawerOverviewSource.match(/layout: 'stacked'/g)).toHaveLength(1); + expect(guestDrawerOverviewSource).toContain('TechnicalDetailsSection'); expect(nodeDrawerOverviewSource).toContain('TechnicalDetailsSection'); const retiredInfoCardFrameClass = [ diff --git a/frontend-modern/src/components/shared/DetailSectionTable.tsx b/frontend-modern/src/components/shared/DetailSectionTable.tsx index ea9b9ce58..8dc65beee 100644 --- a/frontend-modern/src/components/shared/DetailSectionTable.tsx +++ b/frontend-modern/src/components/shared/DetailSectionTable.tsx @@ -90,16 +90,26 @@ export const DetailSectionTable: Component<{ {(row) => ( - - - {row.label} - + + {row.layout !== 'stacked' ? ( + + {row.label} + + ) : null} + {row.layout === 'stacked' ? ( + + {row.label} + + ) : null} {row.valueContent ?? ( { expect(detailSectionTableSource).toContain('lg:flex-none'); expect(detailSectionTableSource).not.toContain('lg:flex-1'); expect(detailSectionTableSource).toContain('lg:grid-cols-[7rem_minmax(0,1fr)]'); + // Long identifiers opt into the shared full-width row; compact rows keep + // their original track and the table-wide truncation policy is untouched. + expect(detailSectionModelSource).toContain("layout?: 'stacked'"); + expect(detailSectionTableSource).toContain("row.layout !== 'stacked'"); + expect(detailSectionTableSource).toContain( + "colspan={row.layout === 'stacked' ? 2 : undefined}", + ); + expect(detailSectionTableSource).toContain('lg:grid-cols-[minmax(0,1fr)]'); + expect(detailSectionTableSource).toContain('[overflow-wrap:anywhere]'); + expect(detailSectionTableSource).toContain('lg:text-left'); expect(detailSectionTableSource).toContain('lg:divide-y-0'); expect(detailSectionModelSource).toContain('footerContent?: JSX.Element'); diff --git a/frontend-modern/src/components/shared/__tests__/DetailSectionTable.test.tsx b/frontend-modern/src/components/shared/__tests__/DetailSectionTable.test.tsx index deb817f32..8e88074da 100644 --- a/frontend-modern/src/components/shared/__tests__/DetailSectionTable.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/DetailSectionTable.test.tsx @@ -185,6 +185,34 @@ describe('DetailSectionTable', () => { expect(fill?.firstElementChild).toHaveClass('bg-emerald-500'); }); + it('opts long identifying labels into full-width wrapping rows without changing compact rows', () => { + const path = '/mnt/media/animation/series'; + render(() => ( + + )); + + const label = screen.getByText(path); + expect(label).toHaveClass('whitespace-normal', '[overflow-wrap:anywhere]', 'text-left'); + expect(label.closest('td')).toHaveAttribute('colspan', '2'); + expect(label.closest('tr')?.querySelectorAll('td')).toHaveLength(1); + expect(label.closest('tr')).toHaveClass('lg:grid-cols-[minmax(0,1fr)]'); + expect(screen.getByText('50% · 5 GB/10 GB').closest('td')).toBe(label.closest('td')); + expect(screen.getByText('Status').closest('tr')).toHaveClass( + 'lg:grid-cols-[7rem_minmax(0,1fr)]', + ); + expect(screen.getByText('Status').closest('tr')?.querySelectorAll('td')).toHaveLength(2); + }); + it('fills five desktop sections on the same three-column tracks', () => { const { container } = render(() => ( = {}, ): DetailRow | null => { const trimmed = value?.trim();