diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 26ce03a228..fbe93daaaa 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -1607,6 +1607,13 @@ service-history reads plus denial/recovery without fabricated samples. failing job's last good sync falls behind by its failure count, and a long-running demo never reports healthy jobs as overdue. A mock job never targets its own source node, and an estate without a second node has none. + The mock estate carries shared storage in the shape the poller produces, + not the per-node shape the PVE API returns: a shared PBS storage is one + cluster-scoped row listing its reporting nodes (issue 1049), never one row + per node with a different usage figure on each. + Demo Docker container names are unique per host, as Docker itself enforces: + a container profile reused for a second round on one host takes a numbered + suffix instead of producing two rows with one name. Mock metrics history must also stay bounded independently of estate size: eager multi-day PVE guest history is limited to a deterministic sample spread across the estate, while every omitted guest continues to receive the same 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 931c3191b5..1aa8041768 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -402,6 +402,12 @@ every tick. Mock replication timing is advanced in that same full-rotation refresh, and only a job whose schedule phase has passed is rewritten, so keeping demo replication on schedule adds no per-tick change for jobs that did not run. +Shared storage in the mock estate stays one row per cluster per store, so the +Storage surface grows with the number of storages rather than with the node +count times the number of shared storages. +Demo Docker container names stay unique per host (numbered suffixes on reused +profiles), so image "used by" lists and container rows never collapse two +distinct containers into what reads as a duplicated row. The browser applies resource deltas to its connection-scoped raw baseline, but canonicalizes and reconciles only changed resources plus the host-merge groups @@ -2563,6 +2569,13 @@ resize-observer plus tooltip lifecycle live in `frontend-modern/src/components/Workloads/useStackedDiskBarState.ts`. Future disk-bar runtime changes must extend through those owners instead of reintroducing mixed resize state and presentation branching into the shell. +The multi-disk `vertical-bars` mode (Proxmox nodes, Machines, and Docker +hosts with more than one agent-reported disk) draws one equal-weight micro-bar +per disk and labels the cell with the fullest disk's usage percentage, derived +in the model from the existing max-disk summary, so a multi-disk host answers +"how close is this host to running out of space?" at a glance in Bars mode +instead of only on hover or in Trends mode. The label names no disk in the +row; the mount and the per-disk breakdown stay in the title and tooltip. The dashboard stacked memory bar now follows that same pattern: the shell stays in `frontend-modern/src/components/Workloads/StackedMemoryBar.tsx`, while memory-capacity math, balloon/swap tooltip derivation, anomaly label @@ -2589,6 +2602,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/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 3711fff084..8d5ab25802 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -2761,6 +2761,14 @@ capability over storage or recovery data. discriminator` and `falls back to the topology discriminator when no vdev layout is reported` in `frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts`. + When neither is reported, a Proxmox storage plugin type resolves to the + name the Proxmox UI gives it (`lvmthin` is LVM-Thin, `nfs` is NFS, + `cifs` is SMB/CIFS, `iscsi` is iSCSI, `zfs`, `btrfs`, `glusterfs`, + `esxi` likewise); title-casing the raw plugin name is not a label. + A storage the poller merged across the cluster carries the synthetic + `cluster` node (the mock fixture's `shared`), and its host label reads + "Shared" with the reporting node count when the storage lists its + nodes, never the raw marker. 27. A rejected legacy RBAC import must not destroy the store. The import is transactional and leaves the legacy files in place, so a failure leaves the diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 98e6481a30..ecbf9657d9 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -917,6 +917,19 @@ last success and last schedule) are relative-age values under that rule: `formatPlatformTableDateTimeValue` time as hover title, never as the raw RFC 3339 string the agent reports, and the Detail column carries enough weight for those labelled ages to fit whole on a full-width desktop table. +The controller row expansion carries those timestamps in full: the +always-visible summary tables of the resource drawer render a Kubernetes +workload-controller section (`buildKubernetesControllerSection`, in the same +slot as the Docker container section) with the Job start and completion time, +the CronJob schedule and last run and last success time, each as the absolute +`formatPlatformTableDateTimeValue` time followed by its age in parentheses and +allowed to wrap in a phone-width detail cell, plus the target, +count, service, and scope fields the narrower layouts drop, so a phone or a +half-width pane reaches them in one tap rather than through a collapsed +platform-details disclosure. The `KubernetesControllersTable` phone projection +keeps controller, kind, ready, and issues and demotes Target with +`platform-table-phone-hidden`, so kind labels such as `DaemonSet` and +`StatefulSet` fit whole instead of clipping in a 15 percent track. Duration and interval cells keep the same split: unified-resource or source-specific consumers own which elapsed duration, human fallback, or poll interval field is meaningful, while dense platform table rendering must use @@ -2150,6 +2163,14 @@ application resource-provider or WebSocket lifecycle. populates only the canonical field on the REST contract, so without landing on `v2.uptime` ESXi hosts and VMware-backed VMs lose uptime on the unified-resources side even though the API payload carries it. + The same mapping must carry every facet the frontend reads at the top + level of `Resource`: the provider facets (`pmg` included) and the + per-source `sourceStatus`, `actionReadiness`, and `technology` fields the + API returns. The REST and websocket transports race on a fresh route + load, and a facet present only under `platformData` on the REST row left + the Mail Gateway table rendering every PMG column as a dash, and Docker + lifecycle refusals and storage freshness reading undefined, whenever the + REST snapshot landed last. That same shared cache boundary must normalize route/query type filters through the canonical frontend-to-`ResourceType` resolver before slicing the snapshot, so compatibility values such as `disk` / `physical_disk` @@ -2673,6 +2694,11 @@ facet. Its value is owned by the backup recovery model, not by canonical resource identity: PBS instance and datastore form the repository scope, and the same value must survive the date/coverage route handoff so both views apply one repository selection without inventing a top-level recovery route. +A PBS datastore resource takes its status from the storage vocabulary that PVE +storage already uses (`available`/`unavailable` alongside online, degraded, and +offline), so a healthy datastore is online and one PBS reports an error for is +offline; the generic string mapper that left every datastore unknown is not a +valid datastore status source. Unified-resource drawers and Kubernetes drill-down controls may own resource timeline filter semantics, namespace choices, and destination routes, but their native select chrome must compose the frontend-primitives-owned `FormSelect`. @@ -5528,6 +5554,16 @@ read-only status and governed UpdateButton content. The boundary must preserve full action names, review semantics and native agent/container identity; it must not hide update labels to satisfy width constraints. DockerNativeTables.test.ts checks that the governed update action remains inside that boundary. +A phone row (under 440px) keeps container, state, CPU, memory and the update +control and defers the restart count to the row expansion, so the state word +and the Update control fit whole instead of clipping to "runni…" beside an +unreadable Restarts header. At compact width (a 1280-1536px laptop window) the +Host, Engine and Updates columns take enough share that "docker 26.1.3", a +host name and the Update control render whole; the percentage-only CPU and +memory bars yield the difference. Swarm services show the rollout state as +words ("Rollback started") with the raw token, message and completion time in +the title, and Swarm tasks show Started as an age with the RFC3339 stamp in +the title, matching every other platform table's time cells. ResourceDetailDrawer headings wrap long display names rather than ellipsising them. Overview rows may still truncate identity; keyboard expansion exposes the diff --git a/frontend-modern/browser-verification/dceabcf6f551939d.json b/frontend-modern/browser-verification/dceabcf6f551939d.json new file mode 100644 index 0000000000..4187797721 --- /dev/null +++ b/frontend-modern/browser-verification/dceabcf6f551939d.json @@ -0,0 +1,67 @@ +{ + "version": 1, + "base_sha": "bb4a1372583e28204f9ac825d54855e5e00557ca", + "verified_at": "2026-10-03T20:35:27Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/StackedDiskBar.tsx", + "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts", + "frontend-modern/src/features/docker/DockerImagesTable.tsx", + "frontend-modern/src/features/docker/DockerServicesTable.tsx", + "frontend-modern/src/features/docker/DockerTasksTable.tsx", + "frontend-modern/src/features/docker/dockerContainerTableModel.ts", + "frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts", + "frontend-modern/src/features/storageBackups/storageAdapters.ts", + "frontend-modern/src/hooks/useUnifiedResources.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/StackedDiskBar.tsx": "100cc005912a2bef11c55fa8b6961d473d245e8a3fb71ab8a640186c294b26eb", + "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts": "9fde1740e300724ef9cab4a3b036c942bdab6e5d2060f262606b27f4b1a40ea6", + "frontend-modern/src/features/docker/DockerImagesTable.tsx": "29284b34d9631f118a449da1c274b1028e3cea57aed37a3ac056e48e05b2b613", + "frontend-modern/src/features/docker/DockerServicesTable.tsx": "0a84ec6834e2aa8f6603c57a3bad168911b9f06bef80c9c7ccbad07d1f04075c", + "frontend-modern/src/features/docker/DockerTasksTable.tsx": "6cc99405c7bdb2b2edfd992f6b17b2636bb9285a53e949e41609dfa8b339cacf", + "frontend-modern/src/features/docker/dockerContainerTableModel.ts": "b821b9caf65d5c247e543a6d6dbf0925f735b617c6393066d0f8c15a10e05e72", + "frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts": "933723fb9afeeb53afa46946a346daca19d50c0d61a81fc0730498ebd8c09bb2", + "frontend-modern/src/features/storageBackups/storageAdapters.ts": "daa85c5d99903d5a5fb10070837106b91f06ad130cae320472b2d980b979fb48", + "frontend-modern/src/hooks/useUnifiedResources.ts": "166e45835c56d00f1a3ddbc37f7127348a0e36dc7c42c921f972db7750c603c6" + }, + "routes": [ + "/proxmox/overview", + "/proxmox/storage", + "/proxmox/mail", + "/docker/overview", + "/docker/images", + "/docker/storage", + "/docker/networks", + "/docker/swarm", + "/machines" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 1280, + "height": 900 + }, + { + "width": 390, + "height": 900 + } + ], + "states": [ + "PR #2432 rebuilt as one commit on origin/main 1f140a5d38 (after Tailwind 4, #2443/#2444), on a private mock stack whose backend is built from this tree (the mock generator and REST mapper changes are live), Vite serving this tree, light and dark", + "Proxmox Storage at 1440 and 390: PBS datastores read Online instead of Unknown, types read NFS, Pool, Filesystem, Datastore and Backup Target, cluster-merged storage reads Shared \u00b7 6 nodes, shared PBS storage is one row; the 390 storage table still clips host and usage values exactly as main does, which open PR #2433 addresses", + "Proxmox Overview nodes table at 1440 light and dark in Bars mode: seven multi-disk hosts show their fullest disk percentage beside equal-weight micro-bars with a Highest usage title, none clipped; bar and fill corners compute 2px, matching main's rounded-xs after the Tailwind 4 rename", + "Mail Gateway on six fresh loads: both instances fill Version, Nodes, Uptime and mail counters every time, with no dash-only rows", + "Docker at 1440, 1280 and 390: Engine reads docker 26.1.3 or podman 4.9.3 whole with the Update control in view; phone containers keep Container, State, CPU, Mem and Update; phone images keep Image, Size and Update; Swarm services read Rollback started and tasks show Started as ages with no raw RFC3339 stamps; storage, networks and swarm phone clipping is limited to identity strings", + "The mock estate has no multi-disk Docker host or machine, so Bars mode was seen on Proxmox nodes only; DockerHostsTable and AgentsMachinesTable share the same StackedDiskBar path and its unit tests" + ], + "interactions": [ + "scrollWidth-over-clientWidth sweep of every visible table cell (sr-only excluded) on each route and width; read column value distributions for Storage State, Type and Host and Docker Engine", + "Opened Mail Gateway from the Proxmox tab strip on six independent page loads and counted dash-only cells per row", + "Read computed border radius of the vertical disk bars and the max-label text and title on every multi-disk node row", + "go test ./internal/mock ./internal/unifiedresources passed; vitest for src/components/Workloads, src/features/docker, src/features/storageBackups, src/features/platformPage and useUnifiedResources: 174 files, 3384 tests passed" + ] +} diff --git a/frontend-modern/browser-verification/f1bc00520f68ff00.json b/frontend-modern/browser-verification/f1bc00520f68ff00.json new file mode 100644 index 0000000000..d75329d97d --- /dev/null +++ b/frontend-modern/browser-verification/f1bc00520f68ff00.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "base_sha": "ebda81d2be79d2ee7e7ff4f665b41e1d330d87a6", + "verified_at": "2026-10-03T20:21:04Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx", + "frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "ddad24a010a2a194f37977f3c3aa3fa647b431bd65d1d75e9f5aea6d42f72692", + "frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "5d7362905632cf4e69c4a878f972ac40544bda2959db2822a431bcc74ba35089" + }, + "routes": [ + "/kubernetes" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 1280, + "height": 900 + }, + { + "width": 900, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Merge of origin/main 1f140a5d38 into #2434: both files carry the PR's controller-expansion changes plus main's Tailwind 4 renames (max-w-48/max-w-52 truncation spans, rounded-sm badges, dark:bg-blue-900/25 identity IP badge), checked on a private mock stack built from the merged tree", + "Workload Controllers table with DaemonSet, CronJob, Job and StatefulSet rows across three mock clusters: 1440 shows Scope and Detail, 1280 and 900 hide them, 390 keeps Controller, Kind, Ready and Issues; table width equals its container at every width, 1440 clips only one long StatefulSet Service detail, 1280 and 900 clip nothing, 390 clips only long controller names", + "CronJob, Job and StatefulSet rows expanded at every width in light (and dark at 1440 and 390): the CronJob section shows Schedule, Last run and Last success as absolute time with age, Active, Namespace and Cluster; the Job section shows Started, Completed, Duration 5m, Target 1 completion and Succeeded; the StatefulSet shows Service, Target, Current, Ready, Available and Updated; no value in those sections clips, and only the pre-existing Identity IDs clip at 390" + ], + "interactions": [ + "Loaded /kubernetes per viewport and colour scheme, clicked the cron-nightly-backfill, nightly-backfill-28918234 and platform-observability rows to open and close their inline drawers", + "Ran a scrollWidth-over-clientWidth sweep over every controllers th/td leaf and every leaf inside each open drawer", + "Ran vitest for src/features/kubernetes, src/components/Infrastructure and src/features/platformPage on the merged tree: 90 files, 1504 tests passed" + ] +} 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 b56964dfe4..9809420da7 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)', () => { @@ -467,6 +489,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 3dbf3531b6..a9e7f98ca6 100644 --- a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts +++ b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts @@ -149,8 +149,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 { @@ -274,7 +283,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); @@ -312,15 +324,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/features/docker/__tests__/DockerNativeTables.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx index 15bb7087d2..b7f88cf332 100644 --- a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx +++ b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx @@ -1516,10 +1516,7 @@ describe('Docker native tables', () => { expect(screen.getByText('2')).toBeInTheDocument(); // The snake_case rollout token reads as words; the raw token stays in the title. expect(screen.getByText('Rollback started')).toBeInTheDocument(); - expect(screen.getByText('Rollback started')).toHaveClass( - 'whitespace-normal', - 'wrap-break-word', - ); + expect(screen.getByText('Rollback started')).toHaveClass('whitespace-normal', 'wrap-break-word'); expect( screen.getByText('Rollback started').closest('[title]')?.getAttribute('title'), ).toContain('rollback_started'); diff --git a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts index 8a63862653..bae62908b8 100644 --- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts @@ -321,9 +321,20 @@ describe('platform overview layout guardrails', () => { ]) { expect(source).toContain('platform-table-mobile-w-30'); } + // The images phone row keeps image, size and the update verdict; host and + // consumers move to the row expansion so the verdict is not clipped. expect(dockerImagesTableSource).toMatch( /sortKey="size"[\s\S]{0,120}?class="platform-table-mobile-w-20 md:w-\[12%\]"/, ); + expect(dockerImagesTableSource).toMatch( + /sortKey="host"[\s\S]{0,120}?class="platform-table-phone-hidden md:w-\[18%\]"/, + ); + expect(dockerImagesTableSource).toMatch( + /sortKey="usedBy"[\s\S]{0,120}?class="platform-table-phone-hidden md:w-\[24%\]"/, + ); + expect(dockerImagesTableSource).toMatch( + /sortKey="update"[\s\S]{0,120}?class="platform-table-mobile-w-25 md:w-\[16%\]"/, + ); expect(dockerServicesTableSource).toMatch( /sortKey="mode"[\s\S]{0,120}?class="platform-table-phone-hidden md:w-\[8%\]"/, ); 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); } /** diff --git a/internal/mock/demo_scenarios.go b/internal/mock/demo_scenarios.go index e4fd41df0f..6211b82793 100644 --- a/internal/mock/demo_scenarios.go +++ b/internal/mock/demo_scenarios.go @@ -649,7 +649,7 @@ func applyDemoOfflineDockerContainers(containers []models.DockerContainer, now t finished = finished.Add(-18 * time.Minute) for i := range containers { profile := offlineProfiles[i%len(offlineProfiles)] - containers[i].Name = profile.Name + containers[i].Name = demoContainerProfileName(profile.Name, i/len(offlineProfiles)) containers[i].Image = profile.Image containers[i].Labels = mergeScenarioLabelSet(containers[i].Labels, profile.Tags) containers[i].State = "exited" @@ -662,6 +662,16 @@ func applyDemoOfflineDockerContainers(containers []models.DockerContainer, now t return containers } +// demoContainerProfileName keeps container names unique per host. Docker +// refuses two containers with one name on a host, so a profile reused for a +// second round takes a numbered suffix instead of producing duplicate rows. +func demoContainerProfileName(name string, round int) string { + if round <= 0 { + return name + } + return fmt.Sprintf("%s-%d", name, round+1) +} + func applyDemoDockerContainerProfiles( containers []models.DockerContainer, profiles []demoDockerContainerProfile, @@ -672,7 +682,7 @@ func applyDemoDockerContainerProfiles( }) for i := range containers { profile := profiles[i%len(profiles)] - containers[i].Name = profile.Name + containers[i].Name = demoContainerProfileName(profile.Name, i/len(profiles)) containers[i].Image = profile.Image containers[i].Labels = mergeScenarioLabelSet(containers[i].Labels, profile.Tags) containers[i].State = "running" diff --git a/internal/mock/demo_scenarios_test.go b/internal/mock/demo_scenarios_test.go index b5e8c34af9..4b46d58649 100644 --- a/internal/mock/demo_scenarios_test.go +++ b/internal/mock/demo_scenarios_test.go @@ -987,3 +987,27 @@ func demoProxmoxNodeIsOffline(graph FixtureGraph, nodeName string) bool { } return false } + +func TestDemoDockerContainerNamesAreUniquePerHost(t *testing.T) { + cfg := DefaultConfig + cfg.DockerHostCount = 3 + cfg.DockerContainersPerHost = 8 + + graph := buildFixtureGraph(cfg, time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC)) + + // Docker refuses two containers with one name on a host. The demo used to + // reuse a curated profile for a second round and show two "customer-portal" + // rows per host, which also doubled every image's "used by" list. + for _, host := range graph.State.DockerHosts { + seen := map[string]struct{}{} + for _, container := range host.Containers { + if _, dup := seen[container.Name]; dup { + t.Fatalf("host %s names two containers %q", host.Hostname, container.Name) + } + seen[container.Name] = struct{}{} + } + } + if !dockerContainerNameExists(graph, "customer-portal") { + t.Fatal("expected the first round of curated names to stay unchanged") + } +} diff --git a/internal/mock/generator.go b/internal/mock/generator.go index c1d914d06d..658d7c48a6 100644 --- a/internal/mock/generator.go +++ b/internal/mock/generator.go @@ -5197,50 +5197,62 @@ func generateStorage(nodes []models.Node) []models.Storage { } sort.Strings(clusterInstances) - // Every node reports the cluster's two shared PBS storage definitions. This - // stays linear in fleet size: the former one-storage-per-target-node shape - // grew quadratically and made a large demo look busy for the wrong reason. + // Each cluster has two shared PBS storage definitions. Every node reports + // them to the API, but the poller merges a shared storage into one + // cluster-scoped entry (issue 1049), so the fixture carries that merged + // shape: one row per store with the reporting nodes listed, never one row + // per node with a different usage figure on each. for _, clusterInstance := range clusterInstances { clusterNodes := clusterNodesByInstance[clusterInstance] sort.Slice(clusterNodes, func(i, j int) bool { return clusterNodes[i].Name < clusterNodes[j].Name }) + nodeNames := make([]string, 0, len(clusterNodes)) + nodeIDs := make([]string, 0, len(clusterNodes)) + anyNodeOnline := false for _, node := range clusterNodes { - for storeIndex, storeName := range []string{"pbs-primary", "pbs-offsite"} { - pbsTotal := int64(48+storeIndex*16) * 1024 * 1024 * 1024 * 1024 - pbsID := fmt.Sprintf("%s-%s-%s", node.Instance, node.Name, storeName) - pbsUsage := SampleMetric("pbsDatastore", pbsID, "usage", now) - pbsUsed := int64(float64(pbsTotal) * (pbsUsage / 100.0)) - isOffline := node.Status != "online" || node.ConnectionHealth == "offline" || node.Uptime <= 0 - status := "available" - enabled := true - active := true - lastSeen := now - if isOffline { - status = "offline" - enabled = false - active = false - pbsUsed = 0 - pbsUsage = 0 - lastSeen = now.Add(-10 * time.Minute) - } - storage = append(storage, models.Storage{ - ID: pbsID, - Name: storeName, - Node: node.Name, - Instance: node.Instance, - Type: "pbs", - Status: status, - Total: pbsTotal, - Used: pbsUsed, - Free: pbsTotal - pbsUsed, - Usage: pbsUsage, - Content: "backup", - Shared: true, - Enabled: enabled, - Active: active, - LastSeen: lastSeen, - }) + nodeNames = append(nodeNames, node.Name) + nodeIDs = append(nodeIDs, fmt.Sprintf("%s-%s", node.Instance, node.Name)) + if node.Status == "online" && node.ConnectionHealth != "offline" && node.Uptime > 0 { + anyNodeOnline = true } } + for storeIndex, storeName := range []string{"pbs-primary", "pbs-offsite"} { + pbsTotal := int64(48+storeIndex*16) * 1024 * 1024 * 1024 * 1024 + pbsID := fmt.Sprintf("%s-cluster-%s", clusterInstance, storeName) + pbsUsage := SampleMetric("pbsDatastore", pbsID, "usage", now) + pbsUsed := int64(float64(pbsTotal) * (pbsUsage / 100.0)) + status := "available" + enabled := true + active := true + lastSeen := now + if !anyNodeOnline { + status = "offline" + enabled = false + active = false + pbsUsed = 0 + pbsUsage = 0 + lastSeen = now.Add(-10 * time.Minute) + } + storage = append(storage, models.Storage{ + ID: pbsID, + Name: storeName, + Node: "cluster", + Nodes: append([]string(nil), nodeNames...), + NodeIDs: append([]string(nil), nodeIDs...), + NodeCount: len(nodeNames), + Instance: clusterInstance, + Type: "pbs", + Status: status, + Total: pbsTotal, + Used: pbsUsed, + Free: pbsTotal - pbsUsed, + Usage: pbsUsage, + Content: "backup", + Shared: true, + Enabled: enabled, + Active: active, + LastSeen: lastSeen, + }) + } sharedTotal := int64(20 * 1024 * 1024 * 1024 * 1024) sharedID := fmt.Sprintf("%s-shared-storage", clusterInstance) diff --git a/internal/mock/generator_test.go b/internal/mock/generator_test.go index 1c254a197c..98772cd2ff 100644 --- a/internal/mock/generator_test.go +++ b/internal/mock/generator_test.go @@ -1291,3 +1291,34 @@ func TestRebaseMockReplicationJobsLeavesUnchangedJobsAlone(t *testing.T) { t.Fatalf("expected at most one job to roll over in one second, %d of %d stayed put", unchanged, len(jobs)) } } + +func TestBuildFixtureStateMergesSharedPBSStorageLikeThePoller(t *testing.T) { + cfg := DefaultConfig + cfg.NodeCount = 5 + + data := buildFixtureState(cfg) + + // Issue 1049: the poller folds a shared storage reported by every node + // into one cluster-scoped row. The fixture must not fan it back out into + // one row per node, each with its own usage figure. + seen := map[string]models.Storage{} + for _, storage := range data.Storage { + if storage.Type != "pbs" { + continue + } + key := storage.Instance + "/" + storage.Name + if _, dup := seen[key]; dup { + t.Fatalf("shared PBS storage %s appears more than once", key) + } + seen[key] = storage + if storage.Node != "cluster" || storage.NodeCount < 2 || len(storage.Nodes) != storage.NodeCount { + t.Fatalf("shared PBS storage %s is not cluster-scoped: node=%q nodes=%v count=%d", key, storage.Node, storage.Nodes, storage.NodeCount) + } + if !strings.HasSuffix(storage.ID, "-cluster-"+storage.Name) { + t.Fatalf("shared PBS storage %s keeps a node-scoped id %q", key, storage.ID) + } + } + if len(seen) == 0 { + t.Fatal("expected shared PBS storage in the fixture") + } +} diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index 2496c8eef2..67cfe887ab 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -1563,7 +1563,11 @@ func resourceFromPBSDatastore(instance models.PBSInstance, datastore models.PBSD name := strings.TrimSpace(datastore.Name) assessment := storagehealth.AssessPBSDatastore(datastore) risk := storageRiskFromAssessment(assessment) - status := storageStatus(statusFromString(datastore.Status), risk) + // The PBS poller reports datastores as "available"/"unavailable", the same + // storage vocabulary PVE uses; the generic string mapper does not know it + // and left every healthy datastore "unknown". + datastoreStatus, _ := statusFromStorageState(datastore.Status) + status := storageStatus(datastoreStatus, risk) incidents := incidentsFromAssessment("pulse", string(SourcePBS), "pbs-datastore:"+name, assessment, instance.LastSeen) status = incidentsStatus(status, incidents) diff --git a/internal/unifiedresources/adapters_test.go b/internal/unifiedresources/adapters_test.go index b06013ade9..b6309233d1 100644 --- a/internal/unifiedresources/adapters_test.go +++ b/internal/unifiedresources/adapters_test.go @@ -1242,6 +1242,28 @@ func TestResourceFromPBSDatastoreDerivesRiskAndIncidents(t *testing.T) { } } +func TestResourceFromPBSDatastoreReadsStorageStatusVocabulary(t *testing.T) { + instance := models.PBSInstance{ID: "pbs-1", Name: "pbs-main", LastSeen: time.Now().UTC()} + // The poller stamps "available" when PBS reports no error and + // "unavailable" when it does; neither is in the generic status mapper. + cases := []struct { + status string + want ResourceStatus + }{ + {status: "available", want: StatusOnline}, + {status: "unavailable", want: StatusOffline}, + {status: "degraded", want: StatusWarning}, + {status: "", want: StatusUnknown}, + } + for _, tc := range cases { + datastore := models.PBSDatastore{Name: "backup-store", Status: tc.status, Total: 100, Used: 10} + resource, _ := resourceFromPBSDatastore(instance, datastore) + if resource.Status != tc.want { + t.Fatalf("status %q: Status = %q, want %q", tc.status, resource.Status, tc.want) + } + } +} + func TestResourceFromPBSInstanceRollsUpDatastoreRisk(t *testing.T) { now := time.Now().UTC() instance := models.PBSInstance{ diff --git a/internal/unifiedresources/pbs_pmg_registry_test.go b/internal/unifiedresources/pbs_pmg_registry_test.go index 0146f353fa..cb7ac971b3 100644 --- a/internal/unifiedresources/pbs_pmg_registry_test.go +++ b/internal/unifiedresources/pbs_pmg_registry_test.go @@ -843,3 +843,26 @@ func TestPBSHostHistoryThreeServerTopology(t *testing.T) { t.Fatalf("three-server topology: services=%d linked hosts=%d linked disks=%d", services, linkedHosts, linkedDisks) } } + +func TestStatusFromStorageStateCoversPBSAndPVEVocabulary(t *testing.T) { + // PVE storage and PBS datastores both report "available"/"unavailable"; + // the mapping has to be shared so neither surface falls back to unknown. + cases := []struct { + state string + want ResourceStatus + known bool + }{ + {state: "available", want: StatusOnline, known: true}, + {state: "ACTIVE", want: StatusOnline, known: true}, + {state: "degraded", want: StatusWarning, known: true}, + {state: "unavailable", want: StatusOffline, known: true}, + {state: "", want: StatusUnknown, known: false}, + {state: "mystery", want: StatusUnknown, known: false}, + } + for _, tc := range cases { + got, known := statusFromStorageState(tc.state) + if got != tc.want || known != tc.known { + t.Fatalf("statusFromStorageState(%q) = (%q, %v), want (%q, %v)", tc.state, got, known, tc.want, tc.known) + } + } +} diff --git a/internal/unifiedresources/status.go b/internal/unifiedresources/status.go index fa4650e281..fcd1676c74 100644 --- a/internal/unifiedresources/status.go +++ b/internal/unifiedresources/status.go @@ -32,14 +32,25 @@ func statusFromGuest(status string) ResourceStatus { } } -func statusFromStorage(storage models.Storage) ResourceStatus { - switch strings.ToLower(strings.TrimSpace(storage.Status)) { +// statusFromStorageState maps the storage-native status vocabulary, which +// includes the "available"/"unavailable" pair PVE storage and PBS datastores +// report, onto a resource status. The second result is false when the string +// is not a storage state the caller can act on. +func statusFromStorageState(status string) (ResourceStatus, bool) { + switch strings.ToLower(strings.TrimSpace(status)) { case "online", "running", "available", "active", "ok": - return StatusOnline + return StatusOnline, true case "warning", "degraded": - return StatusWarning + return StatusWarning, true case "offline", "down", "unavailable", "error": - return StatusOffline + return StatusOffline, true + } + return StatusUnknown, false +} + +func statusFromStorage(storage models.Storage) ResourceStatus { + if status, ok := statusFromStorageState(storage.Status); ok { + return status } if !storage.Active { return StatusOffline