Show each bucket's min-max range behind drawer chart lines

Drawer charts plot one average per time bucket, so a short CPU burst or
I/O spike inside a 6-minute bucket (24h) or an hourly rollup (7d) shrinks into
the average and disappears. The trend cards this Overview replaced
stated a "24h low-high" range; the charts had nothing in its place.

Each stored point already carries its bucket's min and max. A tinted,
outlined band now spans those extremes behind every line, so a spike
stands as a spire above the average. Series whose buckets never spread
draw no band. While a time is inspected, the legend names that bucket's
peak when it rises at least a tenth of the plot above the average, and
the accessible value states each reading's average and range.

Spec 86 also allows sub-pixel clipping when it checks that the drawer's
Memory rows are fully in view. With the charts on Overview, CI placed
that table on a fractional offset and the rounded table border clipped
0.4% of it (ratio 0.9956), failing an assertion of exactly 1.
This commit is contained in:
rcourtman
2026-10-13 22:59:12 +01:00
parent b837a8a98d
commit 77131c19b1
8 changed files with 292 additions and 6 deletions
@@ -5465,7 +5465,12 @@ reading, qualified "current" or "last known". An active alert on a charted
metric tones that reading and, when it carries a numeric trigger, draws it as
a dashed line in its severity colour; `getDrawerChartAlerts` maps alert types
to chart metrics and converts I/O triggers from the alert engine's MiB/s to
the charts' bytes per second. Any
the charts' bytes per second. Each line is a bucket average, so a tinted band
behind it spans every bucket's own lowest and highest reading
(`buildGuestDrawerHistoryBandPath`): a short spike stays visible after
downsampling. While a time is inspected the readout names that bucket's peak
when it rises at least a tenth of the plot above the average, and the
accessible value states each reading's average and range. Any
warning- or danger-toned detail row
is lifted into a "Worth checking" section in front, whichever section owns it,
so demotion never hides a reason a reading cannot be trusted. Guest
@@ -4013,8 +4013,9 @@ node per observation beyond the marker's two paths, and the readout no longer
inserts or removes a line as the inspected time changes.
Numeric gridline labels are computed from the scale the panel already holds;
the compact "Worth checking" list reuses rows already derived, and an alert's
dashed trigger line reuses the alert the drawer already holds. None adds a read
or timer.
dashed trigger line reuses the alert the drawer already holds. The min-max band
reads the `min`/`max` each stored bucket already carries: one extra path per
series with any spread (none for flat data). None adds a read or timer.
The default stored-history legend resolves one latest timestamp from that
same bounded, normalized panel index, then looks up each configured series at
@@ -0,0 +1,37 @@
{
"version": 1,
"base_sha": "b837a8a98d113aa2f06008afc329912eee19b7f4",
"verified_at": "2026-10-13T21:56:00Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx",
"frontend-modern/src/components/Workloads/guestDrawerModel.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "63d543738ffb71ca86a8f9786d75c6479ae7ed0673a4f96b81620a549be63a82",
"frontend-modern/src/components/Workloads/guestDrawerModel.ts": "9e46fe5e7adc08b9199248516583745af18c3a08d225367645d0e8b47b86c64a"
},
"routes": [
"/proxmox/overview"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"final built bytes of this tree (origin/main b837a8a98d plus this change) embedded in a darwin binary, mock mode, headless Chromium on pulse-dev through a reverse tunnel, full-resolution element screenshots read by eye",
"mock history first: its stored buckets barely spread (10 of 102 points, by under 0.1 percentage points), so bands there are a hairline halo and no peak is named",
"then /api/metrics-store/history intercepted with 96 synthetic 24h buckets: CPU averaging 14-22% with two buckets peaking at 97%, network in peaking at 40 MB/s once, disk read peaking at 30 MB/s twice, memory and disk flat. Each spike shows as a tinted, outlined spire from its average line up to its peak on the Utilization, Network I/O and Disk I/O charts, while flat series draw no band",
"1440px: three charts side by side, spires visible at every spike; 390px: charts stacked, the same spires visible, no horizontal overflow"
],
"interactions": [
"expanded the first guest row; hovered the Utilization plot over the CPU spike bucket at 1440 and 390px: the header read \"Stored · <time>\" and the legend read \"CPU 22.0% peak 97.0%\" with Memory and Disk showing a single value each",
"moved the pointer away: the legend returned to \"Latest stored\" with one value per series and no peak label"
]
}
@@ -636,6 +636,36 @@ describe('GuestDrawer', () => {
expect(screen.queryByText('Open related infrastructure')).toBeNull();
});
it('adds one min-max band per series whose buckets spread, and none for flat ones', async () => {
const points = makeHistoryPoints(10);
chartsApiMocks.getMetricsHistory.mockResolvedValue({
resourceType: 'vm',
resourceId: 'inst1:node1:100',
range: '24h',
start: 1,
end: 3,
source: 'store',
metrics: {
cpu: [points[0], { ...points[1], max: 95 }, points[2]],
memory: makeHistoryPoints(20),
disk: makeHistoryPoints(30),
},
});
render(() => <GuestDrawer guest={makeGuest()} onClose={vi.fn()} />);
await fireEvent.click(screen.getByRole('tab', { name: 'Overview' }));
await waitFor(() =>
expect(
screen
.getAllByTestId('guest-history-group-chart')[0]
.querySelector('path[data-history-band="cpu"]'),
).not.toBeNull(),
);
const charts = screen.getAllByTestId('guest-history-group-chart');
expect(charts[0].querySelectorAll('path[data-history-band]')).toHaveLength(1);
expect(charts[0].querySelectorAll('path[data-history-series]')).toHaveLength(3);
expect(charts[1].querySelectorAll('path[data-history-band]')).toHaveLength(0);
});
it('shows current readings without fabricating history points', async () => {
chartsApiMocks.getMetricsHistory.mockResolvedValue({
resourceType: 'vm',
@@ -27,6 +27,7 @@ import { formatCompactSpeed } from '@/utils/format';
import {
GUEST_DRAWER_HISTORY_GROUPS,
buildGuestDrawerHistoryBandPath,
buildGuestDrawerHistoryPath,
getGuestDrawerHistoryRangeBounds,
getGuestDrawerHistoryScale,
@@ -309,7 +310,13 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
if (timestamp === undefined) return 'No stored history observations.';
const values = series().map((item) => {
const point = item.points.find((point) => point.timestamp === timestamp);
return `${item.label} ${point ? formatHistoryChartTooltipValue(point.value, item.unit) : 'no observation'}`;
if (!point) return `${item.label} no observation`;
const value = formatHistoryChartTooltipValue(point.value, item.unit);
const low = formatHistoryChartTooltipValue(point.min ?? point.value, item.unit);
const high = formatHistoryChartTooltipValue(point.max ?? point.value, item.unit);
return low === high
? `${item.label} ${value}`
: `${item.label} ${value} average, ${low} to ${high}`;
});
return `${new Date(timestamp).toLocaleString()}. ${values.join('. ')}.`;
};
@@ -425,8 +432,19 @@ 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);
return {
...item,
peakLabel:
point && peakVisible
? `peak ${formatHistoryChartTooltipValue(point.max ?? point.value, item.unit)}`
: null,
deferred,
descriptionId: `${inspectionId}-deferred-${index}`,
isCurrent: isFallback && !deferred,
@@ -517,6 +535,9 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
<span class={alertToneClass(props.alerts?.[item.metric]?.severity)}>
{item.valueLabel}
</span>
<Show when={item.peakLabel}>
{(label) => <span data-history-peak={item.metric}>{label()}</span>}
</Show>
<Show when={item.isCurrent}>
<span>current</span>
</Show>
@@ -605,6 +626,34 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
<Show when={bounds()}>
{(rangeBounds) => (
<>
<For each={drawableSeries()}>
{(item) => (
<Show
when={buildGuestDrawerHistoryBandPath(
item.points,
scale(),
rangeBounds().startTime,
rangeBounds().endTime,
GUEST_DRAWER_HISTORY_CHART_WIDTH,
GUEST_DRAWER_HISTORY_CHART_HEIGHT,
)}
>
{(band) => (
<path
data-history-band={item.metric}
d={band()}
fill={item.color}
fill-opacity="0.2"
stroke={item.color}
stroke-opacity="0.45"
stroke-width="1"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
)}
</Show>
)}
</For>
<For each={drawableSeries()}>
{(item) => (
<path
@@ -5,7 +5,10 @@ import { ChartsAPI, type AllMetricsHistoryResponse, type HistoryTimeRange } from
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
import { GuestDrawerHistory } from '../GuestDrawerHistory';
import { HOST_METRICS_HISTORY_GROUPS } from '@/components/shared/hostMetricsHistoryModel';
import type { GuestDrawerHistoryTarget } from '../guestDrawerModel';
import {
buildGuestDrawerHistoryBandPath,
type GuestDrawerHistoryTarget,
} from '../guestDrawerModel';
vi.mock('@/stores/license', () => ({
loadRuntimeCapabilities: vi.fn(async () => undefined),
@@ -272,3 +275,118 @@ describe('GuestDrawerHistory stored-observation inspection', () => {
expect(fetch).not.toHaveBeenCalled();
});
});
// Min-max band: each stored point is a bucket average carrying its extremes.
const bandStart = Date.UTC(2026, 9, 12, 12);
const bandTarget = { resourceType: 'vm' as const, resourceId: 'fixture:pve:101' };
const bandPoint = (minute: number, value: number, min = value, max = value) => ({
timestamp: bandStart + minute * 60_000,
value,
min,
max,
});
const bandResponse = (
metrics: AllMetricsHistoryResponse['metrics'],
): AllMetricsHistoryResponse => ({
...bandTarget,
range: '1h',
start: bandStart,
end: bandStart + 3 * 60_000,
source: 'store',
metrics,
});
const bandUtilization = () =>
screen
.getAllByTestId('guest-history-group-chart')
.find((el) => el.dataset.historyGroup === 'utilization')!;
// A 99% CPU burst inside a bucket that averages 20%.
const bandSpiky = {
cpu: [bandPoint(0, 10), bandPoint(1, 20, 10, 99), bandPoint(2, 12)],
memory: [bandPoint(0, 40), bandPoint(1, 40, 39.5, 41.5), bandPoint(2, 40)],
};
describe('drawer History min-max band', () => {
it("draws a band up to a bucket's peak so a short spike is not averaged away", async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(bandResponse(bandSpiky));
render(() => <GuestDrawerHistory target={bandTarget} range="1h" />);
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
const band = bandUtilization().querySelector('path[data-history-band="cpu"]');
expect(band).not.toBeNull();
// 99% on the 0-100 scale sits at y = 8 + (1 - 0.99) * 66 = 8.66 of the viewBox.
expect(band?.getAttribute('d')).toContain(',8.66');
expect(band?.getAttribute('d')).toMatch(/ Z$/);
// The average line itself still passes through 20%, not the peak.
const line = bandUtilization().querySelector('path[data-history-series="cpu"]');
expect(line?.getAttribute('d')).not.toContain(',8.66');
});
it('draws no band for buckets whose readings never spread', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
bandResponse({
...bandSpiky,
memory: [bandPoint(0, 40), bandPoint(1, 40), bandPoint(2, 40)],
}),
);
render(() => <GuestDrawerHistory target={bandTarget} range="1h" />);
await screen.findByRole('slider', { name: 'Inspect Utilization history' });
expect(bandUtilization().querySelector('path[data-history-band="memory"]')).toBeNull();
expect(bandUtilization().querySelectorAll('path[data-history-band]')).toHaveLength(1);
});
it('names the peak only while a time is inspected, and to assistive technology', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(bandResponse(bandSpiky));
render(() => <GuestDrawerHistory target={bandTarget} range="1h" />);
const slider = await screen.findByRole('slider', { name: 'Inspect Utilization history' });
// The default readout is the latest stored time: one number per series.
expect(bandUtilization().querySelector('[data-history-peak]')).toBeNull();
fireEvent.focus(slider);
fireEvent.input(slider, { target: { value: '1' } });
expect(bandUtilization().querySelector('[data-history-peak="cpu"]')).toHaveTextContent(
'peak 99.0%',
);
// Memory's bucket spread is too small to see, so it is not called a peak.
expect(bandUtilization().querySelector('[data-history-peak="memory"]')).toBeNull();
expect(slider).toHaveAttribute(
'aria-valuetext',
expect.stringContaining(
'CPU 20.0% average, 10.0% to 99.0%. Memory 40.0% average, 39.5% to 41.5%.',
),
);
fireEvent.blur(slider);
expect(bandUtilization().querySelector('[data-history-peak]')).toBeNull();
expect(
within(bandUtilization()).getByRole('img', { name: 'Utilization history' }),
).toBeTruthy();
});
});
describe('buildGuestDrawerHistoryBandPath', () => {
const scale = { minValue: 0, maxValue: 100 };
it('needs two points and some spread', () => {
expect(
buildGuestDrawerHistoryBandPath([bandPoint(0, 5, 1, 9)], scale, bandStart, bandStart + 1),
).toBe('');
expect(
buildGuestDrawerHistoryBandPath(
[bandPoint(0, 5), bandPoint(1, 6)],
scale,
bandStart,
bandStart + 60_000,
),
).toBe('');
});
it('runs forward along the highs and back along the lows', () => {
const path = buildGuestDrawerHistoryBandPath(
[bandPoint(0, 50, 0, 100), bandPoint(1, 50, 0, 100)],
scale,
bandStart,
bandStart + 60_000,
);
expect(path).toBe('M34.00,8.00 L352.00,8.00 L352.00,74.00 L34.00,74.00 Z');
});
});
@@ -361,6 +361,49 @@ export const buildGuestDrawerHistoryPath = (
.join(' ');
};
// Each stored point is a bucket average carrying its own lowest and highest
// reading. The band spans those extremes behind the average line, so a short
// spike stays visible after downsampling instead of averaging away. A segment
// whose buckets never spread draws nothing.
export const buildGuestDrawerHistoryBandPath = (
points: readonly AggregatedMetricPoint[],
scale: GuestDrawerHistoryScale,
startTime: number,
endTime: number,
width = 360,
height = 92,
): string => {
if (points.length < 2) return '';
const spreads = points.some((point) => (point.max ?? point.value) > (point.min ?? point.value));
if (!spreads) return '';
const left = 34;
const right = 8;
const top = 8;
const bottom = 18;
const plotWidth = width - left - right;
const plotHeight = height - top - bottom;
const timeSpan = Math.max(1, endTime - startTime);
const valueSpan = Math.max(1, scale.maxValue - scale.minValue);
const coordinate = (timestamp: number, value: number) => {
const x = left + ((timestamp - startTime) / timeSpan) * plotWidth;
const bounded = Math.min(Math.max(value, scale.minValue), scale.maxValue);
const y = top + (1 - (bounded - scale.minValue) / valueSpan) * plotHeight;
return `${x.toFixed(2)},${y.toFixed(2)}`;
};
const upper = points.map(
(point, index) =>
`${index === 0 ? 'M' : 'L'}${coordinate(point.timestamp, Math.max(point.max ?? point.value, point.value))}`,
);
const lower = [...points]
.reverse()
.map(
(point) => `L${coordinate(point.timestamp, Math.min(point.min ?? point.value, point.value))}`,
);
return `${[...upper, ...lower].join(' ')} Z`;
};
export const getGuestDrawerHistoryValueLabel = (
points: readonly AggregatedMetricPoint[],
unit: string,
@@ -227,7 +227,10 @@ test("hybrid guest memory follows canonical snapshots in the browser", async ({
const box = await memoryDetails.boundingBox();
return box ? box.y + box.height : Infinity;
}).toBeLessThan(viewport.height - 64);
await expect(memoryDetails).toBeInViewport({ ratio: 1 });
// Fully readable, allowing the fraction of a pixel the rounded table
// border can clip when earlier drawer content lands on a sub-pixel offset
// (0.9956 was seen at 390px once the charts moved onto Overview).
await expect(memoryDetails).toBeInViewport({ ratio: 0.98 });
await expect(memoryDetails).toContainText("100 MB");
await expect(
memoryDetails.locator("tr").filter({