mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 21:47:20 +00:00
Merge commit '86a244bc04dbddf4ce8c648702d96338528077e3' into maintainer-async/20261014T122334Z-delivery-trust/pulse
Change-source: pulse-maintainer
This commit is contained in:
@@ -9302,6 +9302,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
|
||||
|
||||
|
||||
@@ -4078,7 +4078,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 (
|
||||
|
||||
Reference in New Issue
Block a user