mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
Merge reviewed candidate 20260922T131605Z-web-product (pulse da19341fcd)
Change-source: pulse-maintainer
This commit is contained in:
commit
4b1fb511d1
10 changed files with 190 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -197,6 +197,7 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
|
|||
.join(' · '),
|
||||
{
|
||||
title: disk.labelTitle,
|
||||
layout: 'stacked',
|
||||
wrap: true,
|
||||
progress:
|
||||
disk.progressValue === null
|
||||
|
|
|
|||
|
|
@ -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)]',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue