mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 05:57:27 +00:00
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:
@@ -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
|
||||
|
||||
+37
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user