mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
fix: restore filesystem progress bars in drawers
This commit is contained in:
parent
9ddfc69ec7
commit
656aab0213
12 changed files with 167 additions and 16 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue