mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 20:52:51 +00:00
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:
@@ -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
|
may own the platform-specific section data, but they must not recreate local
|
||||||
`DetailField` grids or route platform-neutral detail tables through a
|
`DetailField` grids or route platform-neutral detail tables through a
|
||||||
provider-named primitive.
|
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.
|
Platform row-detail disclosure controls are also registry-backed templates.
|
||||||
`frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx`
|
`frontend-modern/src/features/platformPage/PlatformResourceDetailTableRow.tsx`
|
||||||
owns `PlatformResourceDetailToggleButton`, which composes
|
owns `PlatformResourceDetailToggleButton`, which composes
|
||||||
|
|||||||
@@ -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
|
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
|
changes in that overview must preserve the filesystem-first ordering rather
|
||||||
than letting the shorter tag list take the wide column.
|
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
|
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
|
boundary for collapse: Workloads owns which inline row is selected and the
|
||||||
close handler, while
|
close handler, while
|
||||||
|
|||||||
@@ -1,40 +1,54 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"base_sha": "014190ea4c630397e13088e3888bffb93012b8f3",
|
"base_sha": "cc1dca44d0dfd6b85eefa0c4b5fd0593cb0bcb8d",
|
||||||
"verified_at": "2026-09-22T11:10:32.326040Z",
|
"verified_at": "2026-09-22T13:31:09.864671Z",
|
||||||
"result": "passed",
|
"result": "passed",
|
||||||
"changed_paths": [
|
"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": {
|
"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": [
|
"routes": [
|
||||||
"/proxmox/backups (production component fixture)",
|
"GuestDrawerOverview production-component fixture /proof.html"
|
||||||
"/browser-tests/pbs-host-history-correlation.html"
|
|
||||||
],
|
],
|
||||||
"viewports": [
|
"viewports": [
|
||||||
|
{
|
||||||
|
"width": 1600,
|
||||||
|
"height": 1100
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
"height": 900
|
"height": 1100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"width": 768,
|
||||||
|
"height": 1100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"width": 390,
|
"width": 390,
|
||||||
"height": 844
|
"height": 1100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"width": 320,
|
||||||
|
"height": 1100
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"states": [
|
"states": [
|
||||||
"Two datastores on one PBS, with a single agent represented by guest and standalone rows",
|
"Guest with System, Memory, Filesystems and Tags sections",
|
||||||
"History on tank, archive, then tank again, with populated synthetic CPU/memory/disk/network history",
|
"Shared-prefix mount paths ending in Films and Series",
|
||||||
"Fresh resource snapshots with changing usage and alternating datastore array order"
|
"Long path with spaces and 140-character unbroken path segment",
|
||||||
|
"1280px desktop with a constrained 650px pane; tablet/phone touch emulation"
|
||||||
],
|
],
|
||||||
"interactions": [
|
"interactions": [
|
||||||
"Keyboard-expand each datastore row and select History",
|
"Read all paths without hovering or scrolling horizontally",
|
||||||
"Check both datastore identities before each switch",
|
"Assert text ranges fit all five label boxes at all five layouts, no page overflow or uncaught errors, and five progress bars",
|
||||||
"Refresh three snapshots at each viewport; assert updated usage, retained drawer DOM, selected History tab and seven chart paths",
|
"Inspect before/after desktop, constrained desktop and smallest-phone screenshots"
|
||||||
"Assert all six history requests use vm/proxmox:100, never the PBS service target",
|
|
||||||
"Inspect desktop and mobile screenshots; no uncaught page errors"
|
|
||||||
],
|
],
|
||||||
"command": "pulse-worker-browser .pbs-proof/check.cjs (assigned workspace root; invokes scripts/check-pbs-host-history-correlation.cjs in the disposable source copy)",
|
"command": "pulse-worker-browser .proof-filesystems/after.cjs",
|
||||||
"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."
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
|||||||
.join(' · '),
|
.join(' · '),
|
||||||
{
|
{
|
||||||
title: disk.labelTitle,
|
title: disk.labelTitle,
|
||||||
|
layout: 'stacked',
|
||||||
wrap: true,
|
wrap: true,
|
||||||
progress:
|
progress:
|
||||||
disk.progressValue === null
|
disk.progressValue === null
|
||||||
|
|||||||
+78
@@ -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)]',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+10
@@ -1653,6 +1653,16 @@ describe('Workloads performance contract', () => {
|
|||||||
expect(filesystemsSectionIndex).toBeGreaterThan(-1);
|
expect(filesystemsSectionIndex).toBeGreaterThan(-1);
|
||||||
expect(tagsSectionIndex).toBeGreaterThan(-1);
|
expect(tagsSectionIndex).toBeGreaterThan(-1);
|
||||||
expect(filesystemsSectionIndex).toBeLessThan(tagsSectionIndex);
|
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(guestDrawerOverviewSource).toContain('TechnicalDetailsSection');
|
||||||
expect(nodeDrawerOverviewSource).toContain('TechnicalDetailsSection');
|
expect(nodeDrawerOverviewSource).toContain('TechnicalDetailsSection');
|
||||||
const retiredInfoCardFrameClass = [
|
const retiredInfoCardFrameClass = [
|
||||||
|
|||||||
@@ -90,16 +90,26 @@ export const DetailSectionTable: Component<{
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
<For each={section.rows}>
|
<For each={section.rows}>
|
||||||
{(row) => (
|
{(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">
|
<TableRow
|
||||||
<TableCell class="w-[38%] px-2 py-1 align-top text-muted lg:w-auto lg:px-0 lg:py-0">
|
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.label}
|
>
|
||||||
</TableCell>
|
{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
|
<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(
|
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.tone,
|
||||||
)} ${row.valueClass ?? ''}`}
|
)} ${row.valueClass ?? ''}`}
|
||||||
title={row.title ?? row.value}
|
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 ?? (
|
{row.valueContent ?? (
|
||||||
<span
|
<span
|
||||||
title={row.title ?? row.value}
|
title={row.title ?? row.value}
|
||||||
|
|||||||
@@ -8006,6 +8006,16 @@ describe('shared primitive guardrails', () => {
|
|||||||
expect(detailSectionTableSource).toContain('lg:flex-none');
|
expect(detailSectionTableSource).toContain('lg:flex-none');
|
||||||
expect(detailSectionTableSource).not.toContain('lg:flex-1');
|
expect(detailSectionTableSource).not.toContain('lg:flex-1');
|
||||||
expect(detailSectionTableSource).toContain('lg:grid-cols-[7rem_minmax(0,1fr)]');
|
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:text-left');
|
||||||
expect(detailSectionTableSource).toContain('lg:divide-y-0');
|
expect(detailSectionTableSource).toContain('lg:divide-y-0');
|
||||||
expect(detailSectionModelSource).toContain('footerContent?: JSX.Element');
|
expect(detailSectionModelSource).toContain('footerContent?: JSX.Element');
|
||||||
|
|||||||
@@ -185,6 +185,34 @@ describe('DetailSectionTable', () => {
|
|||||||
expect(fill?.firstElementChild).toHaveClass('bg-emerald-500');
|
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', () => {
|
it('fills five desktop sections on the same three-column tracks', () => {
|
||||||
const { container } = render(() => (
|
const { container } = render(() => (
|
||||||
<DetailSectionTable
|
<DetailSectionTable
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export type DetailRow = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
tone?: DetailValueTone;
|
tone?: DetailValueTone;
|
||||||
wrap?: boolean;
|
wrap?: boolean;
|
||||||
|
/** Put long identifying labels above the value instead of in the compact label column. */
|
||||||
|
layout?: 'stacked';
|
||||||
progress?: DetailRowProgress;
|
progress?: DetailRowProgress;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ export const makeDetailRow = (
|
|||||||
value?: string | null,
|
value?: string | null,
|
||||||
options: Pick<
|
options: Pick<
|
||||||
DetailRow,
|
DetailRow,
|
||||||
'title' | 'tone' | 'wrap' | 'progress' | 'valueContent' | 'valueClass'
|
'title' | 'tone' | 'wrap' | 'layout' | 'progress' | 'valueContent' | 'valueClass'
|
||||||
> = {},
|
> = {},
|
||||||
): DetailRow | null => {
|
): DetailRow | null => {
|
||||||
const trimmed = value?.trim();
|
const trimmed = value?.trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user