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) => ( +
+

{item.label}

+
+ +
+
+ )} +
+
+ ); +} + +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. +