Use canonical ProgressBar for backup row metric bars

The Backups Size and Duration columns were rendering a bespoke
`InlineProgressBar` — a small fixed-width bar floating next to text.
Every other row metric in Pulse (Storage usage, Ceph pool usage,
Workloads CPU/Memory/Disk) is rendered by `ProgressBar` from
`@/components/shared`: full-cell-width fill with the value text
overlaid on top, via a foreignObject-clipped label. Introducing a
second pattern just for these two columns was a needless divergence
called out in the second consistency pass.

Drop the bespoke component. Wrap shared `ProgressBar` in a
RowMetricBar helper that matches the Storage/Ceph convention
(`STORAGE_BAR_LABEL_WRAP_CLASS` shape) and use it for both columns.
The Size and Duration headers move to the `metric-bar` column kind
so alignment lines up with the bar's natural centring. Bars now
share the same shape, animation curve (320ms width transition from
`progress-fill`), and label-clipping behaviour as the rest of the
app — same code path, same look.
This commit is contained in:
rcourtman
2026-05-20 18:21:03 +01:00
parent 806a2331a3
commit 09bf209afd
@@ -14,6 +14,7 @@ import ChevronRightIcon from 'lucide-solid/icons/chevron-right';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup';
import { ProgressBar } from '@/components/shared/ProgressBar';
import { SearchInput } from '@/components/shared/SearchInput';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCard } from '@/components/shared/TableCard';
@@ -223,33 +224,32 @@ function formatDurationFromSeconds(seconds: number): string {
return `${h}h ${m}m`;
}
// Compact, table-row-sized horizontal usage bar. Used inline in the
// Backup files Size column (proportional to the largest archive in view)
// and in the Recent tasks Duration column (proportional to 2x the median
// duration so a typical task fills ~50% and outliers fill the bar).
//
// Layout invariant: the bar is positioned AFTER the value text in its
// cell, and the cell uses `justify-end`. Because the bar has a fixed
// width and is the rightmost element, every bar's left and right edges
// land at the same x in every row, so they read as a proper comparison
// chart instead of a row of free-floating segments.
function InlineProgressBar(props: {
// Canonical row metric bar — same shape as Storage's usage bar, Ceph's
// pool usage bar, and Workloads' MetricBar: a full-cell-width
// ProgressBar with the value text overlaid on top of the fill. The
// shared `ProgressBar` primitive (foreignObject-based fill that clips
// the label) is the one source of truth for this pattern in Pulse, so
// the Backups tabs read identically to the rest of the app.
function RowMetricBar(props: {
valuePct: number;
toneClass: string;
width?: string;
label?: string;
fillClass: string;
label: string;
tooltip?: string;
}) {
const widthClass = props.width ?? 'w-16';
return (
<div
class={`${widthClass} h-1.5 shrink-0 overflow-hidden rounded-full bg-surface`}
role="img"
aria-label={props.label}
title={props.label}
class="metric-text relative h-4 w-full min-w-[5rem] overflow-hidden"
title={props.tooltip ?? props.label}
>
<div
class={`h-full ${props.toneClass}`}
style={{ width: `${Math.max(0, Math.min(100, props.valuePct))}%` }}
<ProgressBar
value={props.valuePct}
class="h-full"
fillClass={props.fillClass}
label={
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium leading-none text-base-content tabular-nums">
<span class="max-w-full truncate px-1 text-center">{props.label}</span>
</span>
}
/>
</div>
);
@@ -1113,7 +1113,7 @@ export const ProxmoxBackupsTable: Component<{
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Created
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
<TableHead class={getPlatformTableHeadClassForKind('metric-bar')}>
Size
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('text')}>
@@ -1173,25 +1173,18 @@ export const ProxmoxBackupsTable: Component<{
</div>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}
class={`${getPlatformTableCellClassForKind('metric-bar')} text-base-content`}
>
<div class="flex items-center justify-end gap-2">
<span>{formatBytes(arc.size)}</span>
<InlineProgressBar
valuePct={
archiveSizeMaxBytes() > 0
? (arc.size / archiveSizeMaxBytes()) * 100
: 0
}
toneClass={
archiveSizeMaxBytes() > 0 &&
arc.size / archiveSizeMaxBytes() >= 0.66
? 'bg-blue-500'
: 'bg-blue-400'
}
label={`${formatBytes(arc.size)} (relative to largest file in view)`}
/>
</div>
<RowMetricBar
valuePct={
archiveSizeMaxBytes() > 0
? (arc.size / archiveSizeMaxBytes()) * 100
: 0
}
fillClass="bg-blue-500/40 dark:bg-blue-500/40"
label={formatBytes(arc.size)}
tooltip={`${formatBytes(arc.size)} (relative to largest file in view)`}
/>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
@@ -1256,7 +1249,7 @@ export const ProxmoxBackupsTable: Component<{
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
Started
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
<TableHead class={getPlatformTableHeadClassForKind('metric-bar')}>
Duration
</TableHead>
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
@@ -1315,22 +1308,18 @@ export const ProxmoxBackupsTable: Component<{
{formatRelativeTime(task.startTime, { compact: true })}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
class={`${getPlatformTableCellClassForKind('metric-bar')} text-base-content`}
>
<div class="flex items-center justify-end gap-2">
<span class="tabular-nums">
{formatDuration(task.startTime, task.endTime)}
</span>
<InlineProgressBar
valuePct={
taskDurationBaselineSeconds() > 0 && durationSec
? durationBarPct()
: 0
}
toneClass={durationToneClass()}
label={`Duration ${formatDuration(task.startTime, task.endTime)} (median ${formatDurationFromSeconds(taskDurationBaselineSeconds())})`}
/>
</div>
<RowMetricBar
valuePct={
taskDurationBaselineSeconds() > 0 && durationSec
? durationBarPct()
: 0
}
fillClass={durationToneClass()}
label={formatDuration(task.startTime, task.endTime)}
tooltip={`Duration ${formatDuration(task.startTime, task.endTime)} (median ${formatDurationFromSeconds(taskDurationBaselineSeconds())})`}
/>
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content tabular-nums`}