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
This commit is contained in:
pulse-triage[bot] 2026-09-22 14:42:22 +01:00
parent cc1dca44d0
commit da19341fcd
10 changed files with 190 additions and 23 deletions

View file

@ -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

View file

@ -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

View file

@ -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."
}

View file

@ -197,6 +197,7 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
.join(' · '),
{
title: disk.labelTitle,
layout: 'stacked',
wrap: true,
progress:
disk.progressValue === null

View file

@ -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<typeof GuestDrawerOverview> = {
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(() => <GuestDrawerOverview {...props} />);
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)]',
);
});
});

View file

@ -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 = [

View file

@ -90,16 +90,26 @@ export const DetailSectionTable: Component<{
</TableRow>
<For each={section.rows}>
{(row) => (
<TableRow class="lg:grid lg:min-w-0 lg:grid-cols-[7rem_minmax(0,1fr)] lg:items-start lg:gap-3 lg:py-0.5 lg:hover:bg-transparent">
<TableCell class="w-[38%] px-2 py-1 align-top text-muted lg:w-auto lg:px-0 lg:py-0">
{row.label}
</TableCell>
<TableRow
class={`lg:grid lg:min-w-0 lg:items-start lg:gap-3 lg:py-0.5 lg:hover:bg-transparent ${row.layout === 'stacked' ? 'lg:grid-cols-[minmax(0,1fr)]' : 'lg:grid-cols-[7rem_minmax(0,1fr)]'}`}
>
{row.layout !== 'stacked' ? (
<TableCell class="w-[38%] px-2 py-1 align-top text-muted lg:w-auto lg:px-0 lg:py-0">
{row.label}
</TableCell>
) : null}
<TableCell
colspan={row.layout === 'stacked' ? 2 : undefined}
class={`px-2 py-1 text-right align-top font-medium lg:min-w-0 lg:px-0 lg:py-0 lg:text-left ${detailValueToneClass(
row.tone,
)} ${row.valueClass ?? ''}`}
title={row.title ?? row.value}
>
{row.layout === 'stacked' ? (
<span class="mb-1 block whitespace-normal text-left font-normal leading-snug text-muted [overflow-wrap:anywhere]">
{row.label}
</span>
) : null}
{row.valueContent ?? (
<span
title={row.title ?? row.value}

View file

@ -8006,6 +8006,16 @@ describe('shared primitive guardrails', () => {
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');

View file

@ -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(() => (
<DetailSectionTable
sections={[
{
label: 'Filesystems',
rows: compactDetailRows([
makeDetailRow(path, '50% · 5 GB/10 GB', { layout: 'stacked', wrap: true }),
makeDetailRow('Status', 'Available'),
]),
},
]}
/>
));
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(() => (
<DetailSectionTable

View file

@ -17,6 +17,8 @@ export type DetailRow = {
title?: string;
tone?: DetailValueTone;
wrap?: boolean;
/** Put long identifying labels above the value instead of in the compact label column. */
layout?: 'stacked';
progress?: DetailRowProgress;
};
@ -32,7 +34,7 @@ export const makeDetailRow = (
value?: string | null,
options: Pick<
DetailRow,
'title' | 'tone' | 'wrap' | 'progress' | 'valueContent' | 'valueClass'
'title' | 'tone' | 'wrap' | 'layout' | 'progress' | 'valueContent' | 'valueClass'
> = {},
): DetailRow | null => {
const trimmed = value?.trim();