From 5db751985c71bdc172936cb8aed6bbf560d326a0 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:28:27 +0100 Subject: [PATCH 1/3] Fit metric bar labels by measured glyph widths, not character count The metric, stacked memory and stacked disk bars choose between "60%" and "60% (265 GB/440 GB)" by estimating the label's width. The estimate was 5.5px per character plus 8, which is off by -7px to +5px on real labels: at the label's 10px type a digit is 6.7px wide and a full stop 3px. In the Kubernetes clusters table at a 560px viewport the memory bar is 113px wide, the estimate came to 112.5px, the label needs 118px, and it rendered with an ellipsis. A sweep of the platform tables found 24 such bars on main, all stacked memory bars, at 560, 900 and 1440px. estimateTextWidth now sums per-glyph advances. It reads them once per page from a hidden probe that carries the label's type classes, so the estimate follows whichever system font the browser renders; the advances measured for the macOS system font stand in where there is no layout. The value is measured semibold and the detail at normal weight, and each model adds the padding its own label sits in instead of sharing one constant. Two more gaps in the same decision are closed. The stacked bars now count the anomaly marker that shares the label's line, and the inline per-disk slots are sized after the gap between them: a two-disk Proxmox node at 900px clipped "backup 80%" by 2px. The probe is the estimator's only layout read, 1.7ms on the 429-bar Proxmox overview, and never per bar. Bar widths still come from the resize observer alone. --- .../subsystems/performance-and-scalability.md | 16 +++ frontend-modern/browser-verification.json | 70 +++++----- .../metricBarModel.branchcov0720pm.test.ts | 46 +++++-- ...stackedDiskBarModel.branchcov0712b.test.ts | 73 ++++++++++- ...ckedMemoryBarModel.branchcov0724pm.test.ts | 40 ++++++ .../__tests__/useMetricBarState.test.tsx | 34 +++++ .../useStackedMemoryBarState.test.tsx | 30 +++++ .../components/Workloads/metricBarModel.ts | 6 +- .../Workloads/stackedDiskBarModel.ts | 28 +++- .../Workloads/stackedMemoryBarModel.ts | 12 +- .../utils/__tests__/format.coverage.test.ts | 20 +-- .../src/utils/__tests__/formatExtra.test.ts | 77 ++++++++++- frontend-modern/src/utils/format.ts | 124 +++++++++++++++++- 13 files changed, 495 insertions(+), 81 deletions(-) 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/browser-verification.json b/frontend-modern/browser-verification.json index db181fda93..e1d137895f 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,18 +1,25 @@ { "version": 1, - "base_sha": "0e90236d3bb901dc84d4d07e70d4d2a09d5e6705", - "verified_at": "2026-10-02T23:55:48Z", + "base_sha": "74fd189fd03098b7ed94d806b15f570722048670", + "verified_at": "2026-10-03T10:27:38Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/shared/Table.tsx" + "frontend-modern/src/components/Workloads/metricBarModel.ts", + "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts", + "frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts", + "frontend-modern/src/utils/format.ts" ], "content_sha256": { - "frontend-modern/src/components/shared/Table.tsx": "8e5029d5ee9dddc86a4733fa1e7249793cdbedb135f3634e75112f8fb3b327c9" + "frontend-modern/src/components/Workloads/metricBarModel.ts": "1c312bae85ff75b042fb502586662eebc91bc8b5d54054890e2f3bba155d5505", + "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts": "c7719b5646d60cb4683b10d9dd0567729d346dd95e003cb8bb62be33b3d39ac1", + "frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts": "7b2395cc5082ca1fdfb1a47aa0cc6a0f130e87ff57df3827f9f46a29cd704d7a", + "frontend-modern/src/utils/format.ts": "eed4e6a02476120a262b412120c158ac93583e2d6cec9dd39915253777605261" }, "routes": [ "/proxmox/overview", "/proxmox/storage", "/proxmox/replication", + "/proxmox/backups/date", "/proxmox/ceph", "/proxmox/mail", "/docker/overview", @@ -40,61 +47,46 @@ "/vmware/health", "/vmware/activity", "/standalone/machines", - "/standalone/availability", - "/alerts/overview", - "/alerts/history", - "/alerts/thresholds", - "/actions", - "/settings/infrastructure", - "/settings/monitoring", - "/settings/pulse-intelligence", - "/settings/organization", - "/settings/security-users", - "/settings/security-webhooks" + "/standalone/availability" ], "viewports": [ { "width": 1440, - "height": 900 - }, - { - "width": 1280, - "height": 900 + "height": 1400 }, { "width": 900, - "height": 900 + "height": 1400 }, { "width": 700, - "height": 900 + "height": 1400 }, { "width": 560, - "height": 900 + "height": 1400 }, { "width": 390, - "height": 900 + "height": 1400 } ], "states": [ - "Mock stack on origin/main plus this change; Playwright sweep of every table on the routes above at all six viewports, before and after, recording computed th/td padding per cell and leaf elements with scrollWidth > clientWidth under hidden overflow (455 table states compared)", - "Default TableCell/TableHead with no caller padding keep 8px/12px horizontal and 2px/6px vertical padding (Settings infrastructure source and agent tables unchanged at 12px/12px/6px/6px)", - "Platform tables (Proxmox nodes, Docker hosts and containers, Kubernetes clusters/nodes/deployments/pods, TrueNAS systems, vSphere, Machines): cells 12px -> 8px horizontal at 700px and up and 8px -> 6px at 560/390px; header cells keep the stylesheet 6px horizontal and drop to 2px vertical, header rows 29px -> 21px; row height 32px unchanged; clipped-cell count improved in 89 table states, unchanged in 362; the 4 states counted worse are live mock-value drift (relative-time and usage labels) with identical or wider content boxes", - "Physical Disks table (Proxmox > Storage > Physical Disks): cells 8px -> 4px horizontal at 390px and 12px -> 6px at 700px, matching the composed px-1 sm:px-1.5 lg:px-2; clipped cells at 390px 93 -> 18; detail row 12px -> 16px horizontal at 700px and 1440px", - "Storage pools table unchanged at 6px/8px cell padding across 390, 560 and 700px", - "Inline detail rows (Proxmox node, Kubernetes controller, TrueNAS system expansions): detail cell padding 12px/12px/2px/2px -> 0, detail panel inset now only its own px-2 sm:px-4; incident timeline panel in Alert History gets its p-3", - "Alerts overview stats table: dot column right padding 12px -> 0 and value column 12px -> 6px at 700px and up as its classes name; Alert History header cells 12px -> 8px horizontal; AI cost table (Settings > Pulse Intelligence) Est. USD column 12px -> 4px as its px-1 names", - "Workloads table header cells now 6px/8px to match their raw td data cells; Proxmox replication compact-mode !px-1 override still applies at 390px", - "Reactive case: switching a cell class between px-1 and a non-padding class re-resolves the base padding (unit test)" + "Mock stack on origin/main plus this change, Chromium, system font: Playwright sweep of every table on the 32 routes above at all five viewports (160 loads, 2,579 metric bars), recording th/td descendants with scrollWidth > clientWidth under hidden overflow and, per bar, observed width, rendered label, and the label's Range width plus padding", + "Same sweep on unmodified origin/main against the same backend process for a bar-by-bar comparison: 24 stacked memory bars showed a long label that did not fit, overflowing by 0.8px to 7.5px (3 on Kubernetes clusters at 560px, 4 on Proxmox overview at 900px, 16 on Docker overview and 1 on Kubernetes nodes at 1440px), and 3 inline per-disk labels were clipped on Proxmox overview at 900px", + "After the change none of those clip at any viewport: the 24 bars show the percentage alone and the inline disk slots show the percentage", + "Long labels at comfortable widths are kept: 550 bars show the long form against 567 before; of the 17 fewer, 24 are the clipped ones, 14 Proxmox guest disk bars at 900px gained the long form with 2.5px to 5.5px to spare, and 7 memory bars dropped it (5 on vSphere at 900px that had 1.0px to 2.8px to spare, 2 on Kubernetes nodes at 1440px that overflowed by under 0.5px)", + "Kubernetes clusters memory bar at 560px: 112.98px wide, shows the percentage; the same bar shows the long label at 900px (152.92px) and 1440px (182.11px) and returns to the percentage when the window narrows again", + "Still clipped and not changed here: the storage usage bar on /proxmox/storage (EnhancedStorageBar, which has no fit decision and does not use the estimator) clips at 390px (72 rows) and by up to 2.3px at 700px (19 rows), the same before and after", + "Every glyph forced to Verdana (10% wider than the system font): 2,219 bars on the 11 bar-bearing routes, 433 long labels, none clipped. Every glyph forced to Arial (7% narrower): 799 long labels, none clipped. The estimator follows the rendered font in both directions", + "WebKit, system font, same 11 routes: 2,219 bars, 550 long labels, none clipped", + "One-time glyph probe measured on the 429-bar Proxmox overview: 1.7 ms; about 110 ns per estimate afterwards" ], "interactions": [ - "Load each route at 1440, 1280, 900, 700, 560 and 390px and run the padding and clip sweep over all tables", - "Click Physical Disks on Proxmox > Storage at 390, 560, 700 and 1440px and expand the first disk row", - "Expand the first node row on Proxmox > Overview, the first deployment row on Kubernetes > Workloads, the first host row on Docker > Overview and the first system row on TrueNAS > Overview at 390, 560, 700 and 1440px", - "Expand the first alert row incident timeline on Alerts > History at 1440px", - "Compare before and after JSON sweeps per route, tab, viewport and table; re-measure the four states counted worse three times each", - "Revert and restore the change on the running dev server to A/B the Kubernetes clusters memory bar label at 560px" + "Load each route at 1440, 900, 700, 560 and 390px, wait for row and bar counts to settle, and run the clip sweep and bar inventory", + "Stash the change, repeat the full sweep on origin/main against the same backend process, restore the change and compare bar by bar", + "Hover the Kubernetes clusters memory bar at 560px while it shows the percentage alone and read used and free from its tooltip", + "Resize one loaded Kubernetes overview page through 560, 700, 900, 1440 and back to 560px and read the memory bar label at each width", + "Repeat the sweep on the bar-bearing routes with a global Verdana override, a global Arial override, and in WebKit" ] } 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..1914ed8d65 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('bg-surface/80 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); } /** From d08c7f6d05904a61fc9cf8fbfb69d899918ef1e8 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:09:54 +0100 Subject: [PATCH 2/3] Drop the retired shared browser receipt before merging main main retired frontend-modern/browser-verification.json in favour of per-commit receipts (#2436, #2437). Deleting it here lets origin/main merge without a modify/delete conflict; this branch's earlier commits stay valid evidence in range mode. --- frontend-modern/browser-verification.json | 92 ----------------------- 1 file changed, 92 deletions(-) delete mode 100644 frontend-modern/browser-verification.json diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json deleted file mode 100644 index e1d137895f..0000000000 --- a/frontend-modern/browser-verification.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "version": 1, - "base_sha": "74fd189fd03098b7ed94d806b15f570722048670", - "verified_at": "2026-10-03T10:27:38Z", - "result": "passed", - "changed_paths": [ - "frontend-modern/src/components/Workloads/metricBarModel.ts", - "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts", - "frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts", - "frontend-modern/src/utils/format.ts" - ], - "content_sha256": { - "frontend-modern/src/components/Workloads/metricBarModel.ts": "1c312bae85ff75b042fb502586662eebc91bc8b5d54054890e2f3bba155d5505", - "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts": "c7719b5646d60cb4683b10d9dd0567729d346dd95e003cb8bb62be33b3d39ac1", - "frontend-modern/src/components/Workloads/stackedMemoryBarModel.ts": "7b2395cc5082ca1fdfb1a47aa0cc6a0f130e87ff57df3827f9f46a29cd704d7a", - "frontend-modern/src/utils/format.ts": "eed4e6a02476120a262b412120c158ac93583e2d6cec9dd39915253777605261" - }, - "routes": [ - "/proxmox/overview", - "/proxmox/storage", - "/proxmox/replication", - "/proxmox/backups/date", - "/proxmox/ceph", - "/proxmox/mail", - "/docker/overview", - "/docker/images", - "/docker/storage", - "/docker/networks", - "/docker/swarm", - "/kubernetes/overview", - "/kubernetes/nodes", - "/kubernetes/workloads", - "/kubernetes/services", - "/kubernetes/storage", - "/kubernetes/configuration", - "/kubernetes/events", - "/truenas/overview", - "/truenas/storage", - "/truenas/services", - "/truenas/apps", - "/truenas/vms", - "/truenas/shares", - "/truenas/protection", - "/vmware/overview", - "/vmware/storage", - "/vmware/networks", - "/vmware/health", - "/vmware/activity", - "/standalone/machines", - "/standalone/availability" - ], - "viewports": [ - { - "width": 1440, - "height": 1400 - }, - { - "width": 900, - "height": 1400 - }, - { - "width": 700, - "height": 1400 - }, - { - "width": 560, - "height": 1400 - }, - { - "width": 390, - "height": 1400 - } - ], - "states": [ - "Mock stack on origin/main plus this change, Chromium, system font: Playwright sweep of every table on the 32 routes above at all five viewports (160 loads, 2,579 metric bars), recording th/td descendants with scrollWidth > clientWidth under hidden overflow and, per bar, observed width, rendered label, and the label's Range width plus padding", - "Same sweep on unmodified origin/main against the same backend process for a bar-by-bar comparison: 24 stacked memory bars showed a long label that did not fit, overflowing by 0.8px to 7.5px (3 on Kubernetes clusters at 560px, 4 on Proxmox overview at 900px, 16 on Docker overview and 1 on Kubernetes nodes at 1440px), and 3 inline per-disk labels were clipped on Proxmox overview at 900px", - "After the change none of those clip at any viewport: the 24 bars show the percentage alone and the inline disk slots show the percentage", - "Long labels at comfortable widths are kept: 550 bars show the long form against 567 before; of the 17 fewer, 24 are the clipped ones, 14 Proxmox guest disk bars at 900px gained the long form with 2.5px to 5.5px to spare, and 7 memory bars dropped it (5 on vSphere at 900px that had 1.0px to 2.8px to spare, 2 on Kubernetes nodes at 1440px that overflowed by under 0.5px)", - "Kubernetes clusters memory bar at 560px: 112.98px wide, shows the percentage; the same bar shows the long label at 900px (152.92px) and 1440px (182.11px) and returns to the percentage when the window narrows again", - "Still clipped and not changed here: the storage usage bar on /proxmox/storage (EnhancedStorageBar, which has no fit decision and does not use the estimator) clips at 390px (72 rows) and by up to 2.3px at 700px (19 rows), the same before and after", - "Every glyph forced to Verdana (10% wider than the system font): 2,219 bars on the 11 bar-bearing routes, 433 long labels, none clipped. Every glyph forced to Arial (7% narrower): 799 long labels, none clipped. The estimator follows the rendered font in both directions", - "WebKit, system font, same 11 routes: 2,219 bars, 550 long labels, none clipped", - "One-time glyph probe measured on the 429-bar Proxmox overview: 1.7 ms; about 110 ns per estimate afterwards" - ], - "interactions": [ - "Load each route at 1440, 900, 700, 560 and 390px, wait for row and bar counts to settle, and run the clip sweep and bar inventory", - "Stash the change, repeat the full sweep on origin/main against the same backend process, restore the change and compare bar by bar", - "Hover the Kubernetes clusters memory bar at 560px while it shows the percentage alone and read used and free from its tooltip", - "Resize one loaded Kubernetes overview page through 560, 700, 900, 1440 and back to 560px and read the memory bar label at each width", - "Repeat the sweep on the bar-bearing routes with a global Verdana override, a global Arial override, and in WebKit" - ] -} From 5960a4dc3e9729de07504533480b99451dbc2114 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:40:37 +0100 Subject: [PATCH 3/3] Pin the memory label chip padding, not its retired background The Tailwind 4 move (#2443) dropped the chip's bg-surface/80, which never rendered under Tailwind 3, so this branch's source pin failed after merging main. The model counts the chip's px-1 padding; pin that class alone. --- .../__tests__/stackedMemoryBarModel.branchcov0724pm.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1914ed8d65..e88e3eb1ff 100644 --- a/frontend-modern/src/components/Workloads/__tests__/stackedMemoryBarModel.branchcov0724pm.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/stackedMemoryBarModel.branchcov0724pm.test.ts @@ -231,7 +231,7 @@ describe('stackedMemoryBarModel (branch coverage 0724pm)', () => { }); it('counts the padding the label chip carries in StackedMemoryBar', () => { - expect(stackedMemoryBarSource).toContain('bg-surface/80 px-1 text-center'); + expect(stackedMemoryBarSource).toContain('px-1 text-center'); }); }); });