diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index dd225b090f..0bf42b1a70 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -88,6 +88,17 @@ API context; late responses cannot repopulate old evidence. Disposal aborts
reads and removes subscriptions. This uses the existing query lifecycle, without
new inventory polling or a remount cache.
+Successful HTTP responses still need a readable backup envelope, collections
+and consumed row fields. Invalid JSON, missing data and malformed rows produce
+a source-local unavailable state with fixed format guidance, never raw response
+content, a fabricated empty inventory or a page-level rendering failure that
+hides another source. No invalid rows are silently dropped, no completion flags
+are coerced, and only a validated response clears the warning. Explicitly nil
+PVE collections remain compatible with the existing Go encoding.
+`proxmoxBackupInventory.test.ts`, mounted backup-table controls and
+`browser-tests/backup-inventory-format.cjs` verify these boundaries and isolated
+keyboard/touch recovery, not native backup or released availability.
+
Verification: mounted `ProxmoxBackupsTable.test.tsx` covers source pending,
partial/both-source failure, isolated retry, fulfilled empty counts, 401/403,
organisation replacement, late response and disposal. Production component,
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 0d7f50f36c..8d5ab25802 100644
--- a/docs/release-control/v6/internal/subsystems/storage-recovery.md
+++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md
@@ -41,6 +41,17 @@ disposal. `browser-tests/backup-inventory.cjs` uses production components,
API adapters, router and CSS in Chromium/WebKit with synthetic HTTP responses;
these controls are not native backup, permission, thaw or release acceptance.
+An HTTP success alone does not establish readable recovery evidence. The client
+validates the data envelope, source collections and consumed row fields before
+publishing that inventory. Malformed or undecodable responses invalidate that
+source atomically, not a silently truncated list or confirmed empty inventory.
+PVE's explicitly nil Go collections remain valid empty observations; absent
+fields are not nil collections. Optional unknown facts and additional fields
+retain their existing meaning, and completion flags are never coerced.
+`proxmoxBackupInventory.test.ts`, the mounted backup-table tests and
+`browser-tests/backup-inventory-format.cjs` cover this response boundary, source
+isolation and recovery without extending native protection or thaw claims.
+
### Backup identity, phone storage and History consent composition
The Proxmox Coverage last-backup pointer selects completed PBS snapshots or PVE
@@ -2750,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 19287752f7..ecbf9657d9 100644
--- a/docs/release-control/v6/internal/subsystems/unified-resources.md
+++ b/docs/release-control/v6/internal/subsystems/unified-resources.md
@@ -35,6 +35,13 @@ count lifecycle; `browser-tests/backup-inventory.cjs` verifies production rows
and source notices on desktop/phone. This changes no resource identity, native
provider assessment or permission policy.
+The backup inventory count also remains unavailable when an HTTP200 response
+fails the client backup-envelope/row validation; transport success is not an
+observed zero. A malformed source cannot remove independently readable canonical
+server health or the other source's artifacts. Fixed diagnostics expose no
+response body. The inventory parser, mounted table and
+`browser-tests/backup-inventory-format.cjs` exercise that combined boundary.
+
### Guest disk freshness presentation
Existing workload disk evidence retains its source reason through the row,
@@ -910,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
@@ -2143,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`
@@ -2666,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`.
@@ -5521,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-tests/backup-inventory-format.cjs b/frontend-modern/browser-tests/backup-inventory-format.cjs
new file mode 100644
index 0000000000..1d0fce2be9
--- /dev/null
+++ b/frontend-modern/browser-tests/backup-inventory-format.cjs
@@ -0,0 +1,278 @@
+// Production backup readers/table/router/CSS, synthetic HTTP only.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const crypto = require('node:crypto');
+const { chromium, webkit } = require('playwright');
+const phase = 'final';
+const root = '/workspace/frontend-modern';
+const output = `/workspace/tmp/backup-format-${phase}`;
+const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
+const emptyPVE = { data: { backupTasks: [], storageBackups: [], guestSnapshots: [] } };
+const emptyPBS = { data: { backups: [] } };
+const archive = {
+ id: 'archive-112',
+ instance: 'pve-a',
+ node: 'pve-a',
+ storage: 'local',
+ type: 'ct',
+ vmid: 112,
+ time: '2026-10-03T01:00:00Z',
+ format: 'zst',
+ volid: 'local:backup/vzdump-lxc-112.tar.zst',
+};
+const snapshot = {
+ id: 'pbs-main/main/ct/112/2026-10-03T02:00:00Z',
+ instance: 'pbs-main',
+ datastore: 'main',
+ backupType: 'ct',
+ vmid: '112',
+ backupTime: '2026-10-03T02:00:00Z',
+ files: ['index.json.blob'],
+};
+const pvePayload = { data: { ...emptyPVE.data, storageBackups: [archive] } };
+const pbsPayload = { data: { backups: [snapshot] } };
+
+(async () => {
+ fs.mkdirSync(output, { recursive: true });
+ process.chdir(root);
+ const playwrightVersion = require('playwright/package.json').version;
+ assert.equal(
+ playwrightVersion,
+ JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
+ 'node_modules/@playwright/test'
+ ].version,
+ );
+ const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
+ const server = await createServer({
+ cacheDir: path.join(output, 'vite-cache'),
+ server: { host: '127.0.0.1', port: 5298, strictPort: true },
+ });
+ const observations = [];
+ let browser;
+ try {
+ await server.listen();
+ for (const [name, engine, phone] of [
+ ['chromium-desktop', chromium, false],
+ ['webkit-phone', webkit, true],
+ ]) {
+ browser = await engine.launch(
+ engine === chromium
+ ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
+ : { headless: true },
+ );
+ const viewport = phone ? { width: 390, height: 844 } : { width: 1365, height: 900 };
+ const page = await browser.newPage({
+ viewport,
+ isMobile: phone,
+ hasTouch: phone,
+ locale: 'en-GB',
+ timezoneId: 'UTC',
+ });
+ page.setDefaultTimeout(20000);
+ await page.clock.setFixedTime(new Date('2026-10-03T12:00:00Z'));
+ const errors = [],
+ requests = [],
+ offOrigin = [],
+ screenshots = [],
+ checks = [];
+ page.on('pageerror', (error) => errors.push(error.message));
+ const sources = { pve: emptyPVE, pbs: {} };
+ let retryGate;
+ await page.route('**/*', async (route) => {
+ const request = route.request();
+ const url = new URL(request.url());
+ if (url.origin !== 'http://127.0.0.1:5298') {
+ offOrigin.push(url.origin);
+ return route.abort();
+ }
+ if (!url.pathname.startsWith('/api/')) return route.continue();
+ requests.push({ path: url.pathname, method: request.method() });
+ const key =
+ url.pathname === '/api/backups/pbs'
+ ? 'pbs'
+ : url.pathname === '/api/backups/pve'
+ ? 'pve'
+ : null;
+ const payload = key ? sources[key] : { data: [], policy: {}, meta: {} };
+ if (key === 'pbs' && retryGate) await retryGate;
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify(payload),
+ });
+ });
+ const rows = () => page.locator('[data-proxmox-backup-row="recoverable"]');
+ const notice = (source) =>
+ page.getByRole('status').filter({
+ hasText: `${source} backup inventory is unavailable`,
+ });
+ const load = async () => {
+ await page.goto('http://127.0.0.1:5298/browser-tests/backup-inventory.html?view=date', {
+ waitUntil: 'domcontentloaded',
+ });
+ if (phone) await page.evaluate(() => document.documentElement.classList.add('dark'));
+ };
+ const capture = async (suffix) => {
+ await page.evaluate(() =>
+ Promise.all(
+ document
+ .getAnimations()
+ .filter((animation) => animation.effect?.getTiming().iterations !== Infinity)
+ .map((animation) => animation.finished.catch(() => undefined)),
+ ),
+ );
+ const file = path.join(output, `${name}-${suffix}.png`);
+ await page.screenshot({ path: file, fullPage: true });
+ screenshots.push({ path: path.relative('/workspace', file), sha256: sha256(file) });
+ };
+
+ await load();
+ if (phase === 'baseline') {
+ await page.getByText('No backups yet', { exact: true }).waitFor();
+ assert.equal(await notice('PBS').count(), 0);
+ checks.push('HTTP200 missing PBS data is incorrectly shown as No backups yet');
+ } else {
+ await notice('PBS').waitFor();
+ await page.getByText('Backup inventory is incomplete', { exact: true }).waitFor();
+ assert.equal(await page.getByText('No backups yet', { exact: true }).count(), 0);
+ await page.getByText(/The response format is invalid/).waitFor();
+ if (!phone) await page.locator('[title="PBS backup inventory is unavailable"]').waitFor();
+ checks.push(
+ 'HTTP200 missing data remains incomplete with a fixed format explanation, not empty/zero',
+ );
+ }
+ await capture('missing-data');
+
+ sources.pve = pvePayload;
+ sources.pbs = { data: { backups: {} }, message: 'SYNTHETIC_PRIVATE_BODY_DO_NOT_DISPLAY' };
+ await load();
+ if (phase === 'baseline') {
+ await page.getByRole('alert').filter({ hasText: 'Backup view failed' }).waitFor();
+ assert.equal(await rows().count(), 0);
+ checks.push(
+ 'HTTP200 non-array PBS inventory removes independently readable PVE evidence at ErrorBoundary',
+ );
+ } else {
+ await notice('PBS').waitFor();
+ await rows().first().waitFor();
+ assert.equal(await rows().count(), 1);
+ assert.match(await rows().first().innerText(), /PVE file/);
+ assert.equal(await page.getByRole('alert').count(), 0);
+ assert.equal(await page.getByText('SYNTHETIC_PRIVATE_BODY_DO_NOT_DISPLAY').count(), 0);
+ checks.push(
+ 'Malformed PBS is source-local; readable PVE remains and raw response content is not displayed',
+ );
+ }
+ await capture('pbs-invalid');
+
+ sources.pbs = pbsPayload;
+ sources.pve = { data: { ...emptyPVE.data, backupTasks: {} } };
+ await load();
+ if (phase === 'baseline') {
+ await page.getByRole('alert').filter({ hasText: 'Backup view failed' }).waitFor();
+ assert.equal(await rows().count(), 0);
+ checks.push(
+ 'HTTP200 non-array PVE tasks removes independently readable PBS evidence at ErrorBoundary',
+ );
+ } else {
+ await notice('PVE').waitFor();
+ await rows().first().waitFor();
+ assert.equal(await rows().count(), 1);
+ await rows().first().getByText('PBS', { exact: true }).waitFor();
+ await rows().first().getByText('Snapshot', { exact: true }).waitFor();
+ assert.equal(await page.getByRole('alert').count(), 0);
+ checks.push('Malformed PVE is source-local and independent PBS evidence remains usable');
+ }
+ await capture('pve-invalid');
+
+ if (phase === 'final') {
+ sources.pve = pvePayload;
+ sources.pbs = { data: { backups: [{ ...snapshot, inProgress: 'true' }] } };
+ await load();
+ await notice('PBS').waitFor();
+ await rows().first().waitFor();
+ assert.equal(
+ await rows().count(),
+ 1,
+ 'a mistyped completion flag must not become recovery evidence',
+ );
+ const before = requests.filter((r) => r.path === '/api/backups/pve').length;
+ sources.pbs = pbsPayload;
+ let release;
+ retryGate = new Promise((resolve) => (release = resolve));
+ const retry = page.getByRole('button', { name: 'Retry PBS inventory', exact: true });
+ if (phone) await retry.tap();
+ else {
+ await retry.focus();
+ await page.keyboard.press('Enter');
+ }
+ assert.equal(await retry.isDisabled(), true);
+ await notice('PBS').waitFor();
+ assert.equal(await rows().count(), 1);
+ release();
+ await page.waitForFunction(
+ () => document.querySelectorAll('[data-proxmox-backup-row="recoverable"]').length === 2,
+ );
+ retryGate = null;
+ assert.equal(await notice('PBS').count(), 0);
+ assert.equal(requests.filter((r) => r.path === '/api/backups/pve').length, before);
+ await capture('recovered');
+ checks.push(
+ 'Mistyped incomplete flag is rejected; warning persists through keyboard/touch isolated retry until valid recovery',
+ );
+
+ sources.pve = { data: { backupTasks: null, storageBackups: null, guestSnapshots: null } };
+ sources.pbs = emptyPBS;
+ await load();
+ await page.getByText('No backups yet', { exact: true }).waitFor();
+ assert.equal(await page.getByRole('status').filter({ hasText: 'inventory' }).count(), 0);
+ checks.push('Explicit Go nil PVE collections remain a valid empty observation');
+ }
+ const overflow = await page.evaluate(
+ () => document.documentElement.scrollWidth > window.innerWidth + 1,
+ );
+ assert.equal(overflow, false);
+ assert.deepEqual(errors, []);
+ assert.deepEqual(offOrigin, []);
+ assert(requests.every((r) => r.method === 'GET'));
+ observations.push({
+ name,
+ browserVersion: browser.version(),
+ viewport,
+ checks,
+ requests,
+ errors,
+ offOrigin,
+ overflow,
+ screenshots,
+ });
+ await browser.close();
+ browser = null;
+ }
+ const runtime = [
+ 'src/features/proxmox/ProxmoxBackupsTable.tsx',
+ 'src/features/proxmox/proxmoxBackupInventory.ts',
+ ].filter((p) => fs.existsSync(path.join(root, p)));
+ fs.writeFileSync(
+ path.join(output, 'result.json'),
+ JSON.stringify(
+ {
+ phase,
+ playwrightVersion,
+ runtimeSha256: Object.fromEntries(runtime.map((p) => [p, sha256(path.join(root, p))])),
+ observations,
+ },
+ null,
+ 2,
+ ) + '\n',
+ );
+ console.log(`${phase}: ${observations.length} browser contexts passed; receipts at ${output}`);
+ } finally {
+ if (browser) await browser.close();
+ await server.close();
+ }
+})().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/frontend-modern/browser-tests/composed-disk-labels.cjs b/frontend-modern/browser-tests/composed-disk-labels.cjs
new file mode 100644
index 0000000000..fe629a89ee
--- /dev/null
+++ b/frontend-modern/browser-tests/composed-disk-labels.cjs
@@ -0,0 +1,313 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const crypto = require('node:crypto');
+const { chromium, webkit } = require('playwright');
+
+const root = '/workspace/frontend-modern';
+const output = '/workspace/tmp/composed-disk-labels-settled';
+const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
+const runtime = [
+ 'frontend-modern/src/components/Workloads/stackedDiskBarModel.ts',
+ 'frontend-modern/src/components/Workloads/StackedDiskBar.tsx',
+ 'frontend-modern/src/components/Workloads/useStackedDiskBarState.ts',
+ 'frontend-modern/src/components/Workloads/GuestRow.tsx',
+ 'frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx',
+ 'frontend-modern/src/components/Workloads/DiskList.tsx',
+ 'frontend-modern/src/utils/workloadGuestPresentation.ts',
+ 'frontend-modern/src/utils/format.ts',
+ 'frontend-modern/src/index.css',
+];
+
+(async () => {
+ fs.mkdirSync(output, { recursive: true });
+ const playwrightVersion = require('playwright/package.json').version;
+ assert.equal(
+ playwrightVersion,
+ JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
+ 'node_modules/@playwright/test'
+ ].version,
+ );
+ const hashes = Object.fromEntries(
+ runtime.map((file) => [file, sha256(fs.readFileSync(path.join('/workspace', file)))]),
+ );
+ assert.equal(
+ hashes[runtime[0]],
+ '9f50f630df8379673032052970e7cc6d287c02ecd212b3df5d16f1032be42413',
+ );
+ process.chdir(root);
+ const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
+ const server = await createServer({
+ root,
+ configFile: path.join(root, 'vite.config.ts'),
+ cacheDir: path.join(output, 'vite-cache'),
+ server: { host: '127.0.0.1', port: 5301, strictPort: true },
+ });
+ const results = [],
+ screenshots = [];
+ let browser;
+ try {
+ await server.listen();
+ for (const [name, engine, width, dark] of [
+ ['chromium-desktop', chromium, 1365, false],
+ ['webkit-phone', webkit, 390, true],
+ ]) {
+ browser = await engine.launch(
+ engine === chromium
+ ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
+ : { headless: true },
+ );
+ const context = await browser.newContext({
+ viewport: { width, height: width === 390 ? 844 : 900 },
+ isMobile: width === 390,
+ hasTouch: width === 390,
+ locale: 'en-GB',
+ timezoneId: 'UTC',
+ });
+ const page = await context.newPage();
+ page.setDefaultTimeout(15000);
+ page.setDefaultNavigationTimeout(60000);
+ const errors = [],
+ requests = [],
+ checks = [],
+ fits = [];
+ page.on('pageerror', (error) => errors.push(error.message));
+ page.on('request', (request) =>
+ requests.push({
+ method: request.method(),
+ pathname: new URL(request.url()).pathname,
+ origin: new URL(request.url()).origin,
+ }),
+ );
+ await page.route('http://127.0.0.1:5301/api/**', (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ success: true,
+ data: [],
+ alerts: [],
+ anomalies: [],
+ config: null,
+ }),
+ }),
+ );
+ await page.addInitScript((value) => {
+ document.addEventListener('DOMContentLoaded', () => {
+ if (value) document.documentElement.classList.add('dark');
+ });
+ }, dark);
+ const capture = async (state) => {
+ const file = path.join(output, `${name}-${state}.png`);
+ await page.screenshot({ path: file, fullPage: true });
+ screenshots.push({
+ path: path.relative('/workspace', file),
+ sha256: sha256(fs.readFileSync(file)),
+ });
+ };
+ const settle = () =>
+ page.evaluate(
+ () =>
+ new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
+ );
+ await page.goto('http://127.0.0.1:5301/browser-tests/composed-disk-labels.html');
+ await page.waitForFunction(() => window.__composedDiskLabels);
+ await settle();
+ const checkFit = async (expectedWidth, roomy) => {
+ await page.waitForFunction(
+ (value) =>
+ Math.abs(
+ document.querySelector('[data-fit="single"]').getBoundingClientRect().width - value,
+ ) < 0.5,
+ expectedWidth,
+ );
+ await settle();
+ const state = await page.evaluate(() => {
+ const boxes = [...document.querySelectorAll('[data-fit]')].map((box) => {
+ const id = box.getAttribute('data-fit');
+ const labels = [...box.querySelectorAll('span.whitespace-nowrap')];
+ return {
+ id,
+ width: box.getBoundingClientRect().width,
+ text: box.innerText,
+ labels: labels.map((label) => ({
+ text: label.textContent,
+ scroll: label.scrollWidth,
+ client: label.clientWidth,
+ })),
+ sublabels: box.querySelectorAll('.metric-sublabel').length,
+ };
+ });
+ return { boxes, presentation: window.__composedDiskLabels.presentation() };
+ });
+ for (const box of state.boxes) {
+ for (const label of box.labels)
+ assert(
+ label.scroll <= label.client + 1,
+ `${name} ${box.id} ${expectedWidth}: rendered label must fit, not be ellipsised`,
+ );
+ }
+ for (const id of ['single', 'anomaly', 'aggregate']) {
+ const box = state.boxes.find((item) => item.id === id);
+ assert.equal(box.sublabels, roomy ? 1 : 0, `${id}: optional detail matches actual width`);
+ }
+ assert.equal(state.presentation.single.displayLabel, '60%');
+ assert.equal(state.presentation.anomaly.anomalyRatio, '2.5x');
+ assert.equal(state.presentation.aggregate.showDiskCount, true);
+ assert.equal(
+ await page.locator('[data-fit="vertical"] [data-stacked-disk-max-label]').innerText(),
+ '67%',
+ );
+ assert.equal(
+ await page.locator('[data-fit="vertical"] [data-stacked-disk-fill="vertical"]').count(),
+ 2,
+ );
+ fits.push({ width: expectedWidth, boxes: state.boxes });
+ };
+ await checkFit(100, false);
+ const compactInline = await page.locator('[data-fit="inline"]').innerText();
+ assert(!compactInline.includes('MMMM'));
+ assert(compactInline.includes('iiii 67%'));
+ await capture('compact');
+ const roomyButton = page.getByRole('button', { name: 'Use roomy bars', exact: true });
+ if (engine === chromium) {
+ await roomyButton.focus();
+ await page.keyboard.press('Enter');
+ } else await roomyButton.tap();
+ await checkFit(320, true);
+ assert((await page.locator('[data-fit="inline"]').innerText()).includes('MMMM 50%'));
+ assert((await page.locator('[data-fit="inline"]').innerText()).includes('iiii 67%'));
+ await capture('roomy');
+ checks.push(
+ 'ResizeObserver drops optional detail at 100px and shows complete un-clipped single/anomaly/max/count and inline labels at 320px; vertical max remains truthful',
+ );
+ if (engine === chromium) {
+ await page.locator('[data-fit="single"] [data-stacked-disk-trigger]').hover();
+ const tip = page.locator('[data-tooltip-portal="true"]');
+ await tip.waitFor({ state: 'visible' });
+ assert((await tip.innerText()).includes('Guest reads paused while a backup is running.'));
+ assert((await tip.innerText()).includes('/data'));
+ await page.locator('h1').hover();
+ await page.getByRole('button', { name: 'Mark observation fresh' }).click();
+ await page.locator('[data-fit="single"] [data-stacked-disk-trigger]').hover();
+ await tip.waitFor({ state: 'visible' });
+ assert(!(await tip.innerText()).includes('Guest reads paused'));
+ assert((await tip.innerText()).includes('/data'));
+ await page.locator('h1').hover();
+ checks.push(
+ 'Caller-owned paused-read tooltip survives measured model; fresh same-disk update removes only status',
+ );
+ }
+ // Exercise the existing production guest callers, but only the combined
+ // stale-to-fresh boundary, not an unchanged eight-reason acceptance run.
+ await page.goto('http://127.0.0.1:5301/browser-tests/guest-disk-deferral.html');
+ await page.waitForFunction(() => window.__guestDiskEvidence);
+ const row = page.locator('[data-guest-id="fixture-pve:pve-a:101"]');
+ const cell = row.locator('[data-workload-col="disk"]');
+ if (engine === chromium) await row.click();
+ else
+ // WebKit's trusted tap emitted no click on this production row in the
+ // separately retained input diagnostic. Check rendering via a mouse
+ // click, without asserting touch acceptance or masking that failure.
+ await row
+ .locator('[data-workload-col="name"]')
+ .getByText('backup-guest', { exact: true })
+ .click();
+ const drawer = page.getByRole('region', { name: 'Guest Overview' });
+ await drawer.waitFor({ state: 'visible' });
+ const paused = await cell.getAttribute('title');
+ assert(paused.startsWith('Using last known disk stats. '));
+ assert(paused.includes('VM operation lock, such as a backup.'));
+ assert((await drawer.innerText()).includes(paused));
+ assert(
+ (await page.getByRole('region', { name: 'Guest disk list' }).innerText()).includes(paused),
+ );
+ assert((await drawer.innerText()).includes('/data'));
+ if (engine === chromium) {
+ await page.locator('h1').hover();
+ await cell.locator('[data-stacked-disk-trigger]').hover();
+ await page.locator('[data-tooltip-portal="true"]').waitFor({ state: 'visible' });
+ assert((await page.locator('[data-tooltip-portal="true"]').innerText()).includes(paused));
+ await page.locator('h1').hover();
+ }
+ await capture('guest-paused');
+ await page.evaluate(() => {
+ window.__composedOriginalRow = document.querySelector(
+ '[data-guest-id="fixture-pve:pve-a:101"]',
+ );
+ window.__guestDiskEvidence.update({ reason: '', data: true, usage: 75 });
+ });
+ await page.waitForFunction(
+ () => !document.querySelector('[data-workload-col="disk"]').hasAttribute('title'),
+ );
+ assert(!(await drawer.innerText()).includes('Using last known disk stats.'));
+ assert.equal(await drawer.getByRole('progressbar').getAttribute('aria-valuenow'), '75');
+ assert.equal(
+ await page.evaluate(
+ () =>
+ window.__composedOriginalRow ===
+ document.querySelector('[data-guest-id="fixture-pve:pve-a:101"]'),
+ ),
+ true,
+ );
+ // AnimatedNumber must finish; do not capture an intermediate old percent
+ // beside the new capacity and claim that it is a settled observation.
+ await page.waitForFunction(() =>
+ document.querySelector('[data-workload-col="disk"]').textContent.includes('75%'),
+ );
+ await capture('guest-fresh');
+ checks.push(
+ 'Production GuestRow, Overview and disk list retain paused provenance; same-VM resumption clears notice and updates to 75% without remount',
+ );
+ assert.equal(
+ await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
+ true,
+ );
+ assert.deepEqual(errors, []);
+ assert(
+ requests.every(
+ (request) => request.origin === 'http://127.0.0.1:5301' && request.method === 'GET',
+ ),
+ );
+ results.push({
+ name,
+ browserVersion: browser.version(),
+ viewport: { width, height: width === 390 ? 844 : 900 },
+ dark,
+ checks,
+ fits,
+ errors,
+ requestCount: requests.length,
+ });
+ await context.close();
+ await browser.close();
+ browser = null;
+ }
+ fs.writeFileSync(
+ path.join(output, 'result.json'),
+ JSON.stringify(
+ {
+ result: 'passed',
+ verifiedAt: new Date().toISOString(),
+ playwrightVersion,
+ runtimeHashes: hashes,
+ results,
+ screenshots,
+ limitations:
+ 'Synthetic component/CSS/browser composition only; not native QGA, thaw, appliance acceptance, installation, release qualification or availability.',
+ },
+ null,
+ 2,
+ ),
+ );
+ process.stdout.write(
+ `COMPOSED_DISK_LABELS_PASSED ${results.length} browsers / ${screenshots.length} screenshots\n`,
+ );
+ } finally {
+ if (browser) await browser.close();
+ await server.close();
+ }
+})().catch((error) => {
+ process.stderr.write(`${error.stack}\n`);
+ process.exitCode = 1;
+});
diff --git a/frontend-modern/browser-tests/composed-disk-labels.html b/frontend-modern/browser-tests/composed-disk-labels.html
new file mode 100644
index 0000000000..1f5e9a26e6
--- /dev/null
+++ b/frontend-modern/browser-tests/composed-disk-labels.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Composed disk label verification
+
+
+
+
+
+
diff --git a/frontend-modern/browser-tests/composed-disk-labels.tsx b/frontend-modern/browser-tests/composed-disk-labels.tsx
new file mode 100644
index 0000000000..aff9bbdfce
--- /dev/null
+++ b/frontend-modern/browser-tests/composed-disk-labels.tsx
@@ -0,0 +1,93 @@
+// Production disk model, shell, ResizeObserver and CSS; synthetic observations.
+// This fixture verifies composition, not collection, thaw or native recovery.
+import { createSignal, For } from 'solid-js';
+import { render } from 'solid-js/web';
+import { StackedDiskBar } from '../src/components/Workloads/StackedDiskBar';
+import {
+ buildStackedDiskBarPresentation,
+ type StackedDiskBarProps,
+} from '../src/components/Workloads/stackedDiskBarModel';
+import type { Disk } from '../src/types/api';
+import type { AnomalyReport } from '../src/types/aiIntelligence';
+import '../src/index.css';
+
+const GiB = 1024 ** 3;
+const disk = (mountpoint: string, total: number, used: number): Disk => ({
+ mountpoint,
+ total: total * GiB,
+ used: used * GiB,
+ free: (total - used) * GiB,
+ usage: (used / total) * 100,
+ type: 'ext4',
+});
+const single = [disk('/data', 440, 265)];
+const multiple = [disk('/MMMM', 180, 90), disk('/iiii', 260, 175)];
+const anomaly: AnomalyReport = {
+ resource_id: 'fixture-pve:pve-a:101',
+ resource_name: 'backup-guest',
+ resource_type: 'vm',
+ metric: 'disk',
+ current_value: 60,
+ baseline_mean: 24,
+ baseline_std_dev: 4,
+ z_score: 9,
+ severity: 'high',
+ description: 'Synthetic disk anomaly',
+};
+const cases: { id: string; label: string; props: StackedDiskBarProps }[] = [
+ { id: 'single', label: 'Single filesystem', props: { disks: single } },
+ { id: 'anomaly', label: 'Filesystem with anomaly', props: { disks: single, anomaly } },
+ {
+ id: 'aggregate',
+ label: 'Aggregate with fullest disk and count',
+ props: { disks: multiple, mode: 'aggregate', showDiskCount: true },
+ },
+ { id: 'inline', label: 'Equal slots, different glyph widths', props: { disks: multiple } },
+ {
+ id: 'vertical',
+ label: 'Host micro-bars and fullest disk',
+ props: { disks: multiple, mode: 'vertical-bars' },
+ },
+];
+
+function Fixture() {
+ const [width, setWidth] = createSignal(100);
+ const [status, setStatus] = createSignal(
+ 'Using last known disk stats. Guest reads paused while a backup is running.',
+ );
+ (window as any).__composedDiskLabels = {
+ width: setWidth,
+ presentation: () =>
+ Object.fromEntries(
+ cases.map((item) => [item.id, buildStackedDiskBarPresentation(item.props, width())]),
+ ),
+ };
+ return (
+
+ Composed disk evidence
+
+
+
+
+
+
+ {(item) => (
+
+ )}
+
+
+ );
+}
+
+render(() => , document.getElementById('root')!);
diff --git a/frontend-modern/browser-verification/78c9d8427bdfa307.json b/frontend-modern/browser-verification/78c9d8427bdfa307.json
new file mode 100644
index 0000000000..099102e7a1
--- /dev/null
+++ b/frontend-modern/browser-verification/78c9d8427bdfa307.json
@@ -0,0 +1,53 @@
+{
+ "version": 1,
+ "base_sha": "f5925942f49b088689695678b98e703af9ead9ef",
+ "verified_at": "2026-10-03T21:03:10.375831Z",
+ "result": "passed",
+ "changed_paths": [
+ "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx",
+ "frontend-modern/src/features/proxmox/proxmoxBackupInventory.ts"
+ ],
+ "content_sha256": {
+ "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "8e1113c7c9e263d284c86e4c3373381b3d71f6a0161897d9dcc782ce225478d5",
+ "frontend-modern/src/features/proxmox/proxmoxBackupInventory.ts": "deccbf62dcdbafd447cca0f33b516bf98fddb8c726515a30b85b614d26a37095"
+ },
+ "routes": [
+ "/browser-tests/backup-inventory.html?view=date"
+ ],
+ "viewports": [
+ {
+ "width": 1365,
+ "height": 900
+ },
+ {
+ "width": 390,
+ "height": 844
+ }
+ ],
+ "states": [
+ "HTTP200 missing PBS envelope stays incomplete, never no-backups/zero",
+ "Malformed PBS/PVE collections preserve independent source and server health without a rendering failure",
+ "Mistyped incomplete flags are unavailable, not completed restore points",
+ "Explicit Go nil PVE collections remain valid empty observations"
+ ],
+ "interactions": [
+ "Desktop Enter and phone touch isolated retry keep warnings and independent rows while pending, clear only after validated success",
+ "Eight settled final screenshots and two baseline screenshots opened and inspected; light desktop/dark phone warnings and rows readable, no page overflow"
+ ],
+ "playwright_version": "1.56.1",
+ "browser_versions": [
+ {
+ "engine": "chromium-desktop",
+ "version": "141.0.7390.37"
+ },
+ {
+ "engine": "webkit-phone",
+ "version": "26.0"
+ }
+ ],
+ "dependency_snapshot": "8aaf02a3db8e1c245174d04d132398d1716a69525064975470ff738d168abb82",
+ "limitations": [
+ "Synthetic source responses through production table/router/API/CSS, not native provider collection, permission, restore, QGA/thaw or installed/released acceptance.",
+ "Production/embed build is separately source-proved; no new route, polling, source cache or authority."
+ ]
+}
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/e3b706f9d975b05a.json b/frontend-modern/browser-verification/e3b706f9d975b05a.json
new file mode 100644
index 0000000000..463b1880c3
--- /dev/null
+++ b/frontend-modern/browser-verification/e3b706f9d975b05a.json
@@ -0,0 +1,37 @@
+{
+ "version": 1,
+ "base_sha": "ffac59b6dcccaf03f52fb4ba747b6f99a79ba9b0",
+ "verified_at": "2026-10-03T22:38:46.141Z",
+ "result": "passed",
+ "changed_paths": [
+ "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts"
+ ],
+ "content_sha256": {
+ "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts": "9f50f630df8379673032052970e7cc6d287c02ecd212b3df5d16f1032be42413"
+ },
+ "routes": [
+ "/browser-tests/composed-disk-labels.html",
+ "/browser-tests/guest-disk-deferral.html"
+ ],
+ "viewports": [
+ {
+ "width": 1365,
+ "height": 900
+ },
+ {
+ "width": 390,
+ "height": 844
+ }
+ ],
+ "states": [
+ "Exact combined deferred-read statusMessage and measured-glyph disk model; production StackedDiskBar, ResizeObserver, GuestRow, Overview, disk list and stylesheet, synthetic observations only. Playwright 1.56.1 matches the integration lock; Chromium 141.0.7390.37 light desktop and WebKit 26.0 dark 390px phone.",
+ "100px bars retain percentages and omit optional capacity detail; equal inline slots retain iiii 67% but drop the wider MMMM label. 320px bars show whole capacity, anomaly, fullest-disk/count and inline labels without ellipsis. The micro-bars keep a truthful 67% highest-disk label.",
+ "Paused guest evidence retains last-known disk values and caller-owned VM-operation-lock context in row, Overview, disk list and desktop tooltip. Same-VM fresh observations clear stale context without remount; row, Overview and disk list settle at 75%. All eight final screenshots inspected.",
+ "Phone drawer rendering is verified using an emulated mouse click on the guest name. Separate trusted WebKit row/name taps emitted no click and did not open the drawer; this pass does not establish touch acceptance or clear that adverse observation. No native collection, QGA/thaw, installation or release claim."
+ ],
+ "interactions": [
+ "Chromium keyboard focus and Enter / WebKit tap on the native Roomy button drive ResizeObserver from 100px to 320px. Independent DOM scrollWidth/clientWidth checks verify rendered label fit.",
+ "Desktop hover reads paused provenance beside unchanged /data evidence, then confirms fresh same-disk update removes only that status. Production GuestRow/Overview/list callers are exercised at both viewports; the phone guest is opened by mouse click, not a claimed tap success.",
+ "Fresh source update preserves the same row node, clears stale status, updates the Overview progressbar and waits for AnimatedNumber to settle at 75% before capture. No page error, off-origin request, non-GET request or horizontal page overflow."
+ ]
+}
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/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/features/proxmox/ProxmoxBackupsTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx
index e3d51a93b9..c0bf4513ea 100644
--- a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx
+++ b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx
@@ -29,9 +29,7 @@ import type {
GuestSnapshot,
PBSBackup,
PBSBackupsPayload,
- PBSBackupsResponse,
PVEBackupsPayload,
- PVEBackupsResponse,
StorageBackup,
} from '@/types/api';
import type { Resource } from '@/types/resource';
@@ -70,6 +68,12 @@ import { ProxmoxBackupsCoverageStrip } from './ProxmoxBackupsCoverageStrip';
import { ProxmoxBackupServersTable } from './ProxmoxBackupServersTable';
import { ProxmoxCoverageTable } from './ProxmoxCoverageTable';
import { ProxmoxRecoverableTable } from './ProxmoxRecoverableTable';
+import {
+ BackupInventoryFormatError,
+ parsePBSBackupInventory,
+ parsePVEBackupInventory,
+ readBackupInventoryJSON,
+} from './proxmoxBackupInventory';
// One backups surface, two operator views: a chronological recoverable-artifact
// feed for "what ran when", and a guest coverage table for "what is protected".
@@ -97,8 +101,7 @@ async function fetchPVEBackups(signal: AbortSignal): Promise
status: response.status,
});
}
- const payload = (await response.json()) as PVEBackupsResponse;
- return payload?.data ?? EMPTY_PVE_BACKUPS;
+ return parsePVEBackupInventory(await readBackupInventoryJSON(response));
}
async function fetchPBSBackups(signal: AbortSignal): Promise {
@@ -108,8 +111,7 @@ async function fetchPBSBackups(signal: AbortSignal): Promise
status: response.status,
});
}
- const payload = (await response.json()) as PBSBackupsResponse;
- return payload?.data ?? EMPTY_PBS_BACKUPS;
+ return parsePBSBackupInventory(await readBackupInventoryJSON(response));
}
export const ProxmoxBackupsTable: Component<{
@@ -702,6 +704,11 @@ export const ProxmoxBackupsTable: Component<{
{label} backup inventory is {query.error() ? 'unavailable' : 'loading'}. Counts
are incomplete. {getAPIReadAccessErrorMessage(query.error())}
+
+ {' '}
+ The response format is invalid. Missing restore points do not mean no backups
+ exist.
+