fix: restore filesystem progress bars in drawers

This commit is contained in:
rcourtman 2026-08-26 23:30:51 +01:00
parent 9ddfc69ec7
commit 656aab0213
12 changed files with 167 additions and 16 deletions

View file

@ -1207,6 +1207,11 @@ not a replacement status card, CTA band, or page-local nested card.
classes, table rendering, and inline close-action chrome must come from
`detailSectionModel.ts`, `DetailSectionTable`, and `InlineDetailPanel`
instead of local `DetailField` grids or provider-named reusable primitives.
A compact detail row may carry optional bounded progress metadata, but the
shared `DetailSectionTable` must render that metadata through the CSP-safe
`ProgressBar` while preserving the row's textual value as the primary
operator-readable fact. Feature surfaces must omit the metadata when the
measurement is unavailable rather than presenting an empty bar as 0%.
Resource-detail drawer byte labels, integer labels, and count pluralization
are part of that same primitive family: provider drawer models choose the
fields and domain labels, but numeric detail values must route through
@ -3751,6 +3756,11 @@ rows, and use a bounded local label column with left-aligned values so the
layout has no ragged fixed-width island, stranded full-width final card, or
full-drawer scan distance. The responsive presentation stays owned by the shared primitive;
provider drawers must not fork their own desktop card renderers. Monitoring
Optional detail-row progress is also owned by that shared presentation: the
value text remains visible, `DetailSectionTable` composes `ProgressBar` for the
bounded visual fill and accessible value, and unknown measurements render no
bar. Provider drawers must not add inline width styles or local progress
geometry to their section rows.
mode, lifecycle, notes, maintenance,
automatic-action policy, saved access configuration, and action audit belong
to a dedicated `Manage` tab and must not render inside Overview. Guest, node,

View file

@ -754,6 +754,10 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
30-day, or other fixed color cutoffs are forbidden because they can
contradict both user policy and the table beside the drawer.
12. Extend workload disk-list derivations and fallback runtime wiring through `frontend-modern/src/components/Workloads/diskListModel.ts` and `frontend-modern/src/components/Workloads/useDiskListState.ts` rather than rebuilding usage math, progress-state mapping, or tooltip fallback logic inside `frontend-modern/src/components/Workloads/DiskList.tsx`
Workload drawer filesystem rows must consume the same canonical disk
presentation. Known utilization may supply bounded progress metadata to
the shared detail-row primitive while retaining percentage, capacity, and
type text; unknown usage must omit the bar instead of fabricating 0%.
Disk rows carrying the poller's negative unknown-usage sentinel
(config-only LXC mounts, #1477) must be excluded from usage math, summary
percents, and bar/mini-slot rendering in `stackedDiskBarModel.ts`, and
@ -2062,6 +2066,11 @@ compaction, table rendering, and tone classes must compose
instead of workload-local card frames or detail loops. Non-technical secondary
cards still compose `InfoCardFrame` where a framed interactive surface is
genuinely required.
Filesystem rows preserve their compact percentage, used/total, and type text
and supplement known utilization with the threshold-colored progress metadata
derived by `diskListModel.ts`. `GuestDrawerOverview.tsx` passes that metadata
to the shared detail-row primitive; it does not parse display strings, create
local bar geometry, or render a bar for the negative unknown-usage sentinel.
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,31 +1,48 @@
{
"version": 1,
"base_sha": "954ea1050aa121558b34d364ff484d8a4d482421",
"verified_at": "2026-08-26T21:47:28Z",
"base_sha": "9ddfc69ec71d8db6b313522f54c2f68db88ec914",
"verified_at": "2026-08-26T22:30:00Z",
"result": "passed",
"changed_paths": [],
"content_sha256": {},
"routes": ["/alerts/notifications"],
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx",
"frontend-modern/src/components/Workloads/diskListModel.ts",
"frontend-modern/src/components/shared/DetailSectionTable.tsx",
"frontend-modern/src/components/shared/ProgressBar.tsx",
"frontend-modern/src/components/shared/detailSectionModel.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "21bf8fd35d7c79f9bceade2addf8a952a10dfac724a90d3f892769d1d3dbdde1",
"frontend-modern/src/components/Workloads/diskListModel.ts": "7e446cbc5b6b87abb12af69e4dc5a78ae26258ffbbc8f5f70235924d3d535084",
"frontend-modern/src/components/shared/DetailSectionTable.tsx": "eff0236a3d524a041aefe18fa021cc619f61337fb9b80e74bbea07789a90e69e",
"frontend-modern/src/components/shared/ProgressBar.tsx": "8fda21a7dd22a1c00557686604dc07d7b69011d690a9618cf480eaf939e61103",
"frontend-modern/src/components/shared/detailSectionModel.ts": "42a6f8e70a03303d366efe8f8d490b1bf1bc8bfd6338d0510db35890d19ddeec"
},
"routes": [
"/proxmox/overview"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 760,
"width": 768,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"mock-mode Alerts Notifications loaded with notification delivery paused and an empty delivery-attempt log",
"the append-only alert event log supplied 100 bounded held-notification rows with resource, alert type, policy reason, and recorded message evidence",
"the same held-only activity state rendered in the narrow responsive card layout"
"multi-filesystem Proxmox guest expanded to its Overview tab on desktop",
"same expanded filesystem state in the narrow table layout at tablet width",
"same expanded filesystem state at phone width with wrapped detail text"
],
"interactions": [
"loaded the current managed mock build at /alerts/notifications and confirmed Recent delivery activity rendered Held rows even though no delivery attempt existed",
"confirmed every inspected row exposed its full recorded suppression message while its visible badge, resource/type label, plain-language reason, and relative time remained distinct",
"used Refresh delivery status and confirmed the independently loaded held rows updated and remained newest first",
"resized from 1440x900 to 760x900 and confirmed the activity header, refresh action, and held rows remained usable without clipping or page-level horizontal overflow",
"confirmed the browser console contained no errors across the desktop, refresh, and narrow checks"
"expanded checkout-web-373 and confirmed both known filesystems retain percentage, used/total, and type text",
"confirmed each filesystem exposes an accessible progress value matching its rendered fill",
"resized through 1440x900, 768x900, and 390x844 and confirmed bar alignment, readable wrapping, and zero document-level horizontal overflow",
"reloaded the rebased current build and reconfirmed matching progress values plus zero document-level horizontal overflow at desktop and phone widths"
]
}

View file

@ -998,6 +998,25 @@ describe('GuestDrawer', () => {
expect(screen.getByText('Filesystems')).toBeInTheDocument();
expect(technicalDetails().getByText('/')).toBeInTheDocument();
expect(technicalDetails().getByText(/50% · 5\.00 GB\/10\.0 GB/)).toBeInTheDocument();
const progress = technicalDetails().getByRole('progressbar', {
name: 'Filesystem / utilization',
});
expect(progress).toHaveAttribute('aria-valuenow', '50');
expect(progress.querySelector('[data-progress-fill="true"]')).toHaveAttribute('width', '50');
});
it('does not render a misleading progress bar when filesystem usage is unavailable', () => {
const disks: Disk[] = [
{ total: 10737418240, used: 0, usage: -1, mountpoint: '/mnt/config-only' },
];
render(() => <GuestDrawer guest={makeGuest({ disks })} onClose={vi.fn()} />);
expect(technicalDetails().getByText('— · ?/10.0 GB')).toBeInTheDocument();
expect(
technicalDetails().queryByRole('progressbar', {
name: 'Filesystem /mnt/config-only utilization',
}),
).not.toBeInTheDocument();
});
it('hides Filesystems card when disks is empty', () => {

View file

@ -203,7 +203,18 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
[disk.usagePercentLabel, disk.usageText, disk.typeLabel]
.filter(Boolean)
.join(' · '),
{ title: disk.labelTitle, wrap: true },
{
title: disk.labelTitle,
wrap: true,
progress:
disk.progressValue === null
? undefined
: {
value: disk.progressValue,
fillClass: disk.progressClass,
ariaLabel: `Filesystem ${disk.label} utilization`,
},
},
),
),
makeDetailRow(

View file

@ -8,6 +8,7 @@ import {
getWorkloadsDiskLabel,
getWorkloadsDiskLabelTitle,
getWorkloadsDiskProgressClass,
getWorkloadsDiskProgressValue,
getWorkloadsDiskProgressWidth,
getWorkloadsDiskTypeLabel,
getWorkloadsDiskUsagePercent,
@ -186,6 +187,20 @@ describe('diskListModel (branch coverage 0713)', () => {
});
});
describe('getWorkloadsDiskProgressValue', () => {
it('returns the numeric usage percent when live capacity and usage are available', () => {
expect(getWorkloadsDiskProgressValue(makeDisk({ used: 25, total: 100 }))).toBe(25);
});
it('returns null when the disk has no capacity', () => {
expect(getWorkloadsDiskProgressValue(makeDisk({ total: 0 }))).toBeNull();
});
it('returns null for the negative unknown-usage sentinel', () => {
expect(getWorkloadsDiskProgressValue(makeDisk({ usage: -1 }))).toBeNull();
});
});
describe('getWorkloadsDiskTypeLabel', () => {
it('uppercases a lowercase type string', () => {
expect(getWorkloadsDiskTypeLabel(makeDisk({ type: 'ext4' }))).toBe('EXT4');
@ -219,6 +234,7 @@ describe('diskListModel (branch coverage 0713)', () => {
label: '/data',
labelTitle: '/data',
progressClass: NORMAL_CLASS,
progressValue: 25,
progressWidth: '25%',
typeLabel: 'EXT4',
usageText: '25.0 B/100 B',
@ -242,6 +258,7 @@ describe('diskListModel (branch coverage 0713)', () => {
label: 'Unknown',
labelTitle: undefined,
progressClass: NORMAL_CLASS,
progressValue: 25,
progressWidth: '25%',
typeLabel: 'XFS',
// formatBytes(50) = '50.0 B', formatBytes(200) = '200 B'.
@ -264,6 +281,7 @@ describe('diskListModel (branch coverage 0713)', () => {
label: '/foo',
labelTitle: '/foo',
progressClass: NORMAL_CLASS,
progressValue: null,
progressWidth: '0%',
typeLabel: 'EXT4',
usageText: 'Usage unavailable',

View file

@ -15,6 +15,7 @@ export interface WorkloadsDiskPresentation {
label: string;
labelTitle?: string;
progressClass: string;
progressValue: number | null;
progressWidth: string;
typeLabel: string;
usageText: string;
@ -65,6 +66,11 @@ export const getWorkloadsDiskProgressClass = (
thresholds?: MetricDisplayThresholds | null,
): string => getMetricColorClass(getWorkloadsDiskUsagePercent(disk), 'disk', thresholds);
export const getWorkloadsDiskProgressValue = (disk: Disk): number | null =>
hasWorkloadsDiskCapacity(disk) && !isWorkloadsDiskUsageUnknown(disk)
? getWorkloadsDiskUsagePercent(disk)
: null;
export const getWorkloadsDiskProgressWidth = (disk: Disk): string =>
isWorkloadsDiskUsageUnknown(disk)
? '0%'
@ -84,6 +90,7 @@ export const buildWorkloadsDiskPresentation = (
label,
labelTitle: getWorkloadsDiskLabelTitle(label),
progressClass: getWorkloadsDiskProgressClass(disk, thresholds),
progressValue: getWorkloadsDiskProgressValue(disk),
progressWidth: getWorkloadsDiskProgressWidth(disk),
typeLabel: getWorkloadsDiskTypeLabel(disk),
usageText: getWorkloadsDiskUsageText(disk),

View file

@ -1,5 +1,6 @@
import { For, type Component, type JSX } from 'solid-js';
import { ObjectDrawerHeader } from './ObjectDrawerHeader';
import { ProgressBar } from './ProgressBar';
import { Table, TableBody, TableCell, TableHead, TableRow } from './Table';
import type { DetailSection, DetailValueTone } from './detailSectionModel';
@ -11,6 +12,7 @@ export {
formatDetailIntegerValue,
makeDetailRow,
type DetailRow,
type DetailRowProgress,
type DetailSection,
type DetailValueTone,
} from './detailSectionModel';
@ -75,6 +77,14 @@ export const DetailSectionTable: Component<{
>
{row.value}
</span>
{row.progress ? (
<ProgressBar
value={row.progress.value}
fillClass={row.progress.fillClass}
ariaLabel={row.progress.ariaLabel}
class="mt-1 h-1.5"
/>
) : null}
</TableCell>
</TableRow>
)}

View file

@ -4,6 +4,7 @@ interface ProgressBarProps {
value: number;
class?: string;
fillClass?: string;
ariaLabel?: string;
label?: JSX.Element;
overlays?: JSX.Element;
onMouseEnter?: JSX.EventHandlerUnion<HTMLDivElement, MouseEvent>;
@ -21,6 +22,11 @@ export const ProgressBar: Component<ProgressBarProps> = (props) => {
return (
<div
class={`relative w-full overflow-hidden rounded bg-surface-hover ${props.class ?? ''}`}
role={props.ariaLabel ? 'progressbar' : undefined}
aria-label={props.ariaLabel}
aria-valuemin={props.ariaLabel ? 0 : undefined}
aria-valuemax={props.ariaLabel ? 100 : undefined}
aria-valuenow={props.ariaLabel ? width() : undefined}
onMouseEnter={props.onMouseEnter}
onMouseLeave={props.onMouseLeave}
>

View file

@ -8065,6 +8065,10 @@ describe('shared primitive guardrails', () => {
expect(progressBarSource).not.toContain('style={');
expect(frontendIndexCssSource).toContain('.progress-fill-frame');
expect(frontendIndexCssSource).toContain('@media (prefers-reduced-motion: reduce)');
expect(detailSectionModelSource).toContain('export type DetailRowProgress');
expect(detailSectionTableSource).toContain('row.progress');
expect(detailSectionTableSource).toContain('<ProgressBar');
expect(detailSectionTableSource).not.toContain('style={{');
});
it('keeps animated numeric readouts on the shared reduced-motion primitive', () => {

View file

@ -101,6 +101,39 @@ describe('DetailSectionTable', () => {
expect(screen.getByText('Warning').closest('td')).toHaveClass('lg:text-left');
});
it('renders optional row progress without replacing its compact text value', () => {
const { container } = render(() => (
<DetailSectionTable
sections={[
{
label: 'Filesystems',
rows: [
{
label: '/',
value: '50% · 5.00 GB/10.0 GB · ROOTFS',
progress: {
value: 50,
fillClass: 'bg-emerald-500',
ariaLabel: 'Filesystem / utilization',
},
},
],
},
]}
/>
));
expect(screen.getByText('50% · 5.00 GB/10.0 GB · ROOTFS')).toBeInTheDocument();
const progress = screen.getByRole('progressbar', { name: 'Filesystem / utilization' });
expect(progress).toHaveAttribute('aria-valuemin', '0');
expect(progress).toHaveAttribute('aria-valuemax', '100');
expect(progress).toHaveAttribute('aria-valuenow', '50');
expect(progress).toHaveClass('h-1.5');
const fill = container.querySelector('[data-progress-fill="true"]');
expect(fill).toHaveAttribute('width', '50');
expect(fill?.firstElementChild).toHaveClass('bg-emerald-500');
});
it('balances five desktop sections across three- and two-card rows', () => {
const { container } = render(() => (
<DetailSectionTable

View file

@ -2,12 +2,19 @@ import { formatBytes } from '@/utils/format';
export type DetailValueTone = 'default' | 'accent' | 'success' | 'warning' | 'danger' | 'muted';
export type DetailRowProgress = {
value: number;
fillClass?: string;
ariaLabel: string;
};
export type DetailRow = {
label: string;
value: string;
title?: string;
tone?: DetailValueTone;
wrap?: boolean;
progress?: DetailRowProgress;
};
export type DetailSection = {
@ -18,7 +25,7 @@ export type DetailSection = {
export const makeDetailRow = (
label: string,
value?: string | null,
options: Pick<DetailRow, 'title' | 'tone' | 'wrap'> = {},
options: Pick<DetailRow, 'title' | 'tone' | 'wrap' | 'progress'> = {},
): DetailRow | null => {
const trimmed = value?.trim();
if (!trimmed || trimmed === '-') return null;