Name where History readings came from instead of always "stored" (#2933)

A History reply carries its source: the history store, readings Pulse
holds in memory because the store has none for the range, or a lone
current reading when no history exists yet. The drawer readout ignored
it and always said "Latest stored", so memory and live readings were
presented as stored history. The canvas charts did badge the source, but
as "Buffer" and "Live", words a reader has no reason to know.

One shared vocabulary now covers both. Stored history (and demo data)
keeps "Latest stored" / "Stored". Memory readings read "Latest recorded"
/ "Recorded", with one line above the drawer charts saying the store has
none for this range yet, and a "Recent only" badge on canvas charts. A
lone current reading reads "Current", badged "Current only".


Stacked on #2932 (same chart files); it carries that commit until #2932
lands.
This commit is contained in:
rcourtman
2026-10-14 13:20:38 +01:00
committed by GitHub
10 changed files with 177 additions and 24 deletions
@@ -9215,6 +9215,15 @@ is dropped (`withoutRepeatedHistoryTimeTicks`). Phone chart folds share one
button, `PhoneChartsToggle` ("Show <names>" / "Show fewer charts",
`aria-controls` naming the folded group, hidden from `sm` up); the drawer
History and the disk detail both use it.
History names where its readings came from, from the reply's `source`, in
one shared vocabulary (`getHistoryReadoutLabel`, `getHistorySourceNotice`):
stored history (and demo data) reads "Latest stored · <time>" / "Stored ·";
readings Pulse holds in memory because the store has none for the range read
"Latest recorded" / "Recorded", with one line above the drawer charts ("Recent
readings only. The history store has none for this range yet.") and a "Recent
only" badge on a canvas chart (it said "Buffer"); a lone current reading
reads "Current" with "Current only" (it said "Live"). A drawer readout never
calls memory or live readings stored.
### Shared History single observations
@@ -4033,7 +4033,8 @@ handoff reads `document.activeElement` once, as the Retry branch is disposed.
Repeated gridline labels are blanked from the three labels each panel already
formats, and the canvas chart measures the time labels it already draws to
choose how many fit. None adds a read or timer. The disk detail's phone fold
removes reads rather than adding them: folded charts stay paused until opened.
removes reads rather than adding them: folded charts stay paused until opened. The readout and notice
naming a reply's source read the `source` field the reply already carries.
The default stored-history legend resolves one latest timestamp from that
same bounded, normalized panel index, then looks up each configured series at
@@ -213,7 +213,7 @@ const parent = false;
async () => {
assert.match(await description(), /42°C/);
assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 1);
assert.match(await disk.innerText(), /Buffer/i);
assert.match(await disk.innerText(), /Recent only/i);
assert(requests.some((r) => r.org === 'org-a' && r.resourceType === 'disk'));
},
);
@@ -243,7 +243,7 @@ const parent = false;
'Pending org-b removes org-a values, min/max, provenance and grouped inspection',
async () => {
assert.doesNotMatch(await description(), /42°C/);
assert.doesNotMatch(await disk.innerText(), /Buffer|42°C/i);
assert.doesNotMatch(await disk.innerText(), /Recent only|42°C/i);
assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 0);
assert.equal(await pool.locator('[data-history-chart-tooltip]').count(), 0);
assert.equal(await service.getByRole('slider').count(), 0);
@@ -257,7 +257,7 @@ const parent = false;
'Late pre-switch completion cannot resurrect readings or settle the new org',
async () => {
assert.doesNotMatch(await description(), /42°C|99°C/);
assert.doesNotMatch(await disk.innerText(), /Buffer|99°C/i);
assert.doesNotMatch(await disk.innerText(), /Recent only|99°C/i);
assert.equal(await service.getByRole('slider').count(), 0);
},
);
@@ -50,7 +50,8 @@ const { chromium } = require('playwright');
range,
start: 1_700_000_000_000,
end: 1_700_000_060_000,
source: 'store',
// The service target has no stored history yet: Pulse answers from memory.
source: id === 'pbs-three' ? 'memory' : 'store',
metrics: Object.fromEntries(
(id === 'pbs-three'
? ['cpu', 'memory']
@@ -229,6 +230,16 @@ const { chromium } = require('playwright');
await waitForPaths(2);
assert.equal(requests.at(-1).id, 'pbs-three');
assert.equal(requests.at(-1).range, '24h');
// Readings from memory are named as recorded, not stored, and say why.
assert.match(
await detail.getByTestId('guest-history-hover-time').first().innerText(),
/^Latest recorded · /,
);
assert.equal(
await detail.getByTestId('history-source-notice').innerText(),
'Recent readings only. The history store has none for this range yet.',
);
await screenshot('replacement-recorded');
assert.equal(
await detail.locator('[data-history-group="network"] path[data-history-series]').count(),
0,
@@ -0,0 +1,39 @@
{
"version": 1,
"base_sha": "8ae88cb6d1845d690d534084aaf374b55c91c5ed",
"verified_at": "2026-10-14T12:04:15Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx",
"frontend-modern/src/components/shared/HistoryChartHeader.tsx",
"frontend-modern/src/components/shared/historyChartModel.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "0ec0c06291cce526988749094bceafcb78a947f896968570b3001e3ed8eb0ab2",
"frontend-modern/src/components/shared/HistoryChartHeader.tsx": "46a353942d31d318125a0ee7bc41232cfecadb7c1bb16e6733ba5c04291df367",
"frontend-modern/src/components/shared/historyChartModel.ts": "a8b8894cfa937ce26299d72eae9d1cbebd17b977e0f8f2d12943cf7324d22a1e"
},
"routes": [
"/browser-tests/pbs-history-refresh.html (production GuestDrawerHistory renderer, Chromium via Playwright 1.56.1 on pulse-dev)",
"/browser-tests/history-org.html (production HistoryChart canvas with a memory-sourced reply)"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"drawer target answered from memory: readout \"Latest recorded \u00b7 <time>\", one line \"Recent readings only. The history store has none for this range yet.\" above the charts, light and dark",
"drawer target answered from the store: \"Latest stored\", no notice",
"canvas chart answered from memory: \"Recent only\" badge (was \"Buffer\"), withdrawn on an organisation switch"
],
"interactions": [
"drawer poll, failure, retry and target switch",
"organisation switch mid-read on the canvas chart"
]
}
@@ -23,6 +23,9 @@ import {
formatHistoryChartTooltipValue,
formatHistoryRangeOptionLabel,
formatHistoryChartTimeLabel,
getHistoryReadoutLabel,
getHistorySourceNotice,
type HistorySource,
withoutRepeatedHistoryAxisLabels,
} from '@/components/shared/historyChartModel';
import { isRangeLocked, loadRuntimeCapabilities, maxHistoryDays } from '@/stores/license';
@@ -82,6 +85,8 @@ interface GuestDrawerHistoryGroupChartProps {
loading: boolean;
metrics: Record<string, AggregatedMetricPoint[] | undefined>;
range: HistoryTimeRange;
/** Where the reply's readings came from; names the readout's time. */
source?: HistorySource;
sourceKey: string;
timeBounds: GuestDrawerHistoryTimeBounds | null;
touchInspection: boolean;
@@ -506,9 +511,10 @@ const GuestDrawerHistoryGroupChart: Component<GuestDrawerHistoryGroupChartProps>
data-testid="guest-history-hover-time"
dateTime={time().dateTime}
title={time().fullLabel}
aria-label={`Stored observation: ${time().fullLabel}`}
aria-label={`${getHistoryReadoutLabel(props.source, true)} observation: ${time().fullLabel}`}
>
{activeTimestamp() === null ? 'Latest stored · ' : 'Stored · '}
{getHistoryReadoutLabel(props.source, activeTimestamp() !== null)}
{' · '}
{time().label}
</time>
)}
@@ -986,6 +992,14 @@ export const GuestDrawerHistory: Component<GuestDrawerHistoryProps> = (props) =>
}
>
<Show when={!historyQuery.error() || hasHistoryPoints()}>
{/* A reply that is not stored history says so once, above its charts. */}
<Show when={hasHistoryPoints() && getHistorySourceNotice(historyQuery.value().source)}>
{(notice) => (
<p class="text-xs text-muted" data-testid="history-source-notice">
{notice().detail}
</p>
)}
</Show>
<div
id={gridId}
class={`grid gap-3 md:grid-cols-2 ${groups().length > 3 ? 'xl:grid-cols-4' : 'xl:grid-cols-3'}`}
@@ -1001,6 +1015,7 @@ export const GuestDrawerHistory: Component<GuestDrawerHistoryProps> = (props) =>
currentMetrics={props.currentMetrics}
deferredMetrics={props.deferredMetrics}
range={props.range}
source={historyQuery.value().source}
sourceKey={`${props.target?.resourceType}:${props.target?.resourceId}:${props.range}`}
timeBounds={timeBounds()}
touchInspection={touchInspection()}
@@ -71,6 +71,30 @@ const selectSharedTime = (chart: HTMLElement) => {
return chart.querySelector('[data-history-hover-marker]')!;
};
describe('GuestDrawerHistory reading source', () => {
const readout = () => within(group('utilization')).getByTestId('guest-history-hover-time');
it.each([
['store', 'Latest stored', 'Stored', null],
['memory', 'Latest recorded', 'Recorded', 'Recent readings only.'],
['live', 'Current', 'Current', 'No history for this range yet.'],
] as const)('names %s readings as such', async (source, latest, inspected, notice) => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response({ source }));
mount();
const slider = await screen.findByRole('slider', { name: 'Inspect Utilization history' });
expect(readout().textContent).toMatch(new RegExp(`^${latest} · `));
if (notice) expect(screen.getByTestId('history-source-notice')).toHaveTextContent(notice);
else expect(screen.queryByTestId('history-source-notice')).toBeNull();
slider.focus();
fireEvent.input(slider, { target: { value: '0' } });
expect(readout().textContent).toMatch(new RegExp(`^${inspected} · `));
expect(readout()).toHaveAttribute(
'aria-label',
expect.stringMatching(new RegExp(`^${inspected} observation: `)),
);
});
});
describe('GuestDrawerHistory value axis', () => {
it('labels a quiet link\'s rate axis once, not "<1K/s" on two gridlines', async () => {
vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
@@ -1,5 +1,5 @@
import { Component, Show } from 'solid-js';
import { formatHistoryChartTooltipValue } from './historyChartModel';
import { formatHistoryChartTooltipValue, getHistorySourceNotice } from './historyChartModel';
import type { HistoryChartState } from './useHistoryChartState';
interface HistoryChartHeaderProps {
@@ -20,21 +20,19 @@ export const HistoryChartHeader: Component<HistoryChartHeaderProps> = (props) =>
<Show when={props.unit}>
<span class="text-xs text-slate-400">({props.unit})</span>
</Show>
<Show when={props.chart.source() && props.chart.source() !== 'store'}>
<span
class={`text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide ${
props.chart.source() === 'live'
? 'bg-red-100 text-red-700 dark:bg-red-900/25 dark:text-red-300'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/25 dark:text-amber-300'
}`}
title={
props.chart.source() === 'live'
? 'Live sample shown because history is not available yet.'
: 'In-memory buffer shown while history is warming up.'
}
>
{props.chart.source() === 'live' ? 'Live' : 'Buffer'}
</span>
<Show when={getHistorySourceNotice(props.chart.source())}>
{(notice) => (
<span
class={`text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide ${
props.chart.source() === 'live'
? 'bg-red-100 text-red-700 dark:bg-red-900/25 dark:text-red-300'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/25 dark:text-amber-300'
}`}
title={notice().detail}
>
{notice().badge}
</span>
)}
</Show>
</div>
@@ -18,6 +18,8 @@ import {
getHistoryChartTimeTickCount,
getHistoryChartTooltipLayout,
getHistoryChartYAxisLabels,
getHistoryReadoutLabel,
getHistorySourceNotice,
hasHistoryChartSpread,
HISTORY_CHART_RANGES,
withoutRepeatedHistoryAxisLabels,
@@ -131,6 +133,22 @@ vi.mock('@/api/charts', () => ({
},
}));
describe('History source wording', () => {
it('names the readout and badge by where the readings came from', () => {
expect(getHistoryReadoutLabel('store', false)).toBe('Latest stored');
expect(getHistoryReadoutLabel('store', true)).toBe('Stored');
expect(getHistoryReadoutLabel('mock_synthetic', false)).toBe('Latest stored');
expect(getHistoryReadoutLabel(undefined, false)).toBe('Latest stored');
expect(getHistoryReadoutLabel('memory', false)).toBe('Latest recorded');
expect(getHistoryReadoutLabel('memory', true)).toBe('Recorded');
expect(getHistoryReadoutLabel('live', false)).toBe('Current');
expect(getHistorySourceNotice('store')).toBeNull();
expect(getHistorySourceNotice('mock_synthetic')).toBeNull();
expect(getHistorySourceNotice('memory')?.badge).toBe('Recent only');
expect(getHistorySourceNotice('live')?.badge).toBe('Current only');
});
});
describe('History chart min-max band', () => {
const at = (timestamp: number, value: number, min = value, max = value) => ({
timestamp,
@@ -845,11 +863,13 @@ describe('History organisation boundary rendering', () => {
touchPointer(canvas, 'pointerup');
}
expect(container.querySelector('[data-history-chart-tooltip]')).toHaveTextContent('42°C');
// Readings from memory say so in words a reader knows, not "Buffer".
expect(container).toHaveTextContent('Recent only');
eventBus.emit('org_switched', 'org-b');
expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull();
expect(container.querySelector('[aria-live="polite"]')?.textContent).toBe('');
expect(container).not.toHaveTextContent('42°C');
expect(container).not.toHaveTextContent('Buffer');
expect(container).not.toHaveTextContent('Recent only');
expect(canvas).toHaveAccessibleDescription(/Loading 1-hour history data/);
complete({
points: [{ timestamp: 1000, value: 80, min: 80, max: 80 }],
@@ -181,6 +181,42 @@ export function getHistoryChartDataMax(points: AggregatedMetricPoint[]) {
return max;
}
export type HistorySource = 'store' | 'memory' | 'live' | 'mock_synthetic';
/**
* Names where a History reply's readings came from. `store` is the history
* store; `memory` means the store had nothing for the range, so the readings
* are the recent ones Pulse holds in memory; `live` is one current reading
* because no history exists yet. Demo data reads like stored history.
*/
export function getHistoryReadoutLabel(
source: HistorySource | null | undefined,
inspected: boolean,
): string {
if (source === 'live') return 'Current';
if (source === 'memory') return inspected ? 'Recorded' : 'Latest recorded';
return inspected ? 'Stored' : 'Latest stored';
}
/** A short badge and its explanation for a reply that is not stored history. */
export function getHistorySourceNotice(
source: HistorySource | null | undefined,
): { badge: string; detail: string } | null {
if (source === 'memory') {
return {
badge: 'Recent only',
detail: 'Recent readings only. The history store has none for this range yet.',
};
}
if (source === 'live') {
return {
badge: 'Current only',
detail: 'No history for this range yet. Showing the current reading.',
};
}
return null;
}
/** True when any stored bucket's lowest and highest readings differ. */
export function hasHistoryChartSpread(points: readonly AggregatedMetricPoint[]): boolean {
return (