Keep the drawer History readout still while scrubbing (#3325)

Moving the mouse across a guest drawer History chart made the panel jump
up and down. The readout above the plot re-wrapped as its text changed.
A bucket's `peak` label appeared and vanished, and value text changed
width (`789 KB/s` against `1.96 MB/s`), so the readout flipped between
one, two and three lines (four on a phone). Each flip moved the plot,
its row neighbours and everything below the chart grid by 20-40 px.

- Each readout slot (the time and every series value) reserves, in two
invisible pseudo-elements, the widest texts the loaded range can put
there. Digits compare as one shape under `tabular-nums`. The legend now
wraps by panel width alone, and scrubbing changes text only.
- The per-bucket peak moves off the legend onto the plot, as a label
beside the cursor at the spike's height. Labels for several series are
spaced apart, and they flip leftward past the middle. The slider's value
text still states each reading's average and range for assistive
technology.

Verification: a scripted scrub of the production build against a seeded
mock estate, with min-max spread on the history buckets. It ran a mouse
sweep of 121 steps and stepped the slider 40 times per chart, at 1440,
1024 and 390 px. Before the fix: 8-80 layout changes per chart. After:
none. The `GuestDrawerHistory` and `GuestDrawerOverview` suites pass
(263 tests), and the new inspection test pins equal reserves at rest and
while inspecting.
This commit is contained in:
rcourtman
2026-10-26 11:04:35 +00:00
committed by GitHub
5 changed files with 239 additions and 26 deletions
@@ -20,6 +20,21 @@
## Purpose
### Drawer History readout keeps its size while scrubbing (26 October 2026)
Scrubbing a drawer History chart changes the readout's text, never its size.
The readout time and each series value reserve, in two invisible
pseudo-elements, the widest texts the loaded range can put there (compared by
shape under `tabular-nums`), so the legend wraps by panel width alone and the
plot no longer jumps up and down under the pointer. A bucket's `peak` moved off
the legend onto the plot: an `aria-hidden` label beside the cursor at the
spike's height, spaced from other series' labels and flipped leftward past the
middle. The slider's value text still states each reading's average and range.
`GuestDrawerHistory.inspection.test.tsx` pins equal reserves at rest and while
inspecting, and the label's place on the plot. A scripted mouse scrub of the
production build at 1024 and 1440 px went from up to 80 layout changes per
sweep (20 px jumps of the plot and of everything below the chart grid) to none.
### Detail sections follow their own available width
`DetailSectionTable` owns a named inline-size container. Its existing card
@@ -15,6 +15,15 @@
## Purpose
### Drawer History scrubbing does not reflow (26 October 2026)
Reserved readout widths are derived from the points a panel already holds (one
formatted label per observation per series, recomputed only when the reply,
range or source changes) and applied through two CSS pseudo-elements per slot.
Scrubbing then changes text only, so the drawer no longer reflows its chart
grid on pointer moves. Peak labels are at most one positioned span per
inspected series. No read, timer or observer is added.
### History reads follow reporting transitions
The shared drawer history query refreshes once when source reporting authority
@@ -0,0 +1,42 @@
{
"version": 1,
"base_sha": "3cdb70a21d2ff2345f759d283d9cbf86beabc2b2",
"verified_at": "2026-10-26T10:32:29Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "c9d22fb1f1f56293624d38fae7286f0b727f668e1377fdd975da58b1f460f335"
},
"routes": [
"/proxmox/overview"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 1024,
"height": 900
},
{
"width": 390,
"height": 900
}
],
"states": [
"guest drawer Overview with Utilization, Network I/O and Disk I/O History charts from a seeded 24h stored history (mock estate, production vite build)",
"history buckets with min-max spread on every third point, so the per-bucket peak shows while inspecting",
"resting readout (Latest stored) and inspected readout (Stored) for every chart",
"phone width with later chart panels folded behind the show-more toggle",
"before/after comparison against the origin/main build of the same estate"
],
"interactions": [
"mouse scrub left-to-right-to-left across each visible chart in 121 steps, measuring every panel's top and height, the readout height, the plot box and the position of the content below the chart grid at each step: 0 layout changes after (16 to 80 per sweep before, with 20-40 px jumps)",
"keyboard inspection through the slider: Home then ArrowRight 40 times per chart, measured at each step: 0 layout changes after (8 to 27 before)",
"pointer parked on a spike bucket: peak labels render on the plot beside the cursor, stacked without overlap and flipped leftward past the middle, at 1024, 1440 and 390 px",
"no page errors in any run"
]
}
@@ -151,6 +151,30 @@ const alertToneClass = (severity: GuestDrawerHistoryAlert['severity'] | undefine
const clampNumber = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max);
// Inspecting a time swaps the readout's text, and text that changes width
// re-wraps the legend, moving the chart up and down under the pointer. Each
// readout slot therefore reserves the widest text it can show for the loaded
// range in two invisible pseudo-elements. Under tabular-nums every digit has
// one width, so texts compare by shape and the two widest by estimated advance
// are reserved.
const RESERVED_TEXT_CLASS =
'inline-grid whitespace-nowrap tabular-nums before:invisible before:[grid-area:1/1] before:content-[attr(data-reserve-a)] after:invisible after:[grid-area:1/1] after:content-[attr(data-reserve-b)]';
const NARROW_GLYPHS = new Set('.,:/- ilIjtfr');
const WIDE_GLYPHS = new Set('%@MWmw');
const estimateTextAdvance = (text: string): number =>
[...text].reduce(
(sum, glyph) => sum + (NARROW_GLYPHS.has(glyph) ? 0.3 : WIDE_GLYPHS.has(glyph) ? 0.85 : 0.6),
0,
);
const getReservedTextShapes = (texts: readonly string[]): [string, string] => {
const shapes = [...new Set(texts.map((text) => text.replace(/\d/g, '0')))].sort(
(a, b) => estimateTextAdvance(b) - estimateTextAdvance(a),
);
return [shapes[0] ?? '', shapes[1] ?? shapes[0] ?? ''];
};
const getGuestDrawerHistoryX = (timestamp: number, startTime: number, endTime: number): number => {
const plotWidth =
GUEST_DRAWER_HISTORY_CHART_WIDTH -
@@ -418,6 +442,76 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
.filter((item): item is NonNullable<typeof item> => item !== null);
});
const hoverX = createMemo(() => hoveredSeries()[0]?.x ?? null);
// While a time is inspected, name a bucket's peak when it rises a visible
// distance (a tenth of the plot) above the average the line shows. The label
// sits on the plot at the spike, beside the cursor, so it never reflows the
// readout. Labels for several series are spaced apart and kept in the plot.
const peakLabels = createMemo(() => {
const currentScale = scale();
const labels = hoveredSeries()
.filter(
(item) =>
(item.point.max ?? item.point.value) - item.point.value >=
0.1 * (currentScale.maxValue - currentScale.minValue),
)
.map((item) => ({
metric: item.metric,
color: item.color,
label: `peak ${formatHistoryChartTooltipValue(item.point.max ?? item.point.value, item.unit)}`,
left: (item.x / GUEST_DRAWER_HISTORY_CHART_WIDTH) * 100,
top:
(getGuestDrawerHistoryY(item.point.max ?? item.point.value, currentScale) /
GUEST_DRAWER_HISTORY_CHART_HEIGHT) *
100,
}))
.sort((a, b) => a.top - b.top);
const gap = 14;
const highest = (GUEST_DRAWER_HISTORY_PLOT_TOP / GUEST_DRAWER_HISTORY_CHART_HEIGHT) * 100;
const lowest =
((GUEST_DRAWER_HISTORY_CHART_HEIGHT - GUEST_DRAWER_HISTORY_PLOT_BOTTOM) /
GUEST_DRAWER_HISTORY_CHART_HEIGHT) *
100;
labels.forEach((label, index) => {
label.top = Math.max(label.top, highest, index > 0 ? labels[index - 1].top + gap : highest);
});
const overflow = (labels.at(-1)?.top ?? lowest) - lowest;
return labels.map((label) => ({
...label,
top: overflow > 0 ? Math.max(highest, label.top - overflow) : label.top,
// Past the middle the label reads leftward so it stays inside the panel.
flip: label.left > 55,
}));
});
// Every text each series' value slot can show across the loaded range.
const valueReserves = createMemo(
() =>
new Map(
series().map((item) => {
const labels = new Map(
item.points.map((point) => [
point.timestamp,
getGuestDrawerHistoryValueLabel([point], item.unit),
]),
);
return [
item.metric,
getReservedTextShapes(
observationTimes().map((timestamp) => labels.get(timestamp) ?? 'no reading'),
),
] as const;
}),
),
);
const timeReserve = createMemo(() =>
getReservedTextShapes(
observationTimes().flatMap((timestamp) => {
const time = formatHistoryChartTimeLabel(timestamp, props.range);
return [false, true].map(
(inspected) => `${getHistoryReadoutLabel(props.source, inspected)} · ${time}`,
);
}),
),
);
const displaySeries = createMemo(() =>
series().map((item, index) => {
const timestamp = readoutTimestamp();
@@ -428,13 +522,6 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
: props.currentMetrics?.[item.metric];
const isFallback =
timestamp === null && typeof fallbackValue === 'number' && Number.isFinite(fallbackValue);
// While a time is inspected, name the bucket's peak when it rises a
// visible distance (a tenth of the plot) above the average the line
// shows. The default readout stays one number per series.
const peakVisible =
point !== undefined &&
activeTimestamp() !== null &&
(point.max ?? point.value) - point.value >= 0.1 * (scale().maxValue - scale().minValue);
const alert = props.alerts?.[item.metric];
return {
...item,
@@ -443,10 +530,7 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
alert && typeof alert.value === 'number' && Number.isFinite(alert.value)
? `alert at ${formatHistoryChartTooltipValue(alert.value, item.unit)}`
: null,
peakLabel:
point && peakVisible
? `peak ${formatHistoryChartTooltipValue(point.max ?? point.value, item.unit)}`
: null,
reserve: valueReserves().get(item.metric) ?? ['', ''],
deferred,
descriptionId: `${inspectionId}-deferred-${index}`,
isCurrent: isFallback && !deferred,
@@ -508,17 +592,23 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
</h4>
<Show when={readoutTime()}>
{(time) => (
<time
class="text-[10px] tabular-nums text-muted"
data-testid="guest-history-hover-time"
dateTime={time().dateTime}
title={time().fullLabel}
aria-label={`${getHistoryReadoutLabel(props.source, true)} observation: ${time().fullLabel}`}
<span
class={`${RESERVED_TEXT_CLASS} text-[10px] text-muted`}
data-reserve-a={timeReserve()[0]}
data-reserve-b={timeReserve()[1]}
>
{getHistoryReadoutLabel(props.source, activeTimestamp() !== null)}
{' · '}
{time().label}
</time>
<time
class="[grid-area:1/1]"
data-testid="guest-history-hover-time"
dateTime={time().dateTime}
title={time().fullLabel}
aria-label={`${getHistoryReadoutLabel(props.source, true)} observation: ${time().fullLabel}`}
>
{getHistoryReadoutLabel(props.source, activeTimestamp() !== null)}
{' · '}
{time().label}
</time>
</span>
)}
</Show>
</div>
@@ -536,12 +626,13 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
<circle cx="5" cy="5" r="4" fill={item.color} />
</svg>
<span class="font-medium text-base-content">{item.label}</span>
<span class={alertToneClass(props.alerts?.[item.metric]?.severity)}>
{item.valueLabel}
<span
class={`${RESERVED_TEXT_CLASS} ${alertToneClass(props.alerts?.[item.metric]?.severity)}`}
data-reserve-a={item.reserve[0]}
data-reserve-b={item.reserve[1]}
>
<span class="[grid-area:1/1]">{item.valueLabel}</span>
</span>
<Show when={item.peakLabel}>
{(label) => <span data-history-peak={item.metric}>{label()}</span>}
</Show>
<Show when={item.alertLabel}>
{(label) => (
<span
@@ -735,6 +826,25 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
</For>
</Show>
</svg>
{/* The slider's value text states each reading's range, so these
labels are for sighted pointer readers only. */}
<For each={peakLabels()}>
{(peak) => (
<span
aria-hidden="true"
data-history-peak={peak.metric}
class={`pointer-events-none absolute inline-flex -translate-y-1/2 items-center gap-1 whitespace-nowrap rounded-xs bg-surface/95 px-1 py-0.5 text-[10px] leading-none tabular-nums text-base-content shadow-xs ${
peak.flip ? '-translate-x-[calc(100%+6px)]' : 'translate-x-1.5'
}`}
style={{ left: `${peak.left}%`, top: `${peak.top}%` }}
>
<svg class="h-2 w-2 shrink-0" viewBox="0 0 10 10">
<circle cx="5" cy="5" r="4" fill={peak.color} />
</svg>
{peak.label}
</span>
)}
</For>
<Show when={!hasStoredData() && !props.loading}>
<div class="absolute inset-x-8 inset-y-2 flex items-center justify-center rounded-xs text-xs text-muted">
No stored history in this range
@@ -361,6 +361,43 @@ describe('drawer History min-max band', () => {
within(bandUtilization()).getByRole('img', { name: 'Utilization history' }),
).toBeTruthy();
});
// Text that came and went in the readout re-wrapped it while the pointer
// moved, so the plot jumped up and down under a scrubbing mouse.
it('keeps the readout the same size at every inspected time', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
bandResponse({
cpu: [bandPoint(0, 5), bandPoint(1, 30, 10, 100), bandPoint(2, 100)],
memory: [bandPoint(0, 40), bandPoint(2, 41)],
}),
);
render(() => <GuestDrawerHistory target={bandTarget} range="1h" />);
const slider = await screen.findByRole('slider', { name: 'Inspect Utilization history' });
const slots = () =>
Array.from(bandUtilization().querySelectorAll<HTMLElement>('[data-reserve-a]')).map(
(slot) => [slot.textContent, slot.dataset.reserveA, slot.dataset.reserveB],
);
const resting = slots();
// Time, then CPU, Memory and Disk: each slot reserves the widest texts the
// range can put in it (digits compare as one shape under tabular-nums).
expect(resting.map(([, a, b]) => [a, b])).toEqual([
[expect.stringMatching(/^Latest stored · 00:00/), expect.stringMatching(/^Stored · 00:00/)],
['000.0%', '00.0%'],
['no reading', '00.0%'],
['no reading', 'no reading'],
]);
fireEvent.focus(slider);
fireEvent.input(slider, { target: { value: '1' } });
const inspected = slots();
expect(inspected.map(([text]) => text)).not.toEqual(resting.map(([text]) => text));
expect(inspected.map(([, a, b]) => [a, b])).toEqual(resting.map(([, a, b]) => [a, b]));
// The peak is labelled on the plot, not in the readout above it.
const peak = bandUtilization().querySelector('[data-history-peak="cpu"]');
expect(peak).toHaveTextContent('peak 100.0%');
expect(peak?.parentElement?.querySelector('[data-testid="guest-history-plot"]')).not.toBeNull();
});
});
describe('buildGuestDrawerHistoryBandPath', () => {