mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 13:39:47 +00:00
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:
@@ -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
|
||||
|
||||
+119
-1
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user