Fix shared history chart axis labels

This commit is contained in:
Pulse Test
2026-08-28 09:22:00 +01:00
parent 79206d340a
commit 63acead96d
6 changed files with 182 additions and 64 deletions
@@ -119,6 +119,11 @@ populate the legend while history is still being collected, but it must never
be expanded into synthetic timestamps or chart geometry. An empty stored
series renders the shared collecting-history state; zero remains a valid
reported reading, while an absent metric remains unavailable.
Shared history-chart gridlines must carry numeric labels derived from the
plotted scale rather than semantic `Avg` / `Max` placeholders. Byte and
byte-rate axes include their human-readable unit at each gridline, and the
canvas measures both value and time labels into the same plot bounds used by
geometry and hover selection so neither edge clips or drifts from the data.
Object-detail navigation follows that same canonical split across platform and
feature owners. `Overview` is the stable landing tab for current operational
facts, while stored metric charts appear only after selecting an evidence-gated
+14 -25
View File
@@ -1,41 +1,30 @@
{
"version": 1,
"base_sha": "bf1b81700bc08a53cc36afcf4049e16e71d8b37c",
"verified_at": "2026-08-28T08:08:14Z",
"base_sha": "79206d340a655d376225779d73e0dae426804804",
"verified_at": "2026-08-28T08:27:50Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/shared/useDialogState.ts",
"frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx",
"frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx",
"frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx",
"frontend-modern/src/features/alerts/MobileAlertHistoryInvestigationDialog.tsx"
"frontend-modern/src/components/shared/historyChartModel.ts",
"frontend-modern/src/components/shared/useHistoryChartState.ts"
],
"content_sha256": {
"frontend-modern/src/components/shared/useDialogState.ts": "cf9fd26fe5fe4697351e8cb523b736c955b9d11ff5b7c6256355ab85051a2bd9",
"frontend-modern/src/features/alerts/AlertHistoryItemActions.tsx": "794c1bae95735fc6bda600932d9429f1b9964f8168154d95f831967b797a5084",
"frontend-modern/src/features/alerts/AlertHistoryMobileList.tsx": "7187798541822ae85897dfa03c2bfc9fdb0bc3c83ad8d97d347cef612baa5bda",
"frontend-modern/src/features/alerts/AlertResourceIncidentsPanel.tsx": "38225bfcf8f80a0f1fdb46edf96fcc706f45517c9a540964fef12c7a1ab577ea",
"frontend-modern/src/features/alerts/MobileAlertHistoryInvestigationDialog.tsx": "b9eaeb9e7bf30334c25ddccf9f8bc4d465dc138fe65d61270c575de79dbe9ce5"
"frontend-modern/src/components/shared/historyChartModel.ts": "d8742753465cb7d976ab118ef45e0a9e4065fa49789d5d5ec022a60a58df38f1",
"frontend-modern/src/components/shared/useHistoryChartState.ts": "42f076d388da20b95392188e6bb5246f069547ef5aecf8c74c1140cdc2bd74da"
},
"routes": ["/alerts/history"],
"routes": ["/proxmox/storage"],
"viewports": [
{ "width": 1440, "height": 900 },
{ "width": 390, "height": 844 }
],
"states": [
"desktop Alert History default table and contextual inline resource panel",
"narrow Alert History default and search-filtered virtualized card lists",
"narrow full-height Resource incidents drawer with empty, single-incident, and multiple-incident results",
"narrow resource event timeline collapsed and expanded with reactive event cards",
"narrow direct Incident timeline drawer with loaded incident, event filters, note field, and disabled save action",
"closed investigation with the history scroll position retained underneath"
"Proxmox Physical Disks filtered to nvme2 with Samsung 980 PRO 2TB expanded",
"disk History tab with live byte-rate, percent, and temperature charts loaded",
"read chart hovered with point marker and tooltip visible"
],
"interactions": [
"opened Resource investigation from cards near the top and deep inside virtualized mobile history",
"expanded resource Events timelines and scrolled the drawer evidence while confirming the background app scroll position stayed fixed",
"changed resource event disclosure state and confirmed event cards appeared without changing the virtualized history row height",
"opened the direct Incident timeline and dismissed mobile investigations through the explicit close action and Escape",
"confirmed explicit dismissal returns focus to the originating Resource or Timeline action without scrolling it into view",
"opened and closed the existing desktop inline Resource investigation and inspected the desktop table pixels"
"selected Physical Disks, searched for nvme2, expanded Samsung 980 PRO 2TB, and opened History",
"confirmed desktop byte-rate ticks show numeric MB/s scale values aligned with all three gridlines",
"confirmed 390px byte-rate labels and the final time tick remain fully visible without clipping while cards retain useful plot width",
"hovered the narrow Read chart and confirmed cursor, data point, and tooltip remain aligned after the measured axis inset"
]
}
@@ -37,6 +37,7 @@ HTMLCanvasElement.prototype.getContext = vi.fn(() => ({
createLinearGradient: vi.fn(() => ({
addColorStop: vi.fn(),
})),
measureText: vi.fn(() => ({ width: 40 })),
})) as unknown as typeof HTMLCanvasElement.prototype.getContext;
vi.mock('@/stores/license', () => ({
@@ -8,7 +8,9 @@ import {
getHistoryChartDataMax,
getHistoryChartDataMin,
getHistoryChartDefaultColor,
getHistoryChartLeftInset,
getHistoryChartRefreshIntervalMs,
getHistoryChartRightInset,
getHistoryChartScale,
getHistoryChartTooltipLayout,
getHistoryChartYAxisLabels,
@@ -207,18 +209,36 @@ describe('getHistoryChartYAxisLabels', () => {
]);
});
it('renders byte-like 0/avg/max labels', () => {
it('renders byte-rate scale values with units at every tick', () => {
expect(
getHistoryChartYAxisLabels(
{
minValue: 0,
maxValue: 2 * 1024 * 1024,
isPercentLike: false,
isByteLike: true,
},
'B/s',
),
).toStrictEqual([
{ pct: 0, label: '0 B/s' },
{ pct: 0.5, label: '1.00 MB/s' },
{ pct: 1, label: '2.00 MB/s' },
]);
});
it('uses the actual percent scale values when the scale exceeds 100', () => {
expect(
getHistoryChartYAxisLabels({
minValue: 0,
maxValue: 100,
isPercentLike: false,
isByteLike: true,
maxValue: 150,
isPercentLike: true,
isByteLike: false,
}),
).toStrictEqual([
{ pct: 0, label: '0' },
{ pct: 0.5, label: 'Avg' },
{ pct: 1, label: 'Max' },
{ pct: 0, label: '0%' },
{ pct: 0.5, label: '75%' },
{ pct: 1, label: '150%' },
]);
});
@@ -231,13 +251,33 @@ describe('getHistoryChartYAxisLabels', () => {
isByteLike: false,
}),
).toStrictEqual([
{ pct: 0, label: '0' },
{ pct: 0, label: '10' },
{ pct: 0.5, label: '60' },
{ pct: 1, label: '110' },
]);
});
});
describe('getHistoryChartLeftInset', () => {
it('keeps the existing minimum inset for short labels', () => {
expect(getHistoryChartLeftInset([8, 18, 24])).toBe(40);
});
it('expands the inset to fit the widest numeric label plus spacing', () => {
expect(getHistoryChartLeftInset([20.2, 49.1, 61.4])).toBe(70);
});
});
describe('getHistoryChartRightInset', () => {
it('reserves half the final time-label width plus breathing room', () => {
expect(getHistoryChartRightInset(37.2)).toBe(21);
});
it('does not return a negative inset', () => {
expect(getHistoryChartRightInset(-10)).toBe(0);
});
});
describe('formatHistoryChartTimeLabel', () => {
const ts = new Date(2024, 0, 15, 9, 30).getTime();
@@ -308,6 +348,37 @@ describe('createHistoryChartGeometry', () => {
expect(geo.getX(1100)).toBe(200);
});
it('uses a measured left inset for wider y-axis labels', () => {
const geo = createHistoryChartGeometry({
width: 200,
height: 100,
startTime: 1000,
endTime: 1100,
minValue: 0,
maxValue: 10,
leftInset: 70,
});
expect(geo.getX(1000)).toBe(70);
expect(geo.getX(1100)).toBe(200);
});
it('keeps the final time label inside a measured right inset', () => {
const geo = createHistoryChartGeometry({
width: 200,
height: 100,
startTime: 1000,
endTime: 1100,
minValue: 0,
maxValue: 10,
leftInset: 70,
rightInset: 22,
});
expect(geo.getX(1000)).toBe(70);
expect(geo.getX(1100)).toBe(178);
});
it('inverts the value axis so the max sits at the top padding', () => {
const geo = createHistoryChartGeometry({
width: 200,
@@ -42,6 +42,8 @@ export const HISTORY_CHART_RANGES: HistoryTimeRange[] = [
'90d',
];
export const HISTORY_CHART_MIN_LEFT_INSET = 40;
export function formatHistoryChartTooltipValue(value: number, unit?: string): string {
if (unit === '%') return `${value.toFixed(1)}%`;
if (unit === 'B/s') return `${formatBytes(value)}/s`;
@@ -110,31 +112,43 @@ export function getHistoryChartScale(points: AggregatedMetricPoint[], unit?: str
};
}
export function getHistoryChartYAxisLabels({
minValue,
maxValue,
isPercentLike,
isByteLike,
}: {
minValue: number;
maxValue: number;
isPercentLike: boolean;
isByteLike: boolean;
}) {
export function getHistoryChartYAxisLabels(
{
minValue,
maxValue,
isPercentLike,
isByteLike,
}: {
minValue: number;
maxValue: number;
isPercentLike: boolean;
isByteLike: boolean;
},
unit?: string,
) {
return [0, 0.5, 1].map((pct) => {
let label = '';
const scaleValue = minValue + pct * (maxValue - minValue);
let label: string;
if (isPercentLike) {
label = pct === 0 ? '0%' : pct === 1 ? '100%' : '50%';
label = `${Math.round(scaleValue)}%`;
} else if (isByteLike) {
label = pct === 0 ? '0' : pct === 1 ? 'Max' : 'Avg';
label = formatHistoryChartTooltipValue(scaleValue, unit);
} else {
const scaleValue = Math.round(minValue + pct * (maxValue - minValue));
label = pct === 0 ? '0' : `${scaleValue}`;
label = `${Math.round(scaleValue)}`;
}
return { pct, label };
});
}
export function getHistoryChartLeftInset(labelWidths: number[]) {
const widestLabel = Math.max(0, ...labelWidths);
return Math.max(HISTORY_CHART_MIN_LEFT_INSET, Math.ceil(widestLabel) + 8);
}
export function getHistoryChartRightInset(lastTimeLabelWidth: number) {
return Math.max(0, Math.ceil(lastTimeLabelWidth / 2) + 2);
}
export function formatHistoryChartTimeLabel(timestamp: number, range: HistoryTimeRange) {
const date = new Date(timestamp);
if (range === '30d' || range === '90d' || range === '14d' || range === '7d') {
@@ -150,6 +164,8 @@ export function createHistoryChartGeometry({
endTime,
minValue,
maxValue,
leftInset = HISTORY_CHART_MIN_LEFT_INSET,
rightInset = 0,
}: {
width: number;
height: number;
@@ -157,9 +173,12 @@ export function createHistoryChartGeometry({
endTime: number;
minValue: number;
maxValue: number;
leftInset?: number;
rightInset?: number;
}) {
const timeSpan = Math.max(1, endTime - startTime);
const getX = (timestamp: number) => 40 + ((timestamp - startTime) / timeSpan) * (width - 40);
const getX = (timestamp: number) =>
leftInset + ((timestamp - startTime) / timeSpan) * (width - leftInset - rightInset);
const getY = (value: number) =>
height - 20 - ((value - minValue) / (maxValue - minValue)) * (height - 40);
@@ -5,13 +5,16 @@ import { calculateOptimalPoints } from '@/utils/downsample';
import { setupCanvasDPR } from '@/utils/canvasRenderQueue';
import {
HISTORY_CHART_RANGES,
HISTORY_CHART_MIN_LEFT_INSET,
createHistoryChartGeometry,
findHistoryChartClosestPoint,
formatHistoryChartTimeLabel,
getHistoryChartDataMax,
getHistoryChartDataMin,
getHistoryChartDefaultColor,
getHistoryChartLeftInset,
getHistoryChartRefreshIntervalMs,
getHistoryChartRightInset,
getHistoryChartScale,
getHistoryChartYAxisLabels,
type HistoryChartProps,
@@ -38,6 +41,8 @@ export function useHistoryChartState(props: HistoryChartProps, refs: HistoryChar
const [hoveredPoint, setHoveredPoint] = createSignal<HistoryChartHoverPoint | null>(null);
const [chartWidth, setChartWidth] = createSignal(300);
const chartHeight = createMemo(() => props.height || 200);
let chartLeftInset = HISTORY_CHART_MIN_LEFT_INSET;
let chartRightInset = 0;
const refreshIntervalMs = createMemo(() => getHistoryChartRefreshIntervalMs(range()));
@@ -185,21 +190,42 @@ export function useHistoryChartState(props: HistoryChartProps, refs: HistoryChar
const axisTextColor = isDark ? '#9ca3af' : '#6b7280';
const mainColor = getHistoryChartDefaultColor(props.metric, props.color);
const scale = getHistoryChartScale(points, props.unit);
const yAxisTicks = getHistoryChartYAxisLabels(scale, props.unit);
const labelCount = 4;
const timeAxisTicks =
points.length > 0
? Array.from({ length: labelCount }, (_, index) => {
const timestamp =
points[0].timestamp +
((points[points.length - 1].timestamp - points[0].timestamp) * index) /
(labelCount - 1);
return { timestamp, label: formatHistoryChartTimeLabel(timestamp, range()) };
})
: [];
ctx.font = '10px sans-serif';
chartLeftInset = getHistoryChartLeftInset(
yAxisTicks.map((tick) => ctx.measureText(tick.label).width),
);
chartRightInset = getHistoryChartRightInset(
timeAxisTicks.length > 0
? ctx.measureText(timeAxisTicks[timeAxisTicks.length - 1].label).width
: 0,
);
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
for (const tick of getHistoryChartYAxisLabels(scale)) {
for (const tick of yAxisTicks) {
const y = height - 20 - tick.pct * (height - 40);
ctx.beginPath();
ctx.moveTo(40, y);
ctx.lineTo(width, y);
ctx.moveTo(chartLeftInset, y);
ctx.lineTo(width - chartRightInset, y);
ctx.stroke();
ctx.fillStyle = textColor;
ctx.font = '10px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(tick.label, 35, y);
ctx.fillText(tick.label, chartLeftInset - 5, y);
}
if (points.length === 0) {
@@ -213,6 +239,8 @@ export function useHistoryChartState(props: HistoryChartProps, refs: HistoryChar
endTime: points[points.length - 1].timestamp,
minValue: scale.minValue,
maxValue: scale.maxValue,
leftInset: chartLeftInset,
rightInset: chartRightInset,
});
ctx.beginPath();
@@ -241,15 +269,18 @@ export function useHistoryChartState(props: HistoryChartProps, refs: HistoryChar
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
const labelCount = 4;
for (let index = 0; index < labelCount; index++) {
const timestamp = points[0].timestamp + (geometry.timeSpan * index) / (labelCount - 1);
const x = geometry.getX(timestamp);
ctx.fillText(formatHistoryChartTimeLabel(timestamp, range()), x, height - 2);
for (const tick of timeAxisTicks) {
ctx.fillText(tick.label, geometry.getX(tick.timestamp), height - 2);
}
const cursor = cursorX();
if (cursor === null || cursor < 40 || points.length === 0) return;
if (
cursor === null ||
cursor < chartLeftInset ||
cursor > width - chartRightInset ||
points.length === 0
)
return;
ctx.save();
ctx.strokeStyle = isDark ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.3)';
@@ -261,7 +292,7 @@ export function useHistoryChartState(props: HistoryChartProps, refs: HistoryChar
ctx.stroke();
ctx.restore();
const ratio = (cursor - 40) / (width - 40);
const ratio = (cursor - chartLeftInset) / (width - chartLeftInset - chartRightInset);
const hoverTimestamp = points[0].timestamp + ratio * geometry.timeSpan;
const closest = findHistoryChartClosestPoint(points, hoverTimestamp);
const pointX = geometry.getX(closest.timestamp);
@@ -326,16 +357,18 @@ export function useHistoryChartState(props: HistoryChartProps, refs: HistoryChar
endTime: points[points.length - 1].timestamp,
minValue: getHistoryChartScale(points, props.unit).minValue,
maxValue: getHistoryChartScale(points, props.unit).maxValue,
leftInset: chartLeftInset,
rightInset: chartRightInset,
});
if (x < 40) {
if (x < chartLeftInset || x > width - chartRightInset) {
setCursorX(null);
setHoveredPoint(null);
return;
}
setCursorX(x);
const ratio = (x - 40) / (width - 40);
const ratio = (x - chartLeftInset) / (width - chartLeftInset - chartRightInset);
const hoverTimestamp = points[0].timestamp + ratio * geometry.timeSpan;
const closest = findHistoryChartClosestPoint(points, hoverTimestamp);
const pointX = geometry.getX(closest.timestamp);