Compose exact upstream storage and workload presentation

Preserve reviewed backup-reader and canonical deferred-read status guidance with upstream measured-width disk labels. Resolve only additive test assertions, keeping lifecycle refusal, PMG replacement, phone projection and word-wrapping controls. The combined disk model matches the previously diagnosed 9f50f630 content and requires fresh owning-lane browser proof.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-03 23:20:52 +01:00
27 changed files with 835 additions and 92 deletions
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
]
}
@@ -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"
]
}
@@ -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);
@@ -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 && ).
@@ -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');
});
});
});
@@ -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<typeof useMetricBarState> | undefined;
const Harness = () => {
captured = useMetricBarState({
value: 60,
label: '60%',
sublabel: '265 GB/440 GB',
type: 'memory',
});
return <div ref={captured.setContainerRef} />;
};
render(() => <Harness />);
// '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);
});
});
@@ -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<typeof useStackedMemoryBarState> | undefined;
const Harness = () => {
captured = useStackedMemoryBarState({
used: 265 * 1024 ** 3,
total: 440 * 1024 ** 3,
});
return <div ref={captured.setContainerRef} />;
};
render(() => <Harness />);
// '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);
});
});
@@ -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;
@@ -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)
@@ -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,
@@ -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');
@@ -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%\]"/,
);
@@ -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', () => {
@@ -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);
});
});
+121 -3
View File
@@ -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);
}
/**
+12 -2
View File
@@ -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"
+24
View File
@@ -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")
}
}
+50 -38
View File
@@ -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)
+31
View File
@@ -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")
}
}
+5 -1
View File
@@ -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)
@@ -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{
@@ -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)
}
}
}
+16 -5
View File
@@ -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