diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index f846888eb..dc5e7131d 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 78dd21ad5..a16fc60de 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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" ] } diff --git a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx index 87cf49e5f..f1ac1fa67 100644 --- a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx @@ -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', () => ({ diff --git a/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts b/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts index 4d10738a2..39eaefcdf 100644 --- a/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts +++ b/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts @@ -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, diff --git a/frontend-modern/src/components/shared/historyChartModel.ts b/frontend-modern/src/components/shared/historyChartModel.ts index 66154aab6..19f873801 100644 --- a/frontend-modern/src/components/shared/historyChartModel.ts +++ b/frontend-modern/src/components/shared/historyChartModel.ts @@ -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); diff --git a/frontend-modern/src/components/shared/useHistoryChartState.ts b/frontend-modern/src/components/shared/useHistoryChartState.ts index c20b18e70..d27e6af29 100644 --- a/frontend-modern/src/components/shared/useHistoryChartState.ts +++ b/frontend-modern/src/components/shared/useHistoryChartState.ts @@ -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(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);