diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 8df6300caa..2523038a27 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -2536,6 +2536,22 @@ resize-observer lifecycle lives in `frontend-modern/src/components/Workloads/useMetricBarState.ts`. Future metric-bar runtime changes must extend through those owners instead of reintroducing mixed resize state and label-fit logic into the shell. +Label fit in the metric, stacked memory, and stacked disk bar models goes +through `estimateTextWidth` in `frontend-modern/src/utils/format.ts`, which +sums per-glyph advances rather than multiplying a character count. The +advances come from one hidden probe of about 200 nodes carrying the label's +type classes, read once per page load and again only if the device pixel +ratio changes; that is the estimator's only layout read. It is never per bar, +the bars still take their width from the resize observer alone, and each +estimate after the probe is arithmetic over the label's characters. Each model +adds the padding its own label sits in, the stacked bars count the anomaly +marker that shares the label's line, and inline disk slots are sized after the +gap between them. Measured in Chrome on the 429-bar Proxmox overview on +2026-10-03: 1.7 ms for the probe and about 110 ns per estimate. Where layout +is unavailable the estimator uses the advances measured for the macOS system +font. `formatExtra.test.ts` verifies the single probe read and the fallback; +`useMetricBarState.test.tsx` and `useStackedMemoryBarState.test.tsx` verify +that the long label appears only at an observed width that fits it. The dashboard enhanced CPU bar now follows that same pattern: the shell stays in `frontend-modern/src/components/Workloads/EnhancedCPUBar.tsx`, while usage formatting, anomaly presentation, tooltip load-average formatting, and diff --git a/frontend-modern/src/components/Workloads/__tests__/metricBarModel.branchcov0720pm.test.ts b/frontend-modern/src/components/Workloads/__tests__/metricBarModel.branchcov0720pm.test.ts index a3af452ccc..9db7b42aae 100644 --- a/frontend-modern/src/components/Workloads/__tests__/metricBarModel.branchcov0720pm.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/metricBarModel.branchcov0720pm.test.ts @@ -2,16 +2,20 @@ import { describe, expect, it } from 'vitest'; import { estimateTextWidth } from '@/utils/format'; +import metricBarSource from '../MetricBar.tsx?raw'; import type { MetricBarProps } from '../metricBarModel'; import { buildMetricBarPresentation } from '../metricBarModel'; -// estimateTextWidth(text) = text.length * 5.5 + 8 (mirrored from @/utils/format). -// For label='CPU' + sublabel='8c' the composed threshold text is 'CPU (8c)' -// (length 8) -> estimateTextWidth = 8 * 5.5 + 8 = 52. So a containerWidth of 52 -// trips the >= true arm of the showSublabel check, and 51 trips the false arm. +// The long form needs its text plus the 4px of padding the label span +// carries. For label='CPU' + sublabel='8c' the text is 'CPU' semibold (21.83) +// and ' (8c)' at normal weight (23.04): 44.87, rounded up to 45, plus 4 = 49. +// So a containerWidth of 49 trips the >= true arm of the showSublabel check, +// and 48 trips the false arm. const LABEL = 'CPU'; const SUBLABEL = '8c'; -const SUBLABEL_THRESHOLD = estimateTextWidth(`${LABEL} (${SUBLABEL})`); // 52 +const LABEL_PADDING_PX = 4; +const SUBLABEL_THRESHOLD = + estimateTextWidth(LABEL, { detail: ` (${SUBLABEL})` }) + LABEL_PADDING_PX; // 49 // Tailwind background class mirrored from metricThresholds.BG_CLASSES.normal. // Default cpu thresholds are warning 80 / critical 90 (METRIC_THRESHOLDS.cpu), @@ -65,23 +69,47 @@ describe('buildMetricBarPresentation (branch coverage 0720pm)', () => { }); }); - describe('showSublabel threshold branch (containerWidth >= estimateTextWidth(...))', () => { + describe('showSublabel threshold branch (containerWidth >= estimateTextWidth(...) + padding)', () => { it('returns showSublabel=true when containerWidth meets the threshold exactly (>= true arm)', () => { - // estimateTextWidth('CPU (8c)') === 52; containerWidth 52 -> 52 >= 52 true. - expect(SUBLABEL_THRESHOLD).toBe(52); + // The threshold is 49; containerWidth 49 -> 49 >= 49 true. + expect(SUBLABEL_THRESHOLD).toBe(49); const p = buildMetricBarPresentation(makeProps(), SUBLABEL_THRESHOLD); expect(p.showLabel).toBe(true); expect(p.showSublabel).toBe(true); }); it('returns showSublabel=false when containerWidth is one below the threshold (false arm)', () => { - // containerWidth 51 < 52 -> the >= arm is false -> showSublabel false. + // containerWidth 48 < 49 -> the >= arm is false -> showSublabel false. // showLabel itself is unaffected by the width check (still true here). const p = buildMetricBarPresentation(makeProps(), SUBLABEL_THRESHOLD - 1); expect(p.showLabel).toBe(true); expect(p.showSublabel).toBe(false); }); + it('keeps a 113px bar on the short label for "60% (265 GB/440 GB)"', () => { + // Measured in the browser on 2026-10-03: that label is 110.1px of text + // at text-[10px], so it needs 114.1px here and 118.1px in the memory + // bar's wider chip. The old 5.5px-per-character estimate put it at + // 112.5px, showed the long form in a 113px bar and clipped it. + const props = makeProps({ value: 60, label: '60%', sublabel: '265 GB/440 GB' }); + expect(buildMetricBarPresentation(props, 109).showSublabel).toBe(false); + expect(buildMetricBarPresentation(props, 113).showSublabel).toBe(false); + expect(buildMetricBarPresentation(props, 114).showSublabel).toBe(false); + expect(buildMetricBarPresentation(props, 115).showSublabel).toBe(true); + }); + + it('fits a dotted label that the per-character estimate turned away', () => { + // '71% (5.65/8.00TB)' renders 93.5px wide: full stops and the slash are + // half a digit. It fits a 100px bar; 17 characters at 5.5px did not. + const props = makeProps({ value: 71, label: '71%', sublabel: '5.65/8.00TB' }); + expect(buildMetricBarPresentation(props, 100).showSublabel).toBe(true); + expect(buildMetricBarPresentation(props, 97).showSublabel).toBe(false); + }); + + it('counts the padding the label span carries in MetricBar', () => { + expect(metricBarSource).toContain('text-ellipsis px-0.5 text-center'); + }); + it('returns showSublabel=false when sublabel is empty (Boolean(props.sublabel) false arm)', () => { // Even with a very wide container, Boolean('') is false -> showSublabel false. const p = buildMetricBarPresentation(makeProps({ sublabel: '' }), 1000); diff --git a/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts b/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts index 054068a04c..4e522ee8ba 100644 --- a/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import type { AnomalyReport } from '@/types/aiIntelligence'; import type { Disk } from '@/types/api'; +import stackedDiskBarSource from '../StackedDiskBar.tsx?raw'; import { buildStackedDiskBarPresentation } from '../stackedDiskBarModel'; // The four small helpers under test (getDiskUsagePercent, getShortDiskLabel, @@ -117,7 +118,8 @@ describe('stackedDiskBarModel (branch coverage 2)', () => { }); describe('getInlineDiskText (via miniDisks[].inlineText)', () => { - // estimateInlineTextWidth(text) = text.length * 5.4 + 4 (module-internal). + // estimateInlineTextWidth(text) is the label's width at text-[9px] + // semibold, rounded up, plus the slot's 2px of padding (module-internal). it('returns the full text when slotWidth <= 0 (short-circuit)', () => { // slotWidth = containerWidth(0) / 1 = 0 -> first operand of the || . const p = buildStackedDiskBarPresentation( @@ -130,7 +132,7 @@ describe('stackedDiskBarModel (branch coverage 2)', () => { }); it('returns the full text when it fits within the slot', () => { - // 'sda1 50%' width = 8 * 5.4 + 4 = 47.2 <= slotWidth(60). + // 'sda1 50%' width = 46 + 2 = 48 <= slotWidth(60). const p = buildStackedDiskBarPresentation( { disks: [makeDisk({ mountpoint: '/dev/sda1', total: 100, used: 50, free: 50, usage: 50 })], @@ -141,8 +143,8 @@ describe('stackedDiskBarModel (branch coverage 2)', () => { }); it('falls back to the percent label when only it fits', () => { - // shortLabel 'a', percentLabel '100%'. full 'a 100%' width 36.4 > 30; - // '100%' width 25.6 <= 30 -> returns '100%'. + // shortLabel 'a', percentLabel '100%'. full 'a 100%' width 38 > 30; + // '100%' width 30 <= 30 -> returns '100%'. const p = buildStackedDiskBarPresentation( { disks: [makeDisk({ mountpoint: '/dev/a', total: 100, used: 100, free: 0, usage: 100 })] }, 30, @@ -151,7 +153,7 @@ describe('stackedDiskBarModel (branch coverage 2)', () => { }); it('falls back to the short label when the percent label does not fit but the short label does', () => { - // slotWidth 20: full 36.4 > 20; percent '100%' 25.6 > 20; short 'a' 9.4 <= 20 -> 'a'. + // slotWidth 20: full 38 > 20; percent '100%' 30 > 20; short 'a' 8 <= 20 -> 'a'. const p = buildStackedDiskBarPresentation( { disks: [makeDisk({ mountpoint: '/dev/a', total: 100, used: 100, free: 0, usage: 100 })] }, 20, @@ -167,6 +169,26 @@ describe('stackedDiskBarModel (branch coverage 2)', () => { ); expect(p.miniDisks[0].inlineText).toBe(''); }); + + it('sizes each slot after the gap between slots', () => { + // Two disks in a 117.58px bar (Proxmox nodes at a 900px viewport) get + // 57.79px each once the 2px gap is out. 'backup 80%' needs 58 + 2, so + // the slot keeps the percentage; splitting the bar in half had let the + // full text in and clipped it. + const disks = [ + makeDisk({ mountpoint: '/backup', total: 100, used: 80, free: 20, usage: 80 }), + makeDisk({ mountpoint: '/', total: 100, used: 50, free: 50, usage: 50 }), + ]; + const narrow = buildStackedDiskBarPresentation({ disks }, 117.58); + expect(narrow.miniDisks.map((disk) => disk.inlineText)).toEqual(['80%', '/ 50%']); + const wide = buildStackedDiskBarPresentation({ disks }, 122); + expect(wide.miniDisks[0].inlineText).toBe('backup 80%'); + }); + + it('counts the padding and gap the inline slots carry in StackedDiskBar', () => { + expect(stackedDiskBarSource).toContain('flex h-full items-stretch gap-0.5'); + expect(stackedDiskBarSource).toContain('overflow-hidden px-px text-center text-[9px]'); + }); }); describe('getStackedDiskColor (via segments[].color, mode=stacked)', () => { @@ -463,6 +485,47 @@ describe('stackedDiskBarModel (branch coverage 2)', () => { expect(unknown.anomalyRatio).toBe(''); }); + it('shows the sublabel only where the whole label fits', () => { + // '60% (265 GB/440 GB)' is 110.1px of text in the browser and sits in + // 4px of padding: a 113px bar keeps the percentage, a 115px bar fits it. + const GiB = 1024 ** 3; + const disks = [ + makeDisk({ + mountpoint: '/', + total: 440 * GiB, + used: 265 * GiB, + free: 175 * GiB, + usage: 60, + }), + ]; + const narrow = buildStackedDiskBarPresentation({ disks }, 113); + expect(narrow.displayLabel).toBe('60%'); + expect(narrow.displaySublabel).toBe('265 GB/440 GB'); + expect(narrow.showSublabel).toBe(false); + expect(buildStackedDiskBarPresentation({ disks }, 114).showSublabel).toBe(false); + expect(buildStackedDiskBarPresentation({ disks }, 115).showSublabel).toBe(true); + expect(stackedDiskBarSource).toContain('text-ellipsis px-0.5 text-center'); + }); + + it('leaves room for the anomaly marker that shares the line', () => { + // ' 2.5x' adds 25.35px, taking the threshold from 115 to 140. + const GiB = 1024 ** 3; + const props = { + disks: [ + makeDisk({ + mountpoint: '/', + total: 440 * GiB, + used: 265 * GiB, + free: 175 * GiB, + usage: 60, + }), + ], + anomaly: makeAnomaly({ baseline_mean: 10, current_value: 25 }), + }; + expect(buildStackedDiskBarPresentation(props, 139).showSublabel).toBe(false); + expect(buildStackedDiskBarPresentation(props, 140).showSublabel).toBe(true); + }); + it('suppresses the sublabel when containerWidth is too narrow to fit it', () => { // displaySublabel non-empty but containerWidth < estimateTextWidth(...) -> // showSublabel false (the width-failing arm of the && ). diff --git a/frontend-modern/src/components/Workloads/__tests__/stackedMemoryBarModel.branchcov0724pm.test.ts b/frontend-modern/src/components/Workloads/__tests__/stackedMemoryBarModel.branchcov0724pm.test.ts index 25c8480cd8..e88e3eb1ff 100644 --- a/frontend-modern/src/components/Workloads/__tests__/stackedMemoryBarModel.branchcov0724pm.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/stackedMemoryBarModel.branchcov0724pm.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { AnomalyReport } from '@/types/aiIntelligence'; +import stackedMemoryBarSource from '../StackedMemoryBar.tsx?raw'; import type { StackedMemoryBarProps } from '../stackedMemoryBarModel'; import { buildStackedMemoryBarPresentation } from '../stackedMemoryBarModel'; @@ -194,4 +195,43 @@ describe('stackedMemoryBarModel (branch coverage 0724pm)', () => { expect(p.anomalyDescription).toBe('Memory spike'); }); }); + + describe('showSublabel fit (label text plus the chip padding)', () => { + // 265 of 440 GiB reads '60% (265 GB/440 GB)': 110.1px of text in the + // browser, 118.1px with the chip's 8px of padding. This is the label the + // Kubernetes clusters table clipped in a 113px bar on 2026-10-03. + const clusterMemory = makeProps({ used: 265 * GiB, total: 440 * GiB }); + + it('keeps a 113px bar on the short label for "60% (265 GB/440 GB)"', () => { + const p = buildStackedMemoryBarPresentation(clusterMemory, 113); + expect(p.displayLabel).toBe('60%'); + expect(p.displaySublabel).toBe('265 GB/440 GB'); + expect(p.showSublabel).toBe(false); + expect(buildStackedMemoryBarPresentation(clusterMemory, 118).showSublabel).toBe(false); + }); + + it('shows the long label once the bar is wide enough for it', () => { + expect(buildStackedMemoryBarPresentation(clusterMemory, 119).showSublabel).toBe(true); + }); + + it('leaves room for the anomaly marker that shares the line', () => { + // ' 3.0x' adds 25.44px, taking the threshold from 119 to 145. + const withMarker = makeProps({ ...clusterMemory, anomaly: makeAnomaly() }); + expect(buildStackedMemoryBarPresentation(withMarker, 144).showSublabel).toBe(false); + expect(buildStackedMemoryBarPresentation(withMarker, 145).showSublabel).toBe(true); + }); + + it('ignores an anomaly that renders no marker', () => { + // The marker needs both a description and a ratio. + const noDescription = makeProps({ + ...clusterMemory, + anomaly: makeAnomaly({ description: '' }), + }); + expect(buildStackedMemoryBarPresentation(noDescription, 119).showSublabel).toBe(true); + }); + + it('counts the padding the label chip carries in StackedMemoryBar', () => { + expect(stackedMemoryBarSource).toContain('px-1 text-center'); + }); + }); }); diff --git a/frontend-modern/src/components/Workloads/__tests__/useMetricBarState.test.tsx b/frontend-modern/src/components/Workloads/__tests__/useMetricBarState.test.tsx index 4006b7cc27..69c01b4f53 100644 --- a/frontend-modern/src/components/Workloads/__tests__/useMetricBarState.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/useMetricBarState.test.tsx @@ -65,4 +65,38 @@ describe('useMetricBarState', () => { unmount(); expect(observers[0].disconnect).toHaveBeenCalled(); }); + + it('shows the long label only at an observed width that fits it', () => { + const observers: MockResizeObserver[] = []; + globalThis.ResizeObserver = class extends MockResizeObserver { + constructor(callback: ResizeObserverCallback) { + super(callback); + observers.push(this); + } + } as unknown as typeof ResizeObserver; + + let captured: ReturnType | undefined; + + const Harness = () => { + captured = useMetricBarState({ + value: 60, + label: '60%', + sublabel: '265 GB/440 GB', + type: 'memory', + }); + return
; + }; + + render(() => ); + + // '60% (265 GB/440 GB)' renders 110.1px of text plus 4px of padding. The + // width arrives from the resize observer alone; the fit never reads the + // bar's own layout. + observers[0].trigger(113); + expect(captured!.presentation().showSublabel).toBe(false); + observers[0].trigger(115); + expect(captured!.presentation().showSublabel).toBe(true); + observers[0].trigger(109); + expect(captured!.presentation().showSublabel).toBe(false); + }); }); diff --git a/frontend-modern/src/components/Workloads/__tests__/useStackedMemoryBarState.test.tsx b/frontend-modern/src/components/Workloads/__tests__/useStackedMemoryBarState.test.tsx index 2dddbc9f42..4dceac1f31 100644 --- a/frontend-modern/src/components/Workloads/__tests__/useStackedMemoryBarState.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/useStackedMemoryBarState.test.tsx @@ -67,4 +67,34 @@ describe('useStackedMemoryBarState', () => { unmount(); expect(observers[0].disconnect).toHaveBeenCalled(); }); + + it('shows the long label only at an observed width that fits it', () => { + const observers: MockResizeObserver[] = []; + globalThis.ResizeObserver = class extends MockResizeObserver { + constructor(callback: ResizeObserverCallback) { + super(callback); + observers.push(this); + } + } as unknown as typeof ResizeObserver; + + let captured: ReturnType | undefined; + + const Harness = () => { + captured = useStackedMemoryBarState({ + used: 265 * 1024 ** 3, + total: 440 * 1024 ** 3, + }); + return
; + }; + + render(() => ); + + // '60% (265 GB/440 GB)' renders 110.1px of text plus the chip's 8px of + // padding: the 113px Kubernetes clusters bar keeps the percentage. + observers[0].trigger(113); + expect(captured!.presentation().displaySublabel).toBe('265 GB/440 GB'); + expect(captured!.presentation().showSublabel).toBe(false); + observers[0].trigger(119); + expect(captured!.presentation().showSublabel).toBe(true); + }); }); diff --git a/frontend-modern/src/components/Workloads/metricBarModel.ts b/frontend-modern/src/components/Workloads/metricBarModel.ts index 86cefc8e69..7e867e01bf 100644 --- a/frontend-modern/src/components/Workloads/metricBarModel.ts +++ b/frontend-modern/src/components/Workloads/metricBarModel.ts @@ -22,6 +22,9 @@ export interface MetricBarPresentation { width: number; } +// Horizontal padding around the label inside the bar (px-0.5 in MetricBar.tsx). +const LABEL_PADDING_PX = 4; + export function buildMetricBarPresentation( props: MetricBarProps, containerWidth: number, @@ -31,7 +34,8 @@ export function buildMetricBarPresentation( const showSublabel = showLabel && Boolean(props.sublabel) && - containerWidth >= estimateTextWidth(`${props.label} (${props.sublabel})`); + containerWidth >= + estimateTextWidth(props.label, { detail: ` (${props.sublabel})` }) + LABEL_PADDING_PX; const metric = props.type || 'cpu'; const metricType: MetricType = metric === 'generic' ? 'cpu' : metric; diff --git a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts index 7b2adc3f65..0b6c8f35f5 100644 --- a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts +++ b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts @@ -144,8 +144,17 @@ function getShortDiskLabel(label: string): string { return trimmed; } +// Horizontal padding around the aggregate label inside the bar (px-0.5 in +// StackedDiskBar.tsx). +const LABEL_PADDING_PX = 4; +// Inline per-disk labels are text-[9px] font-semibold, in px-px slots that a +// gap-0.5 separates. +const INLINE_DISK_FONT_PX = 9; +const INLINE_DISK_SLOT_PADDING_PX = 2; +const INLINE_DISK_SLOT_GAP_PX = 2; + function estimateInlineTextWidth(text: string): number { - return text.length * 5.4 + 4; + return estimateTextWidth(text, { fontPx: INLINE_DISK_FONT_PX }) + INLINE_DISK_SLOT_PADDING_PX; } function getInlineDiskText(shortLabel: string, percentLabel: string, slotWidth: number): string { @@ -269,7 +278,10 @@ export function buildStackedDiskBarPresentation( const inlineDiskMode = (miniMode || hasMultipleDisks) && !aggregateMode && !explicitStackedMode && !verticalBarsMode; const useStackedSegments = hasMultipleDisks && explicitStackedMode; - const inlineDiskSlotWidth = disks.length > 0 ? containerWidth / disks.length : 0; + const inlineDiskSlotWidth = + disks.length > 0 + ? (containerWidth - INLINE_DISK_SLOT_GAP_PX * (disks.length - 1)) / disks.length + : 0; const totalCapacity = hasDisks ? disks.reduce((sum, disk) => sum + (disk.total || 0), 0) : (props.aggregateDisk?.total ?? 0); @@ -307,15 +319,19 @@ export function buildStackedDiskBarPresentation( (aggregateMode && hasMultipleDisks && maxLabelShort.length > 0 && - containerWidth >= estimateTextWidth(`${displayLabel} ${maxLabelShort}`)); + containerWidth >= + estimateTextWidth(displayLabel, { detail: ` ${maxLabelShort}` }) + LABEL_PADDING_PX); + // The anomaly marker shares the label's line whenever it renders. + const anomalyMarker = props.anomaly?.description && anomalyRatio ? ` ${anomalyRatio}` : ''; const showSublabel = displaySublabel.length > 0 && containerWidth >= - estimateTextWidth( - `${displayLabel}${showMaxLabel ? ` ${maxLabelShort}` : ''} (${displaySublabel})${ + estimateTextWidth(`${displayLabel}${anomalyMarker}`, { + detail: `${showMaxLabel ? ` ${maxLabelShort}` : ''} (${displaySublabel})${ showDiskCount ? ` ${diskCountLabel}` : '' }`, - ); + }) + + LABEL_PADDING_PX; const barColor = aggregateMode && hasMultipleDisks && maxInfo ? getMetricColorRgba(maxInfo.percent, 'disk', props.thresholds) diff --git a/frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts b/frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts index e37b93b9f6..a5a02eb298 100644 --- a/frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts +++ b/frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts @@ -271,6 +271,9 @@ function getTooltipRows( return rows; } +// Horizontal padding of the label chip inside the bar (px-1 in StackedMemoryBar.tsx). +const LABEL_PADDING_PX = 8; + export function buildStackedMemoryBarPresentation( props: StackedMemoryBarProps, containerWidth: number, @@ -281,16 +284,21 @@ export function buildStackedMemoryBarPresentation( !props.unavailable && props.total > 0 ? `${formatBytes(props.used)}/${formatBytes(props.total)}` : ''; + const anomalyRatio = formatAnomalyRatio(props.anomaly) ?? ''; + // The anomaly marker shares the label's line whenever it renders. + const anomalyMarker = props.anomaly?.description && anomalyRatio ? ` ${anomalyRatio}` : ''; const showSublabel = displaySublabel.length > 0 && - containerWidth >= estimateTextWidth(`${displayLabel} (${displaySublabel})`); + containerWidth >= + estimateTextWidth(`${displayLabel}${anomalyMarker}`, { detail: ` (${displaySublabel})` }) + + LABEL_PADDING_PX; return { anomalyClass: props.anomaly ? (ANOMALY_SEVERITY_CLASS[props.anomaly.severity] ?? 'text-yellow-400') : 'text-yellow-400', anomalyDescription: props.anomaly?.description, - anomalyRatio: formatAnomalyRatio(props.anomaly) ?? '', + anomalyRatio, displayLabel, displayPercentValue: utilizationPercent, displaySublabel, diff --git a/frontend-modern/src/utils/__tests__/format.coverage.test.ts b/frontend-modern/src/utils/__tests__/format.coverage.test.ts index c96512214a..440ea82325 100644 --- a/frontend-modern/src/utils/__tests__/format.coverage.test.ts +++ b/frontend-modern/src/utils/__tests__/format.coverage.test.ts @@ -58,24 +58,26 @@ describe('formatPowerOnHours - boundaries', () => { }); describe('estimateTextWidth - edge inputs', () => { - it('counts whitespace characters as full-width', () => { - expect(estimateTextWidth(' ')).toBe(13.5); - expect(estimateTextWidth(' ')).toBe(19); + it('gives a space its own narrow advance', () => { + // A semibold space is 2.78px in the fallback advances. + expect(estimateTextWidth(' ')).toBe(3); + expect(estimateTextWidth(' ')).toBe(6); }); - it('counts control characters (tab, newline) as length 1', () => { - expect(estimateTextWidth('\t')).toBe(13.5); - expect(estimateTextWidth('\n')).toBe(13.5); + it('counts a glyph outside the measured ASCII range as a full em', () => { + expect(estimateTextWidth('\t')).toBe(10); + expect(estimateTextWidth('\n')).toBe(10); + expect(estimateTextWidth('—')).toBe(10); }); - it('counts surrogate pairs as length 2 (UTF-16 code units)', () => { + it('counts surrogate pairs as two UTF-16 code units', () => { // "😀" is one code point but two UTF-16 code units -> length === 2 expect('😀'.length).toBe(2); - expect(estimateTextWidth('😀')).toBe(19); + expect(estimateTextWidth('😀')).toBe(20); }); it('scales linearly for very long strings', () => { - expect(estimateTextWidth('a'.repeat(100))).toBe(558); + expect(estimateTextWidth('a'.repeat(100))).toBe(589); // 100 x 5.89 }); it('is strictly monotonic with respect to length', () => { diff --git a/frontend-modern/src/utils/__tests__/formatExtra.test.ts b/frontend-modern/src/utils/__tests__/formatExtra.test.ts index 319ad23a3d..0de416527a 100644 --- a/frontend-modern/src/utils/__tests__/formatExtra.test.ts +++ b/frontend-modern/src/utils/__tests__/formatExtra.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { formatPowerOnHours, estimateTextWidth, @@ -36,12 +36,75 @@ describe('formatPowerOnHours', () => { }); describe('estimateTextWidth', () => { - it('estimates width based on character count', () => { - expect(estimateTextWidth('')).toBe(8); - expect(estimateTextWidth('a')).toBe(13.5); - expect(estimateTextWidth('abc')).toBe(24.5); - expect(estimateTextWidth('hello')).toBe(35.5); // 5 chars = 27.5 + 8 - expect(estimateTextWidth('hello world')).toBe(68.5); // 11 chars = 60.5 + 8 + // jsdom has no layout, so these read the measured fallback advances + // (macOS system font at 10px) rather than a live probe. + it('returns zero for empty text', () => { + expect(estimateTextWidth('')).toBe(0); + }); + + it('sums per-glyph advances and rounds up', () => { + expect(estimateTextWidth('a')).toBe(6); // 5.89 + expect(estimateTextWidth('abc')).toBe(19); // 5.89 + 6.51 + 5.9 = 18.3 + }); + + it('tells wide glyphs from narrow ones at the same length', () => { + expect(estimateTextWidth('888')).toBe(21); // 3 x 6.88 + expect(estimateTextWidth('...')).toBe(11); // 3 x 3.43 + expect(estimateTextWidth('MMM')).toBe(28); // 3 x 9.08 + expect(estimateTextWidth('iii')).toBe(9); // 3 x 2.86 + }); + + it('measures detail at normal weight after the semibold value', () => { + // 60% is 23.7 semibold, " (265 GB/440 GB)" 86.89 at normal weight. The + // browser renders that label 110.1px wide. + expect(estimateTextWidth('60%', { detail: ' (265 GB/440 GB)' })).toBe(111); + expect(estimateTextWidth('265 GB')).toBe(38); + expect(estimateTextWidth('', { detail: '265 GB' })).toBe(37); + }); + + it('scales with the label font size', () => { + expect(estimateTextWidth('sda1 50%')).toBe(52); // 51.03 + expect(estimateTextWidth('sda1 50%', { fontPx: 9 })).toBe(46); // 45.93 + }); +}); + +describe('estimateTextWidth with layout', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it('reads glyph advances from one hidden probe and reuses them', async () => { + // A stand-in font: digits 8px, capitals 9px, everything else 4px, each + // probe cell holding its glyph sixteen times. + const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function ( + this: Element, + ) { + const text = this.textContent ?? ''; + const advance = /\d/.test(text[0]) ? 8 : /[A-Z]/.test(text[0]) ? 9 : 4; + return { width: text.length * advance } as DOMRect; + }); + vi.resetModules(); + const { estimateTextWidth: measured } = await import('@/utils/format'); + + // 8 digits, 4 capitals, 7 others. + expect(measured('60%', { detail: ' (265 GB/440 GB)' })).toBe(8 * 8 + 4 * 9 + 7 * 4); + // U+0020..U+007E at both weights, plus the ten tabular digits. + expect(rect).toHaveBeenCalledTimes(95 + 10 + 95); + expect(document.body.childElementCount).toBe(0); + + measured('73%', { detail: ' (475 GB/648 GB)' }); + expect(rect).toHaveBeenCalledTimes(95 + 10 + 95); + }); + + it('keeps the measured table when a stubbed layout reports every box alike', async () => { + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ + width: 100, + } as DOMRect); + vi.resetModules(); + const { estimateTextWidth: measured } = await import('@/utils/format'); + + expect(measured('60%', { detail: ' (265 GB/440 GB)' })).toBe(111); }); }); diff --git a/frontend-modern/src/utils/format.ts b/frontend-modern/src/utils/format.ts index 01ed35b2b7..7d959f9d89 100644 --- a/frontend-modern/src/utils/format.ts +++ b/frontend-modern/src/utils/format.ts @@ -283,13 +283,131 @@ export function formatPowerOnHours(hours: number, condensed = false): string { return condensed ? `${hours}h` : `${hours} hours`; } +// Metric bar labels are set at text-[10px]: the value semibold (with tabular +// digits while it animates), any detail after it at normal weight. Glyph +// widths in that type vary too much for a per-character constant (a digit is +// more than twice as wide as a full stop), so label widths are summed from +// per-glyph advances of the font the browser actually renders. A hidden probe +// carrying the label's own type classes measures them once. +const LABEL_FONT_PX = 10; +const LABEL_PROBE_FIRST_CODE = 0x20; +const LABEL_PROBE_LAST_CODE = 0x7e; +const LABEL_PROBE_REPEAT = 16; + +interface LabelGlyphAdvances { + // Semibold; a digit takes the wider of its proportional and tabular advance. + value: readonly number[]; + // Normal weight. + detail: readonly number[]; +} + +// Advances in px for U+0020..U+007E as the probe reads them in Chrome with the +// macOS system font (2026-10-03). They stand in wherever layout is +// unavailable, such as unit tests. +const FALLBACK_LABEL_GLYPH_ADVANCES: LabelGlyphAdvances = { + value: [ + 2.78, 3.54, 5.51, 6.7, 6.7, 10.11, 7.46, 3.43, 4.25, 4.25, 4.92, 6.7, 3.43, 4.92, 3.43, 3.37, + 6.79, 6.7, 6.7, 6.7, 6.85, 6.7, 6.8, 6.7, 6.88, 6.8, 3.43, 3.43, 6.7, 6.7, 6.7, 5.55, 9.36, + 7.25, 6.93, 7.45, 7.52, 6.25, 6.01, 7.68, 7.82, 3.13, 5.92, 7.05, 5.98, 9.08, 7.71, 7.93, 6.73, + 7.93, 6.93, 6.75, 6.64, 7.65, 7.18, 10.08, 7.25, 7.03, 6.83, 4.25, 3.37, 4.25, 6.7, 5.88, 5.12, + 5.89, 6.51, 5.9, 6.51, 6.03, 4.08, 6.46, 6.3, 2.86, 2.86, 5.94, 2.93, 9.24, 6.25, 6.22, 6.47, + 6.47, 4.35, 5.55, 4.11, 6.25, 5.82, 8.35, 5.74, 5.91, 5.69, 4.25, 2.98, 4.25, 6.7, + ], + detail: [ + 2.93, 3.23, 4.9, 6.42, 6.42, 9.38, 7.24, 3.09, 3.94, 3.94, 4.84, 6.42, 3.09, 4.84, 3.09, 3.17, + 6.42, 4.76, 6.16, 6.39, 6.56, 6.3, 6.49, 6, 6.51, 6.49, 3.09, 3.09, 6.42, 6.42, 6.42, 5.25, 9.3, + 6.86, 6.69, 7.28, 7.39, 6.08, 5.84, 7.59, 7.54, 2.8, 5.5, 6.71, 5.8, 8.86, 7.54, 7.84, 6.47, + 7.84, 6.66, 6.49, 6.46, 7.5, 6.86, 9.8, 6.91, 6.67, 6.74, 3.94, 3.17, 3.94, 6.42, 5.59, 5.12, + 5.64, 6.26, 5.72, 6.26, 5.84, 3.74, 6.22, 6.01, 2.59, 2.59, 5.55, 2.65, 8.82, 5.96, 6.03, 6.23, + 6.22, 4.02, 5.27, 3.75, 5.96, 5.54, 7.87, 5.37, 5.55, 5.51, 3.94, 2.71, 3.94, 6.42, + ], +}; + +let labelGlyphAdvances: LabelGlyphAdvances | undefined; +let labelGlyphAdvancesPixelRatio = 0; + +function measureLabelGlyphAdvances(): LabelGlyphAdvances | null { + if (typeof document === 'undefined' || !document.body) return null; + + const probe = document.createElement('div'); + probe.setAttribute('aria-hidden', 'true'); + probe.className = + 'pointer-events-none invisible fixed left-0 top-0 whitespace-pre font-sans text-[10px] leading-none'; + const addGroup = (className: string, firstCode: number, lastCode: number) => { + const group = document.createElement('div'); + group.className = className; + for (let code = firstCode; code <= lastCode; code++) { + const cell = document.createElement('div'); + cell.className = 'w-max'; + cell.textContent = String.fromCharCode(code).repeat(LABEL_PROBE_REPEAT); + group.appendChild(cell); + } + probe.appendChild(group); + return group; + }; + const semiboldGroup = addGroup('font-semibold', LABEL_PROBE_FIRST_CODE, LABEL_PROBE_LAST_CODE); + const tabularGroup = addGroup('font-semibold tabular-nums', 0x30, 0x39); + const normalGroup = addGroup('font-normal', LABEL_PROBE_FIRST_CODE, LABEL_PROBE_LAST_CODE); + + document.body.appendChild(probe); + const read = (group: HTMLElement) => + Array.from(group.children, (cell) => cell.getBoundingClientRect().width / LABEL_PROBE_REPEAT); + const semibold = read(semiboldGroup); + const tabular = read(tabularGroup); + const normal = read(normalGroup); + probe.remove(); + + // No layout (jsdom), or a stubbed one that reports every box alike. + const at = (advances: number[], glyph: string) => + advances[glyph.charCodeAt(0) - LABEL_PROBE_FIRST_CODE]; + if (!(at(normal, '.') > 0 && at(normal, 'M') > at(normal, '.'))) return null; + + const firstDigit = 0x30 - LABEL_PROBE_FIRST_CODE; + tabular.forEach((advance, digit) => { + semibold[firstDigit + digit] = Math.max(semibold[firstDigit + digit], advance); + }); + return { value: semibold, detail: normal }; +} + +function getLabelGlyphAdvances(): LabelGlyphAdvances { + // Zoom changes how glyph advances round, so the table is per pixel ratio. + const pixelRatio = typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1; + if (!labelGlyphAdvances || pixelRatio !== labelGlyphAdvancesPixelRatio) { + labelGlyphAdvances = measureLabelGlyphAdvances() ?? FALLBACK_LABEL_GLYPH_ADVANCES; + labelGlyphAdvancesPixelRatio = pixelRatio; + } + return labelGlyphAdvances; +} + +function sumLabelGlyphAdvances(text: string, advances: readonly number[]): number { + let width = 0; + for (let index = 0; index < text.length; index++) { + // A glyph outside the measured range counts as a full em. + width += advances[text.charCodeAt(index) - LABEL_PROBE_FIRST_CODE] ?? LABEL_FONT_PX; + } + return width; +} + +export interface EstimateTextWidthOptions { + /** Text that follows at normal weight, such as ` (265 GB/440 GB)`. */ + detail?: string; + /** Label font size in px when it is not the bar label's 10px. */ + fontPx?: number; +} + /** - * Estimate rendered text width based on character count. + * Estimate the rendered width in px of a metric bar label: `text` semibold, + * `options.detail` after it at normal weight. The result is the text alone, + * rounded up; callers add the padding their label sits in. * Used for determining if labels fit inside metric bars. * ALL text-width estimation MUST use this function. */ -export function estimateTextWidth(text: string): number { - return text.length * 5.5 + 8; +export function estimateTextWidth(text: string, options: EstimateTextWidthOptions = {}): number { + const advances = getLabelGlyphAdvances(); + const width = + sumLabelGlyphAdvances(text, advances.value) + + sumLabelGlyphAdvances(options.detail ?? '', advances.detail); + return Math.ceil((width * (options.fontPx ?? LABEL_FONT_PX)) / LABEL_FONT_PX); } /**