mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
Fix shared history chart axis labels
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
+79
-8
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user