From e899ecb0cf2ad61535a60aafaea7932cde9bef97 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:32:20 +0100 Subject: [PATCH] Keep backup evidence and rollback consent truthful on narrow screens Compose the useful Kubernetes, storage and Proxmox table improvements from four proposals against current main, preserving organisation-safe History and newer recovery/drawer work. A fresh guest snapshot no longer masquerades as the latest independent backup; controller timestamps and full disk health reasons remain reachable from phone rows. History consent names the running version separately and declares missing restore-scope metadata rather than promising an active-data rewind. Distinguish installation-only and legacy recovery and link the stopped-service full-state instructions. Add substantive dependent contracts, connected tests and a fresh production-component desktop/phone browser receipt, including the SMART wrapping correction found during validation. Original-PR: https://github.com/rcourtman/Pulse/pull/2417 Original-PR: https://github.com/rcourtman/Pulse/pull/2418 Original-PR: https://github.com/rcourtman/Pulse/pull/2421 Original-PR: https://github.com/rcourtman/Pulse/pull/2425 Original-Commit: 3149ec61d8ee063bc99f3405f509bc2fe25d58d3 Original-Commit: c02b2d81ccee3fb5b4c9df1aa74f65c49d39eced Original-Commit: d33d6a4fd347b73161158c01a504e388fa975f5d Original-Commit: 572ea0158b7911bdef9e3405608c7a631b3d7648 Original-Commit: c5e89849bdec09ff4eef5f198e57654d8e16dfc2 Original-Commit: 3f2715e3f3f9b563ad3f119ba0d302b488b97e8a Original-Commit: 852ebdca080535c10b2d10dd39465953cd47b8ef Original-Commit: 083cd070fe0da5a473d9c7cb3366eab163c984ff Co-authored-by: rcourtman <8825017+rcourtman@users.noreply.github.com> Change-source: pulse-maintainer --- .../subsystems/deployment-installability.md | 17 + .../subsystems/frontend-primitives.md | 30 ++ .../subsystems/performance-and-scalability.md | 10 + .../internal/subsystems/storage-recovery.md | 22 + .../internal/subsystems/unified-resources.md | 17 + .../browser-tests/operator-tables-consent.cjs | 419 ++++++++++++++++++ .../operator-tables-consent.html | 12 + .../browser-tests/operator-tables-consent.tsx | 399 +++++++++++++++++ frontend-modern/browser-verification.json | 101 +++-- .../Infrastructure/ResourceDetailSummary.tsx | 2 + ...esourceDetailDrawerKubernetesModel.test.ts | 204 ++++++++- .../resourceDetailDrawerKubernetesModel.ts | 149 +++++++ .../Settings/UpdateHistorySection.tsx | 33 +- .../__tests__/UpdateHistorySection.test.tsx | 80 +++- .../src/components/Storage/DiskDetail.tsx | 44 +- .../src/components/Storage/DiskList.tsx | 56 ++- .../components/Storage/EnhancedStorageBar.tsx | 23 +- .../src/components/Storage/StoragePoolRow.tsx | 7 +- .../components/Storage/StoragePoolsTable.tsx | 6 +- .../Storage/__tests__/DiskDetail.test.tsx | 29 +- .../Storage/__tests__/DiskList.test.tsx | 16 +- .../Storage/__tests__/Storage.test.tsx | 5 +- .../useEnhancedStorageBarModel.test.ts | 1 + .../components/Storage/useDiskDetailModel.ts | 3 + .../Storage/useEnhancedStorageBarModel.ts | 3 + .../Workloads/__tests__/GuestRow.test.tsx | 21 +- .../components/Workloads/guestRowModel.tsx | 15 +- .../kubernetes/KubernetesControllersTable.tsx | 25 +- .../KubernetesControllersTable.test.tsx | 126 +++++- .../proxmox/ProxmoxBackupServersTable.tsx | 32 +- .../features/proxmox/ProxmoxBackupsTable.tsx | 14 +- .../features/proxmox/ProxmoxCoverageTable.tsx | 39 +- .../proxmox/ProxmoxRecoverableTable.tsx | 10 +- .../__tests__/ProxmoxCoverageTable.test.tsx | 4 +- .../ProxmoxRecoverableTable.test.tsx | 5 +- ...oxmoxBackupRecoveryModel.branchcov.test.ts | 2 +- .../proxmoxBackupRecoveryModel.test.ts | 70 ++- .../proxmoxBackupsTablePresentation.test.ts | 7 +- .../proxmoxBackupsTableShared.test.tsx | 24 + .../__tests__/proxmoxHostTableModel.test.ts | 12 +- .../proxmox/proxmoxBackupRecoveryModel.ts | 35 +- .../proxmoxBackupsTablePresentation.ts | 30 +- .../proxmox/proxmoxBackupsTableShared.tsx | 24 +- .../features/proxmox/proxmoxHostTableModel.ts | 33 +- .../__tests__/diskDetailPresentation.test.ts | 36 ++ .../__tests__/diskPresentation.test.ts | 57 ++- .../rowPresentation.branchcov0712b.test.ts | 4 +- .../rowPresentation.branchcov2.test.ts | 4 +- .../__tests__/rowPresentation.test.ts | 41 ++ .../__tests__/storageBarPresentation.test.ts | 2 + .../__tests__/storagePagePresentation.test.ts | 41 +- .../storageBackups/diskDetailPresentation.ts | 32 +- .../storageBackups/diskPresentation.ts | 43 +- .../storageBackups/rowPresentation.ts | 33 +- .../storageBackups/storageBarPresentation.ts | 7 +- .../storageBackups/storagePagePresentation.ts | 40 +- 56 files changed, 2338 insertions(+), 218 deletions(-) create mode 100644 frontend-modern/browser-tests/operator-tables-consent.cjs create mode 100644 frontend-modern/browser-tests/operator-tables-consent.html create mode 100644 frontend-modern/browser-tests/operator-tables-consent.tsx diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 932168a995..fa92cf9255 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -15,6 +15,23 @@ ## Purpose +### Update History consent does not infer the backup's restore scope + +The confirmation names the observed running server separately from the selected +entry's pre-/post-update versions. No version threshold or free-form notes stand +in for actual scope: the current History API does not describe the manifest or +active-store layout. Explain that installation-only recovery leaves active stores +in place but may restore independent installation files; legacy recovery can +also replace install-local configuration/data. Do not promise a settings/alerts +rewind. Full-state recovery still requires a stopped service and a consistent +backup of every active store, via the existing shipped recovery instructions. + +Opening/cancelling consent sends no rollback request, pending consent rejects +duplicate actions and cancellation, and a rejected request retains the warning +and refreshes history. Mounted UpdateHistory tests and the production-CSS offline +browser fixture exercise those states; neither proves a binary/data rollback. + + ### Demo transactions share the service host, not just a workflow queue Stable demo update and recovery keep their existing publication/activation, diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 7f1fbe6186..930119719d 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -20,6 +20,36 @@ ## Purpose +### Composed operator tables and conservative rollback consent + +Phone storage tables use the canonical weighted-width helper and matching +header/cell gutters. Compact labels shorten disk verdicts and capacity values, +not their underlying health or measured units; full reasons stay in the real +disk header and tooltips. Structured ZFS scan activity supplies a compact badge +only during reported rebuild activity; its complete provider summary remains +available. Stable disk History catalog/organisation/access ownership is unchanged. + +Controller expansions carry the supplied kind-specific targets, absolute times, +duration and cluster/namespace when the phone row omits those columns. Proxmox +coverage distinguishes independent backups from guest-local snapshots; By date +retains archive identifiers in hover/search while showing the format as detail. +Phone identity weights leave every configured metric column visible. + +UpdateHistory consent names both the requested target and the observed running +version, separately from the version on the selected history entry. The API does +not expose backup restore scope: do not infer it from a version cutoff, notes or +a retained path. Explain installation-only versus legacy recovery and stopped, +consistent full-state recovery without promising later settings will be reverted +or preserved. Opening/cancelling never starts a write, rejection retains consent, +and a pending request cannot be submitted again or cancelled. + +Verification: the mounted UpdateHistory regression tests, existing kind-specific +controller/storage/backup/workload tests and production-CSS browser fixture +`frontend-modern/browser-tests/operator-tables-consent.cjs` exercise the composed +source at desktop, intermediate and phone widths. Synthetic HTTP/snapshots do +not establish native collectors, tenancy, installation recovery or release delivery. + + ### Organisation-scoped single-metric History Fetched HistoryChart observations belong to the active organisation as well as 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 80cd898e5a..045e6db6a8 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -15,6 +15,16 @@ ## Purpose +### Phone workload identity weights + +The existing phone guest projection allocates 38 percent to the name cell, +which includes disclosure, status and backup controls, while normalising every +remaining visible metric weight. Narrow/mobile/desktop modes and windowing +ownership remain unchanged. Guest column regressions verify the sums and the +production composed-table browser fixture checks identity and all configured +metric tracks at narrow width; this is readability, not a throughput claim. + + ### Bounded confidence/priority work in broadcast grouping — issue #2199 The general identity matcher retains every confidence, reason, ordering and diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index c0a85bd7b9..6726bf78c3 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -21,6 +21,28 @@ ## Purpose +### Backup identity, phone storage and History consent composition + +The Proxmox Coverage last-backup pointer selects completed PBS snapshots or PVE +backup files, never guest-local snapshots. Guest snapshots remain listed as +restore evidence but cannot make an old independent backup look current. +Archive detail uses its format (or filename fallback), preserving the full +provider volid for hover and search in both Coverage and By date. + +Phone pool/disk rows preserve health and identity while using weighted widths, +consistent gutters and compact capacity/health labels. Full disk risk reasons +move into the existing expanded header; the ZFS activity badge uses supplied +scan data only when rebuild is reported and keeps the full summary as its title. +Collection-independent, organisation-owned disk History remains intact. + +The UpdateHistory confirmation is a version-rollback consent, not a full-state +recovery promise. Its current API exposes no verified restore-scope metadata; +copy must retain installation-only and legacy differences and direct full-state +recovery to the stopped-service procedure. Regressions cover these distinctions, +archive/snapshot attribution and storage values; the composed browser fixture +checks real table/drawer rendering, not native or installed recovery. + + ### Disk History is independent of current collection The existing physical-disk History catalog is selected by disk family, not by diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 975c62f408..93e6fa2f6b 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -23,6 +23,23 @@ and sort the complete canonical change table while startup and ingestion wait. ## Purpose +### Narrow controller and backup summaries retain their source facts + +Kubernetes controller row expansions expose supplied kind-specific targets, +counts, absolute timestamps, valid elapsed durations and namespace/cluster when +responsive rows hide those columns. Missing facts are omitted and the ordinary +pluralisation model handles singular targets. Job/CronJob timestamp regressions +and the production composed-table browser fixture verify this existing summary. + +Proxmox Coverage uses latestBackup for completed independent backup evidence, +with guest-local snapshots retained separately. Responsive backup-server, host +and guest rows allocate space through existing canonical weighted-width helpers; +archive format is presentation only, with the provider identifier retained in +hover and search. The recovery-model, table and guest regressions plus desktop/ +phone browser rendering verify attribution, visible facts and source composition. +These frontend improvements change no collector, API authority or native status. + + ### Resource evidence fallback access boundaries When the existing resource-facet reader receives a final HTTP 401/403, its diff --git a/frontend-modern/browser-tests/operator-tables-consent.cjs b/frontend-modern/browser-tests/operator-tables-consent.cjs new file mode 100644 index 0000000000..606dafe4be --- /dev/null +++ b/frontend-modern/browser-tests/operator-tables-consent.cjs @@ -0,0 +1,419 @@ +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'); +(async () => { + const root = '/workspace/frontend-modern'; + const output = '/workspace/tmp/operator-tables-proof'; + fs.mkdirSync(output, { recursive: true }); + process.chdir(root); + const playwright = require('playwright/package.json').version; + assert.equal( + playwright, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + const paths = JSON.parse(fs.readFileSync(path.join(output, 'runtime-paths.json'))); + const hashes = Object.fromEntries( + paths.map((p) => [ + p, + crypto + .createHash('sha256') + .update(fs.readFileSync(path.join('/workspace', p))) + .digest('hex'), + ]), + ); + 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: 5271, strictPort: true }, + }); + const results = [], + screens = []; + let browser; + try { + await server.listen(); + for (const [name, engine, width] of [ + ['desktop', chromium, 1365], + ['phone', webkit, 390], + ['intermediate', chromium, 768], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: { width, height: width === 390 ? 844 : 900 }, + isMobile: width === 390, + hasTouch: width === 390, + locale: 'en-GB', + timezoneId: 'UTC', + }); + await page.clock.setFixedTime(new Date('2026-10-03T12:00:00Z')); + const errors = [], + offOrigin = [], + checks = [], + requests = []; + let rollbackMode = 'reject', + finishRollback; + page.on('pageerror', (e) => errors.push(e.message)); + page.setDefaultTimeout(20000); + await page.route('**/*', async (route) => { + const url = new URL(route.request().url()); + if (url.origin !== 'http://127.0.0.1:5271') { + offOrigin.push(url.origin); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (url.pathname === '/api/updates/history') + return route.fulfill({ + json: [ + { + event_id: 'fixture-update-1', + timestamp: '2026-10-02T12:00:00Z', + action: 'update', + channel: 'stable', + version_from: '6.4.5', + version_to: '6.4.6', + deployment_type: 'systemd', + initiated_by: 'user', + initiated_via: 'ui', + status: 'success', + duration_ms: 30000, + backup_path: '/private/fixture-backup', + notes: 'untrusted restore-everything note', + }, + ], + }); + if (url.pathname === '/api/version') + return route.fulfill({ + json: { + version: '6.6.0-dev', + build: 'synthetic', + runtime: 'community', + isDocker: false, + isSourceBuild: true, + isDevelopment: false, + }, + }); + if (url.pathname === '/api/updates/rollback') { + requests.push({ method: route.request().method(), body: route.request().postDataJSON() }); + if (rollbackMode === 'hold') + await new Promise((resolve) => { + finishRollback = resolve; + }); + return rollbackMode === 'accept' + ? route.fulfill({ + json: { status: 'accepted', message: 'Synthetic acceptance, not recovery' }, + }) + : route.fulfill({ + status: 409, + json: { error: 'Synthetic incompatible backup scope' }, + }); + } + if (url.pathname === '/api/license/runtime-capabilities') + return route.fulfill({ + json: { + capabilities: [], + limits: [], + max_history_days: 7, + hosted_mode: false, + runtime: { build: 'community' }, + blocked_capabilities: [], + }, + }); + if (url.pathname === '/api/license/entitlements') + return route.fulfill({ json: { entitlements: [] } }); + if (url.pathname.includes('/facets')) + return route.fulfill({ + json: { facets: [], capabilities: [], relationships: [], recentChanges: [] }, + }); + if (url.pathname === '/api/security/status') + return route.fulfill({ + json: { + hasAuthentication: true, + requiresAuth: true, + settingsCapabilities: { authenticationWrite: true }, + }, + }); + if (url.pathname === '/api/metrics-store/history') + return route.fulfill({ json: { points: [], metrics: {}, source: 'store' } }); + return route.fulfill({ + json: { + enabled: false, + data: [], + alerts: {}, + facets: [], + capabilities: [], + relationships: [], + recentChanges: [], + }, + }); + }); + const check = async (label, run) => { + try { + await run(); + checks.push({ label, passed: true }); + } catch (e) { + checks.push({ label, passed: false, error: e.message }); + } + }; + const select = async (name) => { + await page + .getByRole('navigation', { name: 'Fixture views' }) + .getByRole('button', { name, exact: true }) + .click(); + }; + const capture = async (state) => { + // Await the production entrance animation instead of capturing translucent frames. + if (await page.getByRole('dialog').count()) { + await page.waitForFunction(() => { + const panel = document.querySelector('[role=dialog]'); + return panel && Number(getComputedStyle(panel).opacity) >= 0.999; + }); + } + await page.waitForTimeout(100); // ResizeObserver layout, not freshness proof. + const p = `${name}-${state}.png`; + await page.screenshot({ path: path.join(output, p), fullPage: true }); + const dimensions = await page.evaluate(() => ({ + width: innerWidth, + scroll: document.documentElement.scrollWidth, + })); + assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions)); + screens.push({ file: p, state, browser: name, width, dimensions }); + }; + await page.goto('http://127.0.0.1:5271/browser-tests/operator-tables-consent.html', { + waitUntil: 'domcontentloaded', + timeout: 120000, + }); + if (width === 390) await page.evaluate(() => document.documentElement.classList.add('dark')); + await check('Pool state and measured capacity survive the phone projection', async () => { + const pool = page.getByRole('region', { name: 'Pool table' }); + await pool.getByText('archive-tank', { exact: true }).waitFor(); + await pool.getByText('Degraded', { exact: true }).waitFor(); + const state = pool.locator('tbody td[data-storage-column="state"]').first(); + const bounds = await state.evaluate((e) => ({ + width: e.clientWidth, + scroll: e.scrollWidth, + text: e.textContent, + })); + assert.ok(bounds.scroll <= bounds.width + 1, JSON.stringify(bounds)); + if (width >= 544) { + await pool.getByText('Resilvering 45%', { exact: true }).waitFor(); + await pool.getByText('Scrubbing 45%', { exact: true }).waitFor(); + assert.ok( + await pool + .locator('[title="ZFS pool archive-tank resilver is running (45.2%)"]') + .count(), + ); + } else { + assert.ok((await pool.getByText('59%', { exact: true }).count()) >= 2); + } + await capture('pools'); + }); + if (width !== 768) { + await select('Disks'); + await check('Disk row and expanded header retain the full SMART reason', async () => { + await page.getByText('Archive HDD', { exact: true }).waitFor(); + await page + .locator('[data-row-id="disk-sda"]') + .getByText('Archive HDD', { exact: true }) + .click(); + const health = page.getByTestId('disk-detail-health'); + await health.getByText('Replace Now', { exact: true }).waitFor(); + assert.ok( + (await health.innerText()).includes('SMART failed. The full reason remains readable'), + ); + const bounds = await health.evaluate((e) => ({ + width: e.clientWidth, + scroll: e.scrollWidth, + })); + assert.ok(bounds.scroll <= bounds.width + 1, JSON.stringify(bounds)); + await capture('disk-reason'); + }); + await select('Controllers'); + await check( + 'Job target and absolute started/completed times are reachable from a narrow row', + async () => { + await page.getByText('nightly-import', { exact: true }).click(); + const details = page.getByTestId('resource-kubernetes-controller-section'); + await details.getByText('Started', { exact: true }).waitFor(); + await details.getByText('Completed', { exact: true }).waitFor(); + await details.getByText('5m', { exact: true }).waitFor(); + await details.getByText('1 completion', { exact: true }).waitFor(); + assert.ok((await details.innerText()).includes('2026')); + await capture('job-times'); + }, + ); + await check( + 'CronJob retains last-run, last-success, schedule and suspension in its expansion', + async () => { + await page.getByText('billing-rollup', { exact: true }).click(); + const details = page.getByTestId('resource-kubernetes-controller-section'); + await details.getByText('Last run', { exact: true }).waitFor(); + await details.getByText('Last success', { exact: true }).waitFor(); + await details.getByText('*/5 * * * *', { exact: true }).waitFor(); + await details.getByText('Suspended', { exact: true }).waitFor(); + await capture('cron-times'); + }, + ); + } + await select('Backups'); + await check( + 'Coverage last backup remains older than a fresh local guest snapshot', + async () => { + const coverage = page.locator('[data-proxmox-backups-table="coverage"]'); + await coverage.waitFor(); + const columns = await coverage + .locator('colgroup col') + .evaluateAll((els) => els.map((e) => e.dataset.proxmoxBackupsColumn)); + const row = coverage.locator('tr[data-proxmox-backup-row=coverage]'); + const backupAge = await row.locator('td').nth(columns.indexOf('latest')).innerText(); + assert.ok(backupAge.includes('13d'), backupAge); + assert.ok(!backupAge.includes('30m'), backupAge); + if (columns.includes('snapshot')) { + assert.ok( + (await row.locator('td').nth(columns.indexOf('snapshot')).innerText()).includes( + '30m', + ), + ); + } + await capture('backup-attribution'); + }, + ); + await check( + 'By date keeps archive format visible and full identifier available at wide widths', + async () => { + const byDate = page.locator('[data-proxmox-backups-table="recoverable"]'); + await byDate.waitFor(); + if (width >= 1120) { + await byDate.getByText('tar.zst', { exact: true }).waitFor(); + assert.ok( + await byDate + .locator('[title="local:backup/vzdump-lxc-310-2026_09_20-02_00_00.tar.zst"]') + .count(), + ); + } + assert.ok((await page.getByText('artifact-cache-310', { exact: true }).count()) >= 2); + }, + ); + if (width !== 768) { + await select('Consent'); + await check( + 'Consent separates running/backup versions and does not promise active-data restoration', + async () => { + const action = page.getByRole('button', { name: 'Roll back', exact: true }); + await action.focus(); + await page.keyboard.press('Enter'); + const dialog = page.getByRole('dialog', { name: 'Confirm rollback' }); + await dialog.getByText('Roll back to Pulse v6.4.5?', { exact: true }).waitFor(); + await dialog.getByText('Running now: Pulse v6.6.0-dev.', { exact: true }).waitFor(); + const text = await dialog.innerText(); + assert.ok(text.includes('Restore scope is not reported for this backup.')); + assert.ok(text.includes('Older recovery can also replace install-local data')); + assert.ok(text.includes('Do not assume this will undo settings or alerts.')); + assert.ok(!text.includes('/private/fixture-backup')); + assert.ok(!text.includes('untrusted restore-everything note')); + assert.equal( + await dialog + .getByRole('link', { name: 'Read the recovery instructions' }) + .getAttribute('href'), + '/docs/AUTO_UPDATE#manual-rollback', + ); + assert.equal(requests.length, 0); + await capture('consent'); + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); + assert.equal(await page.getByRole('dialog').count(), 0); + assert.equal(requests.length, 0); + assert.equal(await action.evaluate((e) => e === document.activeElement), true); + }, + ); + await check( + 'Rejection retains consent and pending submission cannot be duplicated or cancelled', + async () => { + await page.getByRole('button', { name: 'Roll back', exact: true }).click(); + await page.getByRole('button', { name: 'Roll back to v6.4.5', exact: true }).click(); + await page + .getByText('Synthetic incompatible backup scope', { exact: true }) + .first() + .waitFor(); + assert.equal(requests.length, 1); + assert.ok( + (await page.getByRole('dialog').innerText()).includes( + 'Restore scope is not reported', + ), + ); + await capture('consent-rejected'); + rollbackMode = 'hold'; + await page.getByRole('button', { name: 'Roll back to v6.4.5', exact: true }).click(); + await page.waitForFunction( + () => document.querySelector('button:disabled')?.textContent, + ); + assert.ok( + await page + .getByRole('button', { name: 'Starting rollback...', exact: true }) + .isDisabled(), + ); + assert.ok(await page.getByRole('button', { name: 'Cancel', exact: true }).isDisabled()); + await page.keyboard.press('Escape'); + assert.equal(await page.getByRole('dialog').count(), 1); + while (!finishRollback) await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(requests.length, 2); + rollbackMode = 'accept'; + finishRollback(); + await page.getByRole('dialog').waitFor({ state: 'hidden' }); + assert.deepEqual(requests, [ + { method: 'POST', body: { eventId: 'fixture-update-1' } }, + { method: 'POST', body: { eventId: 'fixture-update-1' } }, + ]); + }, + ); + } + await check('No uncaught application errors or external requests', async () => { + assert.deepEqual(errors, []); + assert.deepEqual(offOrigin, []); + }); + results.push({ + name, + width, + browser: browser.version(), + checks, + errors, + offOrigin, + rollbackRequests: requests, + }); + await page.close(); + await browser.close(); + browser = undefined; + } + } finally { + if (browser) await browser.close(); + await server.close(); + } + const result = { + result: results.every((r) => r.checks.every((c) => c.passed)) ? 'passed' : 'failed', + playwright, + base_sha: '697ad83e19fa5e8afee634c212c945600d2292b4', + source_content_sha256: hashes, + results, + screens, + }; + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2)); + console.log( + JSON.stringify({ + result: result.result, + checks: results.flatMap((r) => r.checks).length, + failed: results.flatMap((r) => r.checks.filter((c) => !c.passed)), + screens: screens.length, + }), + ); + if (result.result !== 'passed') process.exitCode = 1; +})().catch((e) => { + console.error(e); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/operator-tables-consent.html b/frontend-modern/browser-tests/operator-tables-consent.html new file mode 100644 index 0000000000..4aabdd1bcb --- /dev/null +++ b/frontend-modern/browser-tests/operator-tables-consent.html @@ -0,0 +1,12 @@ + + + + + + Operator tables and rollback consent verification + + +
+ + + diff --git a/frontend-modern/browser-tests/operator-tables-consent.tsx b/frontend-modern/browser-tests/operator-tables-consent.tsx new file mode 100644 index 0000000000..17b99932c1 --- /dev/null +++ b/frontend-modern/browser-tests/operator-tables-consent.tsx @@ -0,0 +1,399 @@ +// Production components and CSS with synthetic observations. No native, released or +// installed recovery claim; the current history API does not describe restore scope. +import { createSignal, Show } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { StoragePoolsTable } from '../src/components/Storage/StoragePoolsTable'; +import { DiskList } from '../src/components/Storage/DiskList'; +import { KubernetesControllersTable } from '../src/features/kubernetes/KubernetesControllersTable'; +import { ProxmoxCoverageTable } from '../src/features/proxmox/ProxmoxCoverageTable'; +import { ProxmoxRecoverableTable } from '../src/features/proxmox/ProxmoxRecoverableTable'; +import { ProxmoxBackupServersTable } from '../src/features/proxmox/ProxmoxBackupServersTable'; +import { ProxmoxNodesTable } from '../src/features/proxmox/ProxmoxNodesTable'; +import { GuestRow } from '../src/components/Workloads/GuestRow'; +import { Table, TableBody } from '../src/components/shared/Table'; +import { ToastContainer } from '../src/components/Toast/Toast'; +import { UpdateHistorySection } from '../src/components/Settings/UpdateHistorySection'; +import { buildProxmoxBackupRecoveryModel } from '../src/features/proxmox/proxmoxBackupRecoveryModel'; +import { groupStorageRecords } from '../src/features/storageBackups/storageModelCore'; +import { EMPTY_STORAGE_ALERT_STATE } from '../src/features/storageBackups/storageAlertState'; +import { DarkModeContext, WebSocketContext } from '../src/contexts/appRuntime'; +import { updateStore } from '../src/stores/updates'; +import type { Resource } from '../src/types/resource'; +import type { StorageRecord } from '../src/features/storageBackups/models'; +import '../src/index.css'; + +const now = Date.parse('2026-10-03T12:00:00Z'); +const resource = (id: string, type: Resource['type'], extra: Partial = {}): Resource => ({ + id, + type, + name: id, + displayName: id, + platformId: 'fixture-estate', + platformType: 'proxmox-pve', + sourceType: 'api', + status: 'online', + lastSeen: now, + ...extra, +}); +const pools: StorageRecord[] = [ + ['archive-tank', 'DEGRADED', 'resilver'], + ['backup-pool', 'ONLINE', 'scrub'], +].map(([name, state, scan], index) => ({ + id: name, + name, + category: 'pool', + health: index ? 'healthy' : 'warning', + location: { label: index ? 'backup-nas' : 'archive-nas', scope: 'host' }, + source: { + platform: 'truenas', + family: 'onprem', + origin: 'resource', + adapterId: 'resource-storage', + }, + capacity: { totalBytes: 2e12, usedBytes: 1.18e12, freeBytes: 0.82e12, usagePercent: 59 }, + capabilities: ['capacity', 'health'], + observedAt: now, + protectionLabel: `ZFS pool ${name} ${scan} is running (45.2%)`, + protectionSummary: `ZFS pool ${name} ${scan} is running (45.2%)`, + rebuildInProgress: true, + details: { + zfsPool: { + state, + scan: `${scan} in progress`, + scanDetails: { + function: scan.toUpperCase(), + state: 'SCANNING', + percentage: 45.2, + }, + devices: [], + }, + }, +})); +const disks = [ + resource('disk-sda', 'physical_disk', { + identity: { hostname: 'archive-nas' }, + canonicalIdentity: { hostname: 'archive-nas' }, + physicalDisk: { + devPath: '/dev/sda', + model: 'Archive HDD', + serial: 'FIXTURE-1', + diskType: 'hdd', + sizeBytes: 2e12, + health: 'FAILED', + temperature: 42, + storageRole: 'data', + risk: { + level: 'critical', + reasons: [ + { + code: 'smart-failed', + severity: 'critical', + summary: 'SMART failed. The full reason remains readable in the expanded disk header.', + }, + ], + }, + }, + }), + resource('disk-nvme', 'physical_disk', { + identity: { hostname: 'backup-nas' }, + canonicalIdentity: { hostname: 'backup-nas' }, + physicalDisk: { + devPath: '/dev/nvme0n1', + model: 'Backup SSD', + serial: 'FIXTURE-2', + diskType: 'nvme', + sizeBytes: 1e12, + health: 'PASSED', + temperature: 44, + wearout: 2, + storageRole: 'cache', + }, + }), +]; +const controllers = [ + resource('nightly-import', 'k8s-job', { + platformType: 'kubernetes', + kubernetes: { + resourceKind: 'Job', + clusterName: 'prod', + namespace: 'batch', + startTime: '2026-10-03T11:00:00Z', + completionTime: '2026-10-03T11:05:00Z', + desiredReplicas: 1, + succeeded: 1, + active: 0, + failed: 0, + }, + }), + resource('billing-rollup', 'k8s-cronjob', { + platformType: 'kubernetes', + kubernetes: { + resourceKind: 'CronJob', + clusterName: 'prod', + namespace: 'batch', + schedule: '*/5 * * * *', + lastScheduleTime: '2026-10-03T11:00:00Z', + lastSuccessfulTime: '2026-10-03T11:05:00Z', + suspend: true, + active: 0, + }, + }), + resource('node-exporter', 'k8s-daemonset', { + platformType: 'kubernetes', + kubernetes: { + resourceKind: 'DaemonSet', + clusterName: 'prod', + namespace: 'observability', + desiredNumberScheduled: 3, + currentNumberScheduled: 3, + numberReady: 2, + numberUnavailable: 1, + }, + }), + resource('database', 'k8s-statefulset', { + platformType: 'kubernetes', + kubernetes: { + resourceKind: 'StatefulSet', + clusterName: 'prod', + namespace: 'apps', + desiredReplicas: 1, + readyReplicas: 1, + serviceName: 'database-headless', + }, + }), +]; +const workload = resource('ct-310', 'system-container', { + name: 'artifact-cache-310', + displayName: 'artifact-cache-310', + status: 'running', + proxmox: { vmid: 310, node: 'pve-edge', instance: 'homelab' }, +}); +const model = buildProxmoxBackupRecoveryModel({ + workloads: [workload], + pbsBackups: [], + tasks: [], + nowMs: now, + archives: [ + { + id: 'file-310', + storage: 'local', + node: 'pve-edge', + instance: 'homelab', + type: 'ct', + vmid: 310, + time: '2026-09-20T02:00:00Z', + ctime: Date.parse('2026-09-20T02:00:00Z') / 1000, + size: 1048576, + format: 'tar.zst', + protected: false, + volid: 'local:backup/vzdump-lxc-310-2026_09_20-02_00_00.tar.zst', + isPBS: false, + verified: false, + }, + ], + snapshots: [ + { + id: 'snap-310', + name: 'fresh-local-snapshot', + node: 'pve-edge', + instance: 'homelab', + type: 'ct', + vmid: 310, + time: '2026-10-03T11:30:00Z', + vmstate: false, + }, + ], +}); +const pbs = resource('pbs-archive', 'pbs', { + platformType: 'proxmox-pbs', + cpu: { current: 14 }, + memory: { current: 32, total: 8e9, used: 2.56e9, free: 5.44e9 }, + pbs: { + instance: 'pbs-archive', + datastores: [ + { + name: 'archive', + total: 2e12, + used: 1.18e12, + free: 0.82e12, + usage: 59, + status: 'available', + }, + ], + }, +}); +const node = resource('pve-edge', 'node', { + cpu: { current: 12, cores: 4 }, + memory: { current: 32, total: 8e9, used: 2.56e9, free: 5.44e9 }, + disk: { current: 30, total: 1e12, used: 0.3e12, free: 0.7e12 }, + proxmox: { node: 'pve-edge', instance: 'homelab', uptime: 86400 }, +}); +function Fixture() { + const [view, setView] = createSignal('Pools'); + const [disk, setDisk] = createSignal(null); + const [pool, setPool] = createSignal(null); + const [expanded, setExpanded] = createSignal(new Set()); + const ws = { + state: { pmg: [], pbs: [], nodes: [], resources: [], vms: [], containers: [] }, + activeAlerts: () => ({}), + connected: () => true, + initialDataReceived: () => true, + }; + return ( + + document.documentElement.classList.contains('dark')}> + +
+

Operator tables and rollback consent

+ + +
+ {}} + expandedPoolId={pool()} + setExpandedPoolId={setPool} + storageGrowthBySeriesId={new Map()} + storageGrowthColumnLabel="Growth" + physicalDisks={[]} + nodeOnlineByLabel={new Map()} + highlightedRecordId={null} + getRecordAlertState={() => EMPTY_STORAGE_ALERT_STATE} + isLoading={false} + /> +
+
+ +
+ +
+
+ +
+ } + emptyTitle="No controllers" + emptyDescription="No controllers" + showToolbar={false} + /> +
+
+ +
+

Coverage

+ } + emptyTitle="No backups" + emptyDescription="No backups" + sortKey={() => 'posture'} + sortDirection={() => 'asc'} + onSort={() => {}} + expandedKeys={expanded()} + onToggleExpand={(key) => + setExpanded((current) => + current.has(key) + ? new Set([...current].filter((item) => item !== key)) + : new Set([...current, key]), + ) + } + showPbsColumn + showArchiveColumn + showSnapshotColumn + showTaskColumn={false} + /> +

By date

+ } + emptyTitle="No artifacts" + emptyDescription="No artifacts" + sortKey={() => 'created'} + sortDirection={() => 'desc'} + onSort={() => {}} + sizeMaxBytes={1048576} + /> +

Backup servers

+ +

Proxmox nodes

+ } + emptyTitle="No nodes" + emptyDescription="No nodes" + /> +

Workload row

+ + + + +
+
+
+ +
+ +
+
+
+
+
+ ); +} +void updateStore.checkForUpdates(); +render( + () => ( + + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index bd2176689c..34fd143392 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,23 +1,67 @@ { "version": 1, "result": "passed", - "base_sha": "227657b1b0d6924e98da9382f812bb0cb1874f57", - "verified_at": "2026-10-03T04:13:25.499572Z", + "base_sha": "697ad83e19fa5e8afee634c212c945600d2292b4", + "verified_at": "2026-10-03T06:30:17.630790Z", "changed_paths": [ - "frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx", - "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx", - "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx", - "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts" + "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx", + "frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts", + "frontend-modern/src/components/Settings/UpdateHistorySection.tsx", + "frontend-modern/src/components/Storage/DiskDetail.tsx", + "frontend-modern/src/components/Storage/DiskList.tsx", + "frontend-modern/src/components/Storage/EnhancedStorageBar.tsx", + "frontend-modern/src/components/Storage/StoragePoolRow.tsx", + "frontend-modern/src/components/Storage/StoragePoolsTable.tsx", + "frontend-modern/src/components/Storage/useDiskDetailModel.ts", + "frontend-modern/src/components/Storage/useEnhancedStorageBarModel.ts", + "frontend-modern/src/components/Workloads/guestRowModel.tsx", + "frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx", + "frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts", + "frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts", + "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx", + "frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts", + "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts", + "frontend-modern/src/features/storageBackups/diskPresentation.ts", + "frontend-modern/src/features/storageBackups/rowPresentation.ts", + "frontend-modern/src/features/storageBackups/storageBarPresentation.ts", + "frontend-modern/src/features/storageBackups/storagePagePresentation.ts" ], "content_sha256": { - "frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx": "44f939bd8accdb5662f2f99a2c4d6324d965ca520414d0a0db8daba790af04d9", - "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "41dadd17f930115374facc8f7a9dc46f3e4eab69e41e4375c90f9a5f57022fe8", - "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "ee9482a2076b86e62c85bffe0ce816a6ccd542ec3b07a85e829a1cc07dd8b1de", - "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts": "a39e66fc3ca772f0394ad0b5a74feee67a80daac3fdc604d014cadb52cda02b6" + "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "5356c4527e5bccaf86d1dbcaf808cdbeacfce05c8bd58879ab8f90287db1cca8", + "frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts": "79a9a4414958bbd3034bc145ea5f5b5a1f58a52af4ff8de8b46f18888f5aed51", + "frontend-modern/src/components/Settings/UpdateHistorySection.tsx": "39e7f5daa49bd18fd71d91a09a06da47ec3abc5496108d3715020180ca439e52", + "frontend-modern/src/components/Storage/DiskDetail.tsx": "04e5bacb4900cc77a94fdb804363048ed53f4b84dc1c0e9610878a106a1db1a6", + "frontend-modern/src/components/Storage/DiskList.tsx": "47da15179f6c7e98583d9db2a858790233660f8dc4eb9ea85681f262b50514f5", + "frontend-modern/src/components/Storage/EnhancedStorageBar.tsx": "ed72a33e754e96ebd03374c34f9732b02411cd9055667f4e0051d6f7e66ab653", + "frontend-modern/src/components/Storage/StoragePoolRow.tsx": "09417822bd543ddbc312d0c15a4ea74971066d6623d421d889858e56262b9d3d", + "frontend-modern/src/components/Storage/StoragePoolsTable.tsx": "7b61e9fac5e3a9fbae831a3cf907a71f1be7158f06bfe7a9fe7d16fce7a2c928", + "frontend-modern/src/components/Storage/useDiskDetailModel.ts": "ee5059bb3a7e0069561e4f103e2fd89101e096ad32b3b27c6f4777c26c1388f8", + "frontend-modern/src/components/Storage/useEnhancedStorageBarModel.ts": "d03daecfe22ede32e46edc74a20e4ac11d732d4f9ca202c0b6f36c22a590aa44", + "frontend-modern/src/components/Workloads/guestRowModel.tsx": "7787854145257e13bc347c2513f89e595060e606857a9a7c4ae6c0a897b6c639", + "frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "f6f1b090580e4bea0d6496b908f72049c886c4faeee7aca3a3b11e9417c67df7", + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "adbae773cc485f5dc16d1d316bbd5e1b1ace6be24404861ce02bf7dd8890bb25", + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "786be66c40d33006edc1eda234df083ee68e0296103882ff108711026dd2ea29", + "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "6a546a5c1e8a9bbe6a2bdeac61c988a8e9f35cf96888adff245a5efd7017769e", + "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx": "fdc9fc2720ed04771f5c9d271f8972a4dece25e165064183fcf59678466a970e", + "frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts": "b872437fb4cfb61b07e0c949ae6f323d6fd3f2622e57480931f7ef74fafe79c2", + "frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts": "dc2641e74f12ed01b5e2a1424f2223c8ca136fa4e0321dd49a79e5ce458fc3cd", + "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx": "2ab0f6c58e18337f05ea85082ed5c465a625c5388ff82987eec41bbbe3005915", + "frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts": "4aab84754245dda70c3b3be3e81b15875a8664d38c8d0325329b2074b6c26fd5", + "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts": "4366372e6acf67ac62e03a2c9c34e599d304724a1c79e17b60503bdae9aa6fe3", + "frontend-modern/src/features/storageBackups/diskPresentation.ts": "fc637ecfee6ffff80ee826bdb8ff546ef0dd8ffa5cd0790c19b1fdcf71dcfb65", + "frontend-modern/src/features/storageBackups/rowPresentation.ts": "cabd37e3f736467aecff68359fcb97be08bcabbe5c5ec6e0a0da2fe67d2e3261", + "frontend-modern/src/features/storageBackups/storageBarPresentation.ts": "562eac2212378e98823717f712c791133cebf1f44bd71c75c7db097b244f6cc0", + "frontend-modern/src/features/storageBackups/storagePagePresentation.ts": "2a93a0176b912283c77b384ee2c66c4bc62c21a04b645eb93917a41740ad2d6e" }, "routes": [ - "Existing PBS/resource detail Overview change history", - "Existing resource detail Manage action history" + "Existing storage pool and physical-disk tables / disk expansion", + "Existing Kubernetes controller table / production resource summary expansion", + "Existing Proxmox coverage, By date, PBS/node tables and guest row", + "Existing Settings Update History confirmation" ], "viewports": [ { @@ -27,26 +71,31 @@ { "width": 390, "height": 844 + }, + { + "width": 768, + "height": 900 } ], "states": [ - "Final facet HTTP 403 withdraws embedded snapshot events/counts instead of reintroducing denied evidence", - "Denied filtered facets do not fall back to the unfiltered bundle", - "Retry retains denial while pending; fresh successful response restores events and clears guidance", - "First action-history HTTP 503 stays visible in Manage with retry and no false empty state", - "Initial facet HTTP 503 retains applicable embedded evidence; snapshot-only table rows still make no remote reads", - "All fourteen final screenshots inspected in desktop Chromium and dark touch phone WebKit; Playwright 1.56.1 matches the integration lock" + "Completed backup stays 13d old alongside a separate fresh 30m guest snapshot; missing independent backup remains unit-covered", + "Structured ZFS scrub/resilver activity badges render at desktop/intermediate width; phone retains Degraded/Online and measured 59% capacity", + "Disk table verdict keeps full SMART reason in the expanded header without phone overflow", + "Job and CronJob absolute timestamps, elapsed duration, target, schedule, suspension and last-success remain reachable from phone row expansion", + "Consent names running and selected historical versions separately and declares absent restore scope; legacy and installation-only effects are distinguished", + "Rejected rollback keeps consent and error toast; pending actions cannot be duplicated or cancelled", + "All sixteen final screenshots inspected; Playwright 1.56.1 matches integration lock, light desktop/intermediate Chromium and dark touch phone WebKit" ], "interactions": [ - "Remount full production resource drawer with denied/transient reads", - "Activate scoped Retry with keyboard Enter", - "Apply and clear Change kind filter", - "Switch between Overview and Manage" + "Expand/collapse real resource and disk rows", + "Open consent using keyboard Enter, cancel and return focus without a rollback write", + "Reject one synthetic rollback POST, then hold/accept a second while checking disabled duplicate/cancel and Escape behaviour", + "Select existing backup projections with independent archive and guest snapshot dates" ], "limitations": [ - "Synthetic HTTP responses and PBS snapshots through real production drawer/client/CSS, not appliance authorisation, reporter cause, release or installed recovery acceptance.", - "401 withdrawal is unit-covered; real client login redirection is not overridden or browser-proved.", - "Action client existing 401/402/403 unavailable-plan responses are unchanged. Only thrown read failures retain actionable error presentation.", - "Independent reader authorisation remains independent; this is not a global session revocation broadcast." + "Synthetic observations and HTTP responses through production components and CSS in a local Vite preview. Not native appliance data/authorisation, reporter cause, released availability, actual rollback, Tailscale or SSE acceptance.", + "The History API exposes no restore-scope metadata; no version heuristic or free-form note establishes scope. The UI conservatively says it is unknown.", + "Full provider identifiers remain titles/search targets. Some long row names and secondary intermediate-width fields intentionally truncate; this does not prove every native estate or every value fits.", + "The frontend graph is unchanged and its seven high GHSA-vfj7-8cjw-p6xm findings remain; browser/component/build proof is not audit clearance." ] } diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx index 8291e514a0..2f03d28dcb 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx @@ -13,6 +13,7 @@ import { TagBadges } from '@/components/shared/TagBadges'; import { formatBytes, formatRelativeTime, formatUptime } from '@/utils/format'; import { getDockerImageRegistryLink } from '@/features/docker/dockerImageReference'; import { formatInteger } from './resourceDetailMappers'; +import { buildKubernetesControllerSection } from './resourceDetailDrawerKubernetesModel'; import type { UseResourceDetailDrawerStateResult } from './useResourceDetailDrawerState'; interface ResourceSummaryPresentationProps { @@ -285,6 +286,7 @@ export const InlineResourceSummaryTables: Component { ).toBe('https://prod.k8s.local:6443'); }); }); + +describe('buildKubernetesControllerSection', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + const absolute = (timestamp: string): string => + formatPlatformTableDateTimeValue(timestamp, { dateTimeFormat: { year: 'numeric' } }); + + it('returns nothing for resources that are not workload controllers', () => { + expect( + buildKubernetesControllerSection( + makeResource({ id: 'pod-1', type: 'pod', kubernetes: { namespace: 'apps' } }), + ), + ).toBeNull(); + expect(buildKubernetesControllerSection(makeResource({ id: 'vm-1', type: 'vm' }))).toBeNull(); + expect( + buildKubernetesControllerSection(makeResource({ id: 'job-1', type: 'k8s-job' })), + ).toBeNull(); + }); + + it('leads a Job with its absolute start and completion times, then the counts', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-24T13:31:00Z')); + + const section = buildKubernetesControllerSection( + makeResource({ + id: 'nightly-import', + type: 'k8s-job', + kubernetes: { + clusterName: 'prod', + namespace: 'batch', + desiredReplicas: 10, + active: 1, + succeeded: 8, + failed: 2, + startTime: '2026-05-24T12:55:00Z', + completionTime: '2026-05-24T13:00:00Z', + }, + }), + ); + + expect(section?.label).toBe('Job'); + expect(section?.testId).toBe('resource-kubernetes-controller-section'); + expect(section?.rows).toEqual([ + { label: 'Started', value: `${absolute('2026-05-24T12:55:00Z')} (36m ago)`, wrap: true }, + { label: 'Completed', value: `${absolute('2026-05-24T13:00:00Z')} (31m ago)`, wrap: true }, + { label: 'Duration', value: '5m' }, + { label: 'Target', value: '10 completions' }, + { label: 'Active', value: '1' }, + { label: 'Succeeded', value: '8' }, + { label: 'Failed', value: '2', tone: 'danger' }, + { label: 'Namespace', value: 'batch' }, + { label: 'Cluster', value: 'prod' }, + ]); + }); + + it('keeps a running Job on its start time without a completion or duration row', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-24T13:31:00Z')); + + const section = buildKubernetesControllerSection( + makeResource({ + id: 'nightly-import', + type: 'k8s-job', + kubernetes: { + namespace: 'batch', + desiredReplicas: 1, + active: 1, + failed: 0, + startTime: '2026-05-24T13:29:30Z', + }, + }), + ); + + expect(section?.rows.map((row) => row.label)).toEqual([ + 'Started', + 'Target', + 'Active', + 'Failed', + 'Namespace', + ]); + expect(section?.rows[0]).toEqual({ + label: 'Started', + value: `${absolute('2026-05-24T13:29:30Z')} (1m ago)`, + wrap: true, + }); + expect(section?.rows[3]).toEqual({ label: 'Failed', value: '0', tone: 'default' }); + }); + + it('leads a CronJob with its schedule and the last run and last success times', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-24T13:31:00Z')); + + const section = buildKubernetesControllerSection( + makeResource({ + id: 'billing-rollup', + type: 'k8s-cronjob', + kubernetes: { + clusterName: 'prod', + namespace: 'batch', + schedule: '0 2 * * *', + suspend: true, + active: 0, + lastScheduleTime: '2026-05-24T02:00:00Z', + lastSuccessfulTime: '2026-05-23T02:00:00Z', + }, + }), + ); + + expect(section?.label).toBe('CronJob'); + expect(section?.rows).toEqual([ + { label: 'Schedule', value: '0 2 * * *', valueClass: 'font-mono' }, + { label: 'Last run', value: `${absolute('2026-05-24T02:00:00Z')} (11h ago)`, wrap: true }, + { label: 'Last success', value: `${absolute('2026-05-23T02:00:00Z')} (1d ago)`, wrap: true }, + { label: 'Suspended', value: 'Yes', tone: 'warning' }, + { label: 'Active', value: '0' }, + { label: 'Namespace', value: 'batch' }, + { label: 'Cluster', value: 'prod' }, + ]); + }); + + it('carries the service name and counts for StatefulSets and DaemonSets', () => { + const statefulSet = buildKubernetesControllerSection( + makeResource({ + id: 'checkout-api-stateful', + type: 'k8s-statefulset', + kubernetes: { + namespace: 'apps', + desiredReplicas: 3, + currentReplicas: 3, + readyReplicas: 2, + availableReplicas: 2, + updatedReplicas: 2, + serviceName: 'checkout-headless', + }, + }), + ); + expect(statefulSet?.label).toBe('StatefulSet'); + expect(statefulSet?.rows).toEqual([ + { label: 'Service', value: 'checkout-headless' }, + { label: 'Target', value: '3 pods' }, + { label: 'Current', value: '3' }, + { label: 'Ready', value: '2' }, + { label: 'Available', value: '2' }, + { label: 'Updated', value: '2' }, + { label: 'Namespace', value: 'apps' }, + ]); + + const daemonSet = buildKubernetesControllerSection( + makeResource({ + id: 'node-exporter', + type: 'k8s-daemonset', + kubernetes: { + namespace: 'observability', + desiredNumberScheduled: 6, + currentNumberScheduled: 6, + numberReady: 5, + numberAvailable: 5, + numberUnavailable: 1, + numberMisscheduled: 0, + updatedReplicas: 5, + }, + }), + ); + expect(daemonSet?.label).toBe('DaemonSet'); + expect(daemonSet?.rows).toEqual([ + { label: 'Target', value: '6 nodes' }, + { label: 'Current', value: '6' }, + { label: 'Ready', value: '5' }, + { label: 'Available', value: '5' }, + { label: 'Updated', value: '5' }, + { label: 'Unavailable', value: '1', tone: 'warning' }, + { label: 'Misscheduled', value: '0', tone: 'default' }, + { label: 'Namespace', value: 'observability' }, + ]); + }); + + it('shows the ReplicaSet labelling and generation fields the Detail column carries', () => { + const section = buildKubernetesControllerSection( + makeResource({ + id: 'checkout-api-replicaset', + type: 'k8s-replicaset', + kubernetes: { + desiredReplicas: 4, + readyReplicas: 3, + fullyLabeledReplicas: 4, + observedGeneration: 7, + }, + }), + ); + expect(section?.label).toBe('ReplicaSet'); + expect(section?.rows).toEqual([ + { label: 'Target', value: '4 pods' }, + { label: 'Ready', value: '3' }, + { label: 'Fully labeled', value: '4' }, + { label: 'Observed generation', value: '7' }, + ]); + }); +}); diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts index 5d3d5e9275..2d44225f6b 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts @@ -3,9 +3,18 @@ import { compactDetailRows as compactRows, compactDetailSections as compactSections, formatDetailBytesValue, + formatDetailCountValue, + formatDetailIntegerValue, makeDetailRow as makeRow, + type DetailRow, type DetailSection, + type DetailValueTone, } from '@/components/shared/detailSectionModel'; +import { + formatPlatformTableDateTimeValue, + formatPlatformTableDurationValue, + formatPlatformTableRelativeTimeValue, +} from '@/features/platformPage/sharedPlatformPage'; export type ResourceDetailDrawerKubernetesSection = DetailSection; @@ -120,6 +129,146 @@ export const buildKubernetesDetailSections = ( return compactSections(sections); }; +const countRow = ( + label: string, + value: number | undefined, + options: { noun?: string; tone?: (count: number) => DetailValueTone } = {}, +): DetailRow | null => { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + const text = options.noun + ? formatDetailCountValue(value, options.noun) + : formatDetailIntegerValue(value); + return makeRow(label, text, options.tone ? { tone: options.tone(value) } : {}); +}; + +const warnWhenPositive = (count: number): DetailValueTone => (count > 0 ? 'warning' : 'default'); +const dangerWhenPositive = (count: number): DetailValueTone => (count > 0 ? 'danger' : 'default'); + +// Kubernetes reports controller timestamps as RFC 3339 strings. The table row +// only has room for an age, so the expansion carries the absolute time first +// and the age after it: "Oct 2, 2026, 09:13 PM (1h ago)". The value wraps +// because a phone-width detail cell is too narrow for both on one line. +const timestampRow = (label: string, timestamp?: string | null): DetailRow | null => { + const raw = asString(timestamp); + if (!raw) return null; + const absolute = formatPlatformTableDateTimeValue(raw, { + emptyText: '', + dateTimeFormat: { year: 'numeric' }, + }); + if (!absolute) return makeRow(label, raw, { wrap: true }); + const relative = formatPlatformTableRelativeTimeValue(raw, { emptyText: '' }); + return makeRow(label, relative ? `${absolute} (${relative})` : absolute, { wrap: true }); +}; + +const elapsedSeconds = (start?: string | null, end?: string | null): number | undefined => { + const startedAt = Date.parse(asString(start) ?? ''); + const endedAt = Date.parse(asString(end) ?? ''); + if (!Number.isFinite(startedAt) || !Number.isFinite(endedAt) || endedAt < startedAt) { + return undefined; + } + return (endedAt - startedAt) / 1000; +}; + +type ControllerRows = { label: string; rows: Array }; + +// Each kind leads with what its narrow table row drops: the Detail column +// (service name, timestamps) below the large layout and Target on a phone. +const controllerRows = (resource: Resource): ControllerRows | null => { + const k = resource.kubernetes; + if (!k) return null; + switch (resource.type) { + case 'k8s-replicaset': + return { + label: 'ReplicaSet', + rows: [ + countRow('Target', k.desiredReplicas, { noun: 'pod' }), + countRow('Current', k.currentReplicas), + countRow('Ready', k.readyReplicas), + countRow('Available', k.availableReplicas), + countRow('Fully labeled', k.fullyLabeledReplicas), + countRow('Observed generation', k.observedGeneration), + ], + }; + case 'k8s-statefulset': + return { + label: 'StatefulSet', + rows: [ + makeRow('Service', k.serviceName), + countRow('Target', k.desiredReplicas, { noun: 'pod' }), + countRow('Current', k.currentReplicas), + countRow('Ready', k.readyReplicas), + countRow('Available', k.availableReplicas), + countRow('Updated', k.updatedReplicas), + ], + }; + case 'k8s-daemonset': + return { + label: 'DaemonSet', + rows: [ + countRow('Target', k.desiredNumberScheduled, { noun: 'node' }), + countRow('Current', k.currentNumberScheduled), + countRow('Ready', k.numberReady), + countRow('Available', k.numberAvailable), + countRow('Updated', k.updatedReplicas), + countRow('Unavailable', k.numberUnavailable, { tone: warnWhenPositive }), + countRow('Misscheduled', k.numberMisscheduled, { tone: warnWhenPositive }), + ], + }; + case 'k8s-job': + return { + label: 'Job', + rows: [ + timestampRow('Started', k.startTime), + timestampRow('Completed', k.completionTime), + makeRow( + 'Duration', + formatPlatformTableDurationValue(elapsedSeconds(k.startTime, k.completionTime), { + emptyText: '', + }), + ), + countRow('Target', k.desiredReplicas, { noun: 'completion' }), + countRow('Active', k.active), + countRow('Succeeded', k.succeeded), + countRow('Failed', k.failed, { tone: dangerWhenPositive }), + ], + }; + case 'k8s-cronjob': + return { + label: 'CronJob', + rows: [ + makeRow('Schedule', k.schedule, { valueClass: 'font-mono' }), + timestampRow('Last run', k.lastScheduleTime), + timestampRow('Last success', k.lastSuccessfulTime), + makeRow('Suspended', k.suspend === true ? 'Yes' : null, { tone: 'warning' }), + countRow('Active', k.active), + ], + }; + default: + return null; + } +}; + +// Workload-controller facts for the always-visible summary of the resource +// drawer, the same slot Docker containers use for their timestamps. The +// controllers table hides Scope and Detail below its large layout and Target +// on a phone, so without this a Job's completion time or a CronJob's last +// success is unreachable from a narrow row. +export const buildKubernetesControllerSection = (resource: Resource): DetailSection | null => { + const controller = controllerRows(resource); + if (!controller) return null; + const rows = compactRows([ + ...controller.rows, + makeRow('Namespace', resource.kubernetes?.namespace), + makeRow('Cluster', resource.kubernetes?.clusterName), + ]); + if (rows.length === 0) return null; + return { + label: controller.label, + rows, + testId: 'resource-kubernetes-controller-section', + }; +}; + export const buildKubernetesDetailsSummary = (resource: Resource): string | null => { const k = resource.kubernetes; if (!k) return null; diff --git a/frontend-modern/src/components/Settings/UpdateHistorySection.tsx b/frontend-modern/src/components/Settings/UpdateHistorySection.tsx index b8dd29d332..4fee194fd4 100644 --- a/frontend-modern/src/components/Settings/UpdateHistorySection.tsx +++ b/frontend-modern/src/components/Settings/UpdateHistorySection.tsx @@ -5,6 +5,7 @@ import { updateStore } from '@/stores/updates'; import { Button } from '@/components/shared/Button'; import { Dialog } from '@/components/shared/Dialog'; import { LoadingSpinner } from '@/components/shared/LoadingSpinner'; +import { getShippedDocUrl } from '@/utils/docsLinks'; const HISTORY_LIMIT = 20; @@ -45,7 +46,7 @@ const statusPresentation = (status: string): { label: string; className: string } }; -// A rollback restores the backup taken before this update was applied, so it +// A rollback uses the backup taken before this update was applied, so it // is only offered where that backup is still retained on disk (the backend // clears backup_path when retention prunes it) and the update actually landed. const canRollBack = (entry: UpdateHistoryEntry): boolean => @@ -89,7 +90,7 @@ export const UpdateHistorySection: Component = () => { Update History

- Updates applied through Pulse, with rollback to the backup taken before each one. + Updates applied through Pulse, with version rollback using a retained backup.

@@ -187,14 +188,30 @@ export const UpdateHistorySection: Component = () => {
-

- This restores the binary, configuration, and data from the backup taken before the - update to {formatVersionLabel(entry().version_to)} on{' '} - {formatTimestamp(entry().timestamp)}. +

+ Running now: Pulse {formatVersionLabel(updateStore.versionInfo()?.version || '')}.

- Settings and alert changes made since that backup will be reverted, and Pulse will - restart to complete the rollback. + This requests an installation rollback using the backup taken before the update to{' '} + {formatVersionLabel(entry().version_to)} on {formatTimestamp(entry().timestamp)}. + Pulse will restart if the rollback succeeds. +

+

+ Restore scope is not reported for this backup. A scope-aware server keeps active + runtime data in place and restores only the binary and independent installation + files. Older recovery can also replace install-local data and configuration, + reverting later changes. +

+

+ Do not assume this will undo settings or alerts. Full-state recovery requires + Pulse to be stopped and a consistent backup of every active store.{' '} + + Read the recovery instructions + + .

diff --git a/frontend-modern/src/components/Settings/__tests__/UpdateHistorySection.test.tsx b/frontend-modern/src/components/Settings/__tests__/UpdateHistorySection.test.tsx index 4f6ab77192..20a5b255e5 100644 --- a/frontend-modern/src/components/Settings/__tests__/UpdateHistorySection.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/UpdateHistorySection.test.tsx @@ -4,6 +4,7 @@ import type { UpdateHistoryEntry } from '@/api/updates'; const mockListUpdateHistory = vi.fn(); const mockStoreRollbackUpdate = vi.fn(); +let runningVersion: string | undefined = '6.0.5'; vi.mock('@/api/updates', () => ({ UpdatesAPI: { @@ -13,7 +14,7 @@ vi.mock('@/api/updates', () => ({ vi.mock('@/stores/updates', () => ({ updateStore: { - versionInfo: () => ({ version: '6.0.5' }), + versionInfo: () => (runningVersion ? { version: runningVersion } : null), rollbackUpdate: (...args: unknown[]) => mockStoreRollbackUpdate(...args), }, })); @@ -39,6 +40,7 @@ describe('UpdateHistorySection', () => { beforeEach(() => { mockListUpdateHistory.mockReset(); mockStoreRollbackUpdate.mockReset(); + runningVersion = '6.0.5'; }); afterEach(() => { @@ -102,6 +104,24 @@ describe('UpdateHistorySection', () => { fireEvent.click(await screen.findByRole('button', { name: 'Roll back' })); expect(await screen.findByText('Roll back to Pulse v6.0.4?')).toBeInTheDocument(); + expect(screen.getByText('Running now: Pulse v6.0.5.')).toBeInTheDocument(); + expect(screen.getByText(/Restore scope is not reported for this backup/)).toHaveTextContent( + 'A scope-aware server keeps active runtime data in place', + ); + expect(screen.getByText(/Older recovery can also/)).toHaveTextContent( + 'reverting later changes', + ); + expect(screen.getByText(/Do not assume this will undo settings or alerts/)).toHaveTextContent( + 'Full-state recovery requires Pulse to be stopped', + ); + expect(screen.getByRole('link', { name: 'Read the recovery instructions' })).toHaveAttribute( + 'href', + '/docs/AUTO_UPDATE#manual-rollback', + ); + expect( + screen.queryByText(/Settings and alert changes made since that backup will be reverted/), + ).not.toBeInTheDocument(); + expect(mockStoreRollbackUpdate).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: 'Roll back to v6.0.4' })); @@ -116,4 +136,62 @@ describe('UpdateHistorySection', () => { expect(screen.queryByText('Roll back to Pulse v6.0.4?')).not.toBeInTheDocument(), ); }); + + it('distinguishes the running version from an older history entry without guessing restore scope', async () => { + runningVersion = 'v6.6.0-rc.1'; + mockListUpdateHistory.mockResolvedValue([{ ...baseEntry, notes: 'restore everything' }]); + render(() => ); + fireEvent.click(await screen.findByRole('button', { name: 'Roll back' })); + expect(screen.getByText('Running now: Pulse v6.6.0-rc.1.')).toBeInTheDocument(); + expect(screen.getByText(/This requests an installation rollback/)).toHaveTextContent( + 'before the update to v6.0.5', + ); + expect(screen.getByText(/Restore scope is not reported/)).toBeInTheDocument(); + expect(screen.queryByText('restore everything')).not.toBeInTheDocument(); + expect(screen.queryByText(baseEntry.backup_path!)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(mockStoreRollbackUpdate).not.toHaveBeenCalled(); + }); + + it('does not infer the running version from the selected update when version information is missing', async () => { + runningVersion = undefined; + mockListUpdateHistory.mockResolvedValue([baseEntry]); + render(() => ); + fireEvent.click(await screen.findByRole('button', { name: 'Roll back' })); + expect(screen.getByText('Running now: Pulse unknown.')).toBeInTheDocument(); + expect(screen.getByText(/Restore scope is not reported/)).toBeInTheDocument(); + }); + + it('retains the consent and refreshes history after a rejected rollback', async () => { + mockListUpdateHistory.mockResolvedValue([baseEntry]); + mockStoreRollbackUpdate.mockResolvedValue(false); + render(() => ); + fireEvent.click(await screen.findByRole('button', { name: 'Roll back' })); + fireEvent.click(screen.getByRole('button', { name: 'Roll back to v6.0.4' })); + await waitFor(() => expect(mockListUpdateHistory).toHaveBeenCalledTimes(2)); + expect(screen.getByRole('dialog')).toHaveTextContent('Restore scope is not reported'); + expect(screen.getByRole('button', { name: 'Roll back to v6.0.4' })).not.toBeDisabled(); + }); + + it('prevents duplicate requests and cancellation while the rollback request is pending', async () => { + mockListUpdateHistory.mockResolvedValue([baseEntry]); + let finish!: (accepted: boolean) => void; + mockStoreRollbackUpdate.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + render(() => ); + fireEvent.click(await screen.findByRole('button', { name: 'Roll back' })); + fireEvent.click(screen.getByRole('button', { name: 'Roll back to v6.0.4' })); + expect(screen.getByRole('button', { name: 'Starting rollback...' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'Starting rollback...' })); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(mockStoreRollbackUpdate).toHaveBeenCalledTimes(1); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + finish(true); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); }); diff --git a/frontend-modern/src/components/Storage/DiskDetail.tsx b/frontend-modern/src/components/Storage/DiskDetail.tsx index 71c6cedb3a..3588836cc0 100644 --- a/frontend-modern/src/components/Storage/DiskDetail.tsx +++ b/frontend-modern/src/components/Storage/DiskDetail.tsx @@ -11,6 +11,10 @@ import { resolveHistoryRangeWithinLimit, } from '@/components/Storage/historyRangeAccess'; import { + DISK_DETAIL_HEADER_STACK_CLASS, + DISK_DETAIL_HEALTH_LABEL_CLASS, + DISK_DETAIL_HEALTH_ROW_CLASS, + DISK_DETAIL_HEALTH_SUMMARY_CLASS, DISK_DETAIL_HISTORY_RANGE_OPTIONS, DISK_DETAIL_LIVE_CHARTS, getDiskAttributeValueTextClass, @@ -55,6 +59,7 @@ export const DiskDetail: Component = (props) => { historyResourceId, attributeCards, historyCharts, + healthPresentation, metricResourceId, collectionMessages, liveIOAvailable, @@ -91,19 +96,32 @@ export const DiskDetail: Component = (props) => { return (
-
- {diskData().model || 'Unknown Disk'} - {diskData().devPath} - {diskData().node} - - {getPhysicalDiskSourceBadgePresentation(props.disk).label} - - - S/N: {diskData().serial} - +
+
+ + {diskData().model || 'Unknown Disk'} + + {diskData().devPath} + {diskData().node} + + {getPhysicalDiskSourceBadgePresentation(props.disk).label} + + + S/N: {diskData().serial} + +
+ {/* The verdict the row shows as a single word, with its reason in full. */} +
+ + {healthPresentation().label} + + + {healthPresentation().summary} + +
diff --git a/frontend-modern/src/components/Storage/DiskList.tsx b/frontend-modern/src/components/Storage/DiskList.tsx index 5715390316..fdff3b3d36 100644 --- a/frontend-modern/src/components/Storage/DiskList.tsx +++ b/frontend-modern/src/components/Storage/DiskList.tsx @@ -55,7 +55,6 @@ import { PHYSICAL_DISK_HEADER_SIZE_CLASS, PHYSICAL_DISK_HEADER_TEMP_CLASS, PHYSICAL_DISK_HEALTH_LABEL_CLASS, - PHYSICAL_DISK_HEALTH_SUMMARY_CLASS, PHYSICAL_DISK_HEALTH_WRAP_CLASS, PHYSICAL_DISK_DEVICE_TEXT_CLASS, PHYSICAL_DISK_LIFE_CLASS, @@ -72,8 +71,10 @@ import { PHYSICAL_DISK_TABLE_ROW_HOVER_CLASS, PHYSICAL_DISK_TABLE_ROW_SELECTED_CLASS, PHYSICAL_DISK_TABLE_ROW_STYLE, - getPhysicalDiskColumnWidthPercent, + getPhysicalDiskCellPaddingClass, + getPhysicalDiskColumnWidthStyle, getPhysicalDiskEmptyStatePresentation, + getPhysicalDiskHealthCompactLabel, getPhysicalDiskHealthStatus, getPhysicalDiskHealthSummary, getPhysicalDiskHostLabel, @@ -117,11 +118,18 @@ export const DiskList: Component = (props) => { baseClass: string, columnId: PhysicalDiskTableColumnId, visibleClass: 'table-column' | 'table-cell', - ) => - `${baseClass} ${isPhysicalDiskColumnVisible(layoutMode(), columnId) ? visibleClass : 'hidden'}`.trim(); - const columnStyle = (columnId: PhysicalDiskTableColumnId) => ({ - width: `${getPhysicalDiskColumnWidthPercent(layoutMode(), columnId)}%`, - }); + ) => { + const visibility = isPhysicalDiskColumnVisible(layoutMode(), columnId) + ? visibleClass + : 'hidden'; + // Rendered cells and headers shed the shared primitives' desktop padding + // on the phone layouts; `` elements carry no padding. + const padding = + visibleClass === 'table-cell' ? getPhysicalDiskCellPaddingClass(layoutMode()) : ''; + return `${baseClass} ${padding} ${visibility}`.replace(/\s+/g, ' ').trim(); + }; + const columnStyle = (columnId: PhysicalDiskTableColumnId) => + getPhysicalDiskColumnWidthStyle(layoutMode(), columnId); const { getDiskTemperatureThresholds } = useAlertsActivation(); const model = useDiskListModel({ disks: () => props.disks, @@ -327,6 +335,11 @@ export const DiskList: Component = (props) => { const status = createMemo(() => getPhysicalDiskHealthStatus(data())); const hostLabel = createMemo(() => getPhysicalDiskHostLabel(data(), disk)); const healthSummary = createMemo(() => getPhysicalDiskHealthSummary(status())); + const healthCompactLabel = createMemo(() => + getPhysicalDiskHealthCompactLabel(status().label), + ); + const healthLabelClass = () => + `${PHYSICAL_DISK_HEALTH_LABEL_CLASS} ${status().tone}`; const isSelected = () => model.selectedDisk()?.id === disk.id; const summarySeriesId = createMemo(() => resolvePhysicalDiskMetricResourceId(disk)); const isSummaryHighlighted = () => @@ -446,15 +459,30 @@ export const DiskList: Component = (props) => { data-storage-column="health" >
- - {status().label} - - + {/* The row shows the verdict word only; the reason is the + cell's title here and is spelled out in the disk drawer. + The phone projection of the table container shows the + shorter word; the tone stays on each span so the rendered + label is the styled element in either projection. */} + + {status().label} + + } + > - {healthSummary()} + {healthCompactLabel()} + + + {status().label}
diff --git a/frontend-modern/src/components/Storage/EnhancedStorageBar.tsx b/frontend-modern/src/components/Storage/EnhancedStorageBar.tsx index 71ea29ccef..02a5af5eca 100644 --- a/frontend-modern/src/components/Storage/EnhancedStorageBar.tsx +++ b/frontend-modern/src/components/Storage/EnhancedStorageBar.tsx @@ -25,6 +25,7 @@ import { getStorageBarTooltipTitle, getStorageBarZfsHeadingLabel, } from '@/features/storageBackups/storageBarPresentation'; +import { PlatformResponsiveTableLabel } from '@/features/platformPage/sharedPlatformPage'; import { useTooltip } from '@/hooks/useTooltip'; import { ProgressBar } from '@/components/shared/ProgressBar'; import { TooltipPortal } from '@/components/shared/TooltipPortal'; @@ -42,13 +43,14 @@ interface EnhancedStorageBarProps { export function EnhancedStorageBar(props: EnhancedStorageBarProps) { const tip = useTooltip(); const alertsActivation = useAlertsActivation(); - const { usagePercent, barColor, label, tooltipRows, zfsSummary } = useEnhancedStorageBarModel({ - used: () => props.used, - total: () => props.total, - free: () => props.free, - zfsPool: () => props.zfsPool, - thresholds: () => alertsActivation.getMetricThresholds('storage', 'disk'), - }); + const { usagePercent, barColor, label, compactLabel, tooltipRows, zfsSummary } = + useEnhancedStorageBarModel({ + used: () => props.used, + total: () => props.total, + free: () => props.free, + zfsPool: () => props.zfsPool, + thresholds: () => alertsActivation.getMetricThresholds('storage', 'disk'), + }); return (
@@ -71,7 +73,12 @@ export function EnhancedStorageBar(props: EnhancedStorageBarProps) { } label={ - {label()} + + {/* The phone projection of the table container keeps the + percentage only; the used/total sublabel returns with the + wider container and always lives in the tooltip. */} + + } /> diff --git a/frontend-modern/src/components/Storage/StoragePoolRow.tsx b/frontend-modern/src/components/Storage/StoragePoolRow.tsx index 58e8e6d2a8..1d29643412 100644 --- a/frontend-modern/src/components/Storage/StoragePoolRow.tsx +++ b/frontend-modern/src/components/Storage/StoragePoolRow.tsx @@ -26,6 +26,7 @@ import { import type { StorageCapacityDeltaPresentation } from '@/features/storageBackups/storageCapacityDeltaPresentation'; import type { Resource } from '@/types/resource'; import { + getStoragePoolCellPaddingClass, isStoragePoolColumnVisible, type StoragePoolTableColumnId, type StoragePoolTableLayoutMode, @@ -62,7 +63,11 @@ interface StoragePoolRowProps { export const StoragePoolRow: Component = (props) => { const columnClass = (baseClass: string, columnId: StoragePoolTableColumnId) => - `${baseClass} ${isStoragePoolColumnVisible(props.layoutMode, columnId) ? 'table-cell' : 'hidden'}`.trim(); + `${baseClass} ${getStoragePoolCellPaddingClass(props.layoutMode)} ${ + isStoragePoolColumnVisible(props.layoutMode, columnId) ? 'table-cell' : 'hidden' + }` + .replace(/\s+/g, ' ') + .trim(); const row = createMemo(() => buildStoragePoolRowModel(props.record, props.growthDelta ?? null)); const detailControlsId = createMemo(() => buildSummaryDisclosureControlsId(props.summarySeriesId), diff --git a/frontend-modern/src/components/Storage/StoragePoolsTable.tsx b/frontend-modern/src/components/Storage/StoragePoolsTable.tsx index c6217f0273..47dea2524a 100644 --- a/frontend-modern/src/components/Storage/StoragePoolsTable.tsx +++ b/frontend-modern/src/components/Storage/StoragePoolsTable.tsx @@ -9,7 +9,7 @@ import { } from '@/components/shared/Table'; import { getPlatformTableHeadClassForKind } from '@/features/platformPage/sharedPlatformPage'; import { - getStoragePoolColumnWidthPercent, + getStoragePoolColumnWidthStyle, getStoragePoolTableColumns, getStoragePoolTableLayoutModeForContainer, getStorageEmptyStateMessage, @@ -106,9 +106,7 @@ export const StoragePoolsTable: Component = (props) => { {(column) => ( )} diff --git a/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx b/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx index cfa8a0f5d0..babc7242a7 100644 --- a/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@solidjs/testing-library'; +import { fireEvent, render, screen, within } from '@solidjs/testing-library'; import { createSignal, type JSX } from 'solid-js'; import { describe, expect, it, vi } from 'vitest'; import { DiskDetail } from '@/components/Storage/DiskDetail'; @@ -135,6 +135,33 @@ describe('DiskDetail', () => { expect(screen.getByText('Reallocated Sectors')).toBeInTheDocument(); }); + it('shows the health verdict and its reason in the drawer header', () => { + const disk = buildDisk(); + disk.physicalDisk!.health = 'FAILED'; + + render(() => ); + + const health = screen.getByTestId('disk-detail-health'); + expect(within(health).getByText('Replace Now')).toHaveClass('text-red-700'); + expect(within(health).getByText('Disk health has degraded to a critical state.')).toHaveClass( + 'min-w-0', + 'max-w-full', + 'whitespace-normal', + 'break-words', + ); + }); + + it('keeps the drawer header to the verdict alone when the disk is healthy', () => { + const disk = buildDisk(); + disk.physicalDisk!.health = 'PASSED'; + + render(() => ); + + const health = screen.getByTestId('disk-detail-health'); + expect(within(health).getByText('Healthy')).toBeInTheDocument(); + expect(within(health).queryByText('No active disk-health issues.')).not.toBeInTheDocument(); + }); + it('shows an explicit overview fallback when no detail readings are available', () => { const disk = buildDisk(); delete disk.physicalDisk!.smart; diff --git a/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx b/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx index c8b0ad9432..ae75eb0693 100644 --- a/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx @@ -134,12 +134,18 @@ describe('DiskList', () => { 'archive', 'Cache Pool', 'Archive Pool', - 'SMART failed.', '4%', '63°C', ]) { expect(within(row as HTMLElement).getByText(text)).toBeInTheDocument(); } + // The reason is not a second truncating string in the 32px row: it rides + // on the verdict as its title and is spelled out in the disk drawer. + expect(within(row as HTMLElement).queryByText('SMART failed.')).not.toBeInTheDocument(); + expect(within(row as HTMLElement).getByText('Replace Now')).toHaveAttribute( + 'title', + 'SMART failed.', + ); expect(within(row as HTMLElement).getByText('3.64 TB')).toBeInTheDocument(); expect(screen.queryByText('Healthy')).not.toBeInTheDocument(); expect(screen.queryByText('96%')).not.toBeInTheDocument(); @@ -275,7 +281,13 @@ describe('DiskList', () => { expect(screen.getByText('Parity')).toBeInTheDocument(); expect(screen.getByText('Tower Array')).toBeInTheDocument(); expect(screen.getByText('Needs Attention')).toBeInTheDocument(); - expect(screen.getByText('Pending sectors detected.')).toBeInTheDocument(); + // The reason stays one hover away on the verdict rather than as a second + // truncating string in the row; the drawer spells it out. + expect(screen.queryByText('Pending sectors detected.')).not.toBeInTheDocument(); + expect(screen.getByText('Needs Attention')).toHaveAttribute( + 'title', + 'Pending sectors detected.', + ); }); it('renders SSD life and falls back to the Proxmox usage string for Belongs', () => { diff --git a/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx b/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx index e77acd0a3d..7c917328be 100644 --- a/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx @@ -1154,10 +1154,11 @@ describe('Storage', () => { render(() => ); + // zpool's upper-case state is presented titleized, like every other state. await waitFor(() => { - expect(screen.getAllByText('DEGRADED').length).toBeGreaterThan(0); + expect(screen.getAllByText('Degraded').length).toBeGreaterThan(0); }); - expect(screen.getAllByText('DEGRADED')[0]).toHaveAttribute( + expect(screen.getAllByText('Degraded')[0]).toHaveAttribute( 'title', '1 read, 2 checksum errors', ); diff --git a/frontend-modern/src/components/Storage/__tests__/useEnhancedStorageBarModel.test.ts b/frontend-modern/src/components/Storage/__tests__/useEnhancedStorageBarModel.test.ts index b3fc3721a0..18d67ca221 100644 --- a/frontend-modern/src/components/Storage/__tests__/useEnhancedStorageBarModel.test.ts +++ b/frontend-modern/src/components/Storage/__tests__/useEnhancedStorageBarModel.test.ts @@ -28,6 +28,7 @@ describe('useEnhancedStorageBarModel', () => { expect(result.usagePercent()).toBe(40); expect(result.label()).toBe('40% (400 B/1000 B)'); + expect(result.compactLabel()).toBe('40%'); expect(result.tooltipRows()).toEqual([ { label: 'Used', value: '400 B' }, { label: 'Free', value: '600 B' }, diff --git a/frontend-modern/src/components/Storage/useDiskDetailModel.ts b/frontend-modern/src/components/Storage/useDiskDetailModel.ts index 7ea36e2a61..2038b38433 100644 --- a/frontend-modern/src/components/Storage/useDiskDetailModel.ts +++ b/frontend-modern/src/components/Storage/useDiskDetailModel.ts @@ -7,6 +7,7 @@ import { } from '@/features/storageBackups/diskPresentation'; import { getDiskDetailAttributeCards, + getDiskDetailHealthPresentation, getDiskDetailHistoryCharts, } from '@/features/storageBackups/diskDetailPresentation'; import { useAlertsActivation } from '@/stores/alertsActivation'; @@ -29,6 +30,7 @@ export const useDiskDetailModel = (options: UseDiskDetailModelOptions) => { getDiskDetailAttributeCards(diskData(), getDiskTemperatureThresholds(diskData().type)), ); const historyCharts = createMemo(() => getDiskDetailHistoryCharts(diskData())); + const healthPresentation = createMemo(() => getDiskDetailHealthPresentation(diskData())); const metricResourceId = createMemo(() => historyResourceId()); const collectionMessages = createMemo(() => getPhysicalDiskCollectionMessages(diskData())); const liveIOAvailable = createMemo(() => { @@ -43,6 +45,7 @@ export const useDiskDetailModel = (options: UseDiskDetailModelOptions) => { historyResourceId, attributeCards, historyCharts, + healthPresentation, metricResourceId, collectionMessages, liveIOAvailable, diff --git a/frontend-modern/src/components/Storage/useEnhancedStorageBarModel.ts b/frontend-modern/src/components/Storage/useEnhancedStorageBarModel.ts index ddb6f141e7..06cf0aa3d7 100644 --- a/frontend-modern/src/components/Storage/useEnhancedStorageBarModel.ts +++ b/frontend-modern/src/components/Storage/useEnhancedStorageBarModel.ts @@ -3,6 +3,7 @@ import type { ZFSPool } from '@/types/api'; import { getMetricColorClass } from '@/utils/metricThresholds'; import type { MetricDisplayThresholds } from '@/utils/metricThresholds'; import { + getStorageBarCompactLabel, getStorageBarLabel, getStorageBarTooltipRows, getStorageBarUsagePercent, @@ -23,6 +24,7 @@ export const useEnhancedStorageBarModel = (options: UseEnhancedStorageBarModelOp getMetricColorClass(usagePercent(), 'disk', options.thresholds?.()), ); const label = createMemo(() => getStorageBarLabel(options.used(), options.total())); + const compactLabel = createMemo(() => getStorageBarCompactLabel(options.used(), options.total())); const tooltipRows = createMemo(() => getStorageBarTooltipRows(options.used(), options.free(), options.total()), ); @@ -32,6 +34,7 @@ export const useEnhancedStorageBarModel = (options: UseEnhancedStorageBarModelOp usagePercent, barColor, label, + compactLabel, tooltipRows, zfsSummary, }; diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx index 6e72824233..d6fb813c6b 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx @@ -1308,23 +1308,26 @@ describe('GUEST_COLUMNS', () => { }); it('derives mobile overrides from the canonical guest column model', () => { + // The phone name cell carries the toggle, status dot, and backup badge, so + // it takes a wider anchor than the shared 30% and the metric chips share + // the rest. expect(getGuestColumnStyle('name', true)).toEqual({ - width: '30%', - 'max-width': '30%', + width: '38%', + 'max-width': '38%', }); expect(getGuestColumnStyle('cpu', true)).toEqual({ - width: '11.3235%', - 'max-width': '11.3235%', + width: '10.0294%', + 'max-width': '10.0294%', }); expect(getGuestColumnStyle('availability', true)).toEqual({ - width: '7.2059%', - 'max-width': '7.2059%', + width: '6.3824%', + 'max-width': '6.3824%', }); expect(getGuestColumnStyle('type', true)).toEqual({ - width: '9.2647%', - 'max-width': '9.2647%', + width: '8.2059%', + 'max-width': '8.2059%', }); - expect(getGuestColumnWidthStyle('name', true)).toEqual({ width: '30%' }); + expect(getGuestColumnWidthStyle('name', true)).toEqual({ width: '38%' }); expect(getGuestColumnWidthStyle('diskIo', true)).toEqual({ width: '170px' }); }); diff --git a/frontend-modern/src/components/Workloads/guestRowModel.tsx b/frontend-modern/src/components/Workloads/guestRowModel.tsx index 40b81bf47e..0c7c67ea17 100644 --- a/frontend-modern/src/components/Workloads/guestRowModel.tsx +++ b/frontend-modern/src/components/Workloads/guestRowModel.tsx @@ -469,6 +469,13 @@ const percentageColumn = (width: string): GuestColumnWidthOverride => ({ maxWidth: width, }); +// The phone name cell carries the expansion toggle, status dot, and backup +// badge before the name, so the shared 30% identity anchor left a 390px phone +// 66px for the name and clipped "artifact-cache-310" to "artifact-ca…". The +// four metric chips beside it need far less than an even split, so the name +// takes the same wider phone anchor the Proxmox host table uses. +const GUEST_PHONE_IDENTITY_WIDTH_PERCENT = 38; + // Responsive weights are normalized against the currently visible column set. // That keeps each workload view mode full-width without assuming one fixed set. const GUEST_COLUMN_RESPONSIVE_WEIGHTS: Record< @@ -548,9 +555,11 @@ const getResponsiveColumnOverride = ( activeIds, layoutMode === 'narrow' ? { columnId: 'name', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT } - : layoutMode === 'phone' || layoutMode === 'mobile' - ? { columnId: 'name', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT } - : undefined, + : layoutMode === 'phone' + ? { columnId: 'name', widthPercent: GUEST_PHONE_IDENTITY_WIDTH_PERCENT } + : layoutMode === 'mobile' + ? { columnId: 'name', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT } + : undefined, ); return typeof style.width === 'string' ? percentageColumn(style.width) : undefined; }; diff --git a/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx index 8d7c18e9d3..3b64504700 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx @@ -16,6 +16,7 @@ import { formatPlatformTableDateTimeValue, formatPlatformTableRelativeTimeValue, formatPlatformTableTextValue, + getPlatformResourceCountNoun, getPlatformTableCellClassForKind, PlatformTableShell, type PlatformTableSortValue, @@ -43,15 +44,20 @@ const controllerName = (resource: Resource): string => const controllerKind = (resource: Resource): string => resource.kubernetes?.resourceKind || getResourceTypeLabel(resource.type) || resource.type; +const countedTarget = (count: number | undefined, noun: string): string => { + const value = count ?? 0; + return `${value} ${getPlatformResourceCountNoun(noun, value)}`; +}; + const targetValue = (resource: Resource): string => { switch (resource.type) { case 'k8s-replicaset': case 'k8s-statefulset': - return `${resource.kubernetes?.desiredReplicas ?? 0} pods`; + return countedTarget(resource.kubernetes?.desiredReplicas, 'pods'); case 'k8s-daemonset': - return `${resource.kubernetes?.desiredNumberScheduled ?? 0} nodes`; + return countedTarget(resource.kubernetes?.desiredNumberScheduled, 'nodes'); case 'k8s-job': - return `${resource.kubernetes?.desiredReplicas ?? 0} completions`; + return countedTarget(resource.kubernetes?.desiredReplicas, 'completions'); case 'k8s-cronjob': return formatPlatformTableTextValue(resource.kubernetes?.schedule); default: @@ -317,7 +323,7 @@ export const KubernetesControllersTable: Component<{ kind="text" sort={sort} sortKey="kind" - class="platform-table-mobile-w-15 md:w-[8%]" + class="platform-table-mobile-w-25 md:w-[8%]" > Kind @@ -329,11 +335,16 @@ export const KubernetesControllersTable: Component<{ > Scope + {/* The phone row keeps controller, kind, ready and issues. A + 15% kind track clipped "DaemonSet" and "StatefulSet" and the + target track clipped "1 completions", so target moves to the + row expansion, which also carries the schedule and job + timestamps the narrower layouts drop. */} Target @@ -365,7 +376,7 @@ export const KubernetesControllersTable: Component<{ kind="text" sort={sort} sortKey="exceptions" - class="platform-table-mobile-w-20 md:w-[11%]" + class="platform-table-mobile-w-30 md:w-[11%]" > @@ -431,7 +442,7 @@ export const KubernetesControllersTable: Component<{ {target()} diff --git a/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx b/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx index cf201f66f5..1823b37427 100644 --- a/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx +++ b/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx @@ -1,6 +1,16 @@ -import { cleanup, render, screen } from '@solidjs/testing-library'; +import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library'; import { afterEach, describe, expect, it, vi } from 'vitest'; +vi.mock('@/api/resources', () => ({ + ResourceAPI: { + getFacetBundle: vi.fn().mockResolvedValue({ + capabilities: [], + relationships: [], + recentChanges: [], + }), + }, +})); + import { formatPlatformTableDateTimeValue } from '@/features/platformPage/sharedPlatformPage'; import type { Resource } from '@/types/resource'; import { KubernetesControllersTable } from '../KubernetesControllersTable'; @@ -160,6 +170,117 @@ describe('KubernetesControllersTable', () => { expect(screen.queryByText(/2026-05-24T1[23]/)).not.toBeInTheDocument(); }); + it('keeps controller, kind, ready and issues on the phone row and moves target to the expansion', () => { + render(() => ( + } + emptyTitle="No controllers" + emptyDescription="No controllers" + showToolbar={false} + /> + )); + + // "DaemonSet" and "StatefulSet" clipped in a 15% kind track at 390px, and + // "1 completions" clipped in the target track, so the phone row widens kind + // and issues and demotes target to the row expansion. + expect(screen.getByText('Kind').closest('th')).toHaveClass('platform-table-mobile-w-25'); + expect(screen.getByText('Target').closest('th')).toHaveClass('platform-table-phone-hidden'); + expect(screen.getByText('6 nodes').closest('td')).toHaveClass('platform-table-phone-hidden'); + expect(screen.getByText('Exceptions').closest('th')).toHaveClass('platform-table-mobile-w-30'); + expect(screen.getByText('Controller').closest('th')).toHaveClass('platform-table-mobile-w-30'); + expect(screen.getByText('Ready/Done').closest('th')).toHaveClass('platform-table-mobile-w-15'); + expect(screen.getByText('Kind').closest('th')).not.toHaveClass('platform-table-phone-hidden'); + }); + + it('shows the absolute Job and CronJob times in the row expansion', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-24T13:31:00Z')); + + render(() => ( + } + emptyTitle="No controllers" + emptyDescription="No controllers" + showToolbar={false} + /> + )); + + // Tapping the row opens the expansion; the Detail column is hidden below a + // large container, so this is where a phone or half-width pane reads the + // completion time. + fireEvent.click(screen.getByText('nightly-import')); + const job = screen.getByTestId('resource-kubernetes-controller-section'); + expect(within(job).getByText('Started')).toBeInTheDocument(); + expect( + within(job).getByText(`${absoluteTime('2026-05-24T12:55:00Z')} (36m ago)`), + ).toBeInTheDocument(); + expect(within(job).getByText('Completed')).toBeInTheDocument(); + expect( + within(job).getByText(`${absoluteTime('2026-05-24T13:00:00Z')} (31m ago)`), + ).toBeInTheDocument(); + expect(within(job).getByText('Duration')).toBeInTheDocument(); + expect(within(job).getByText('5m')).toBeInTheDocument(); + expect(within(job).getByText('Target')).toBeInTheDocument(); + expect(within(job).getByText('10 completions')).toBeInTheDocument(); + expect(within(job).getByText('Failed').closest('tr')).toHaveTextContent('2'); + + fireEvent.click(screen.getByText('billing-rollup')); + const cron = screen.getByTestId('resource-kubernetes-controller-section'); + expect(within(cron).getByText('Schedule')).toBeInTheDocument(); + expect(within(cron).getByText('*/5 * * * *')).toBeInTheDocument(); + expect(within(cron).getByText('Last run')).toBeInTheDocument(); + expect( + within(cron).getByText(`${absoluteTime('2026-05-24T13:25:00Z')} (6m ago)`), + ).toBeInTheDocument(); + expect(within(cron).getByText('Last success')).toBeInTheDocument(); + expect( + within(cron).getByText(`${absoluteTime('2026-05-24T12:55:00Z')} (36m ago)`), + ).toBeInTheDocument(); + expect(within(cron).getByText('Namespace').closest('tr')).toHaveTextContent('batch'); + }); + it('falls back to start and last-run times when a Job or CronJob has not completed', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-05-24T13:31:00Z')); @@ -198,6 +319,9 @@ describe('KubernetesControllersTable', () => { /> )); + // A single desired completion reads as "1 completion", matching the + // expansion's Target row. + expect(screen.getByText('1 completion')).toBeInTheDocument(); expect(screen.getByText('Started 1m ago')).toHaveAttribute( 'title', `Started: ${absoluteTime('2026-05-24T13:29:30Z')}`, diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx index 313270eca0..79c59775f0 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx @@ -422,14 +422,10 @@ export function ProxmoxBackupServersTable(props: { } header={ <> - + - + @@ -449,20 +445,16 @@ export function ProxmoxBackupServersTable(props: { - + - + Used @@ -597,12 +589,14 @@ export function ProxmoxBackupServersTable(props: { > {(datastore) => (
- + + + diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx index dfdf028f97..530547b651 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx @@ -43,6 +43,7 @@ import { import { buildProxmoxBackupRecoveryModel, coverageRowMatchesSearch, + isBackupArtifact, recoverableArtifactMatchesSearch, type RecoverableArtifact, type WorkloadCoverageRow, @@ -347,17 +348,20 @@ export const ProxmoxBackupsTable: Component<{ return recoveryModel().coverageRows.flatMap((row) => { const artifacts = row.artifacts.filter(locationMatches); if (artifacts.length === 0) return []; + // Same rule as the model: running or failed artifacts stay listed but + // never become a "latest" pointer, and snapshots never become the backup. + const completed = artifacts.filter((artifact) => !artifact.running && !artifact.failed); return [ { ...row, artifacts, - latestRecovery: newestArtifact(artifacts), - latestPBS: newestArtifact(artifacts.filter((artifact) => artifact.sourceKind === 'pbs')), + latestBackup: newestArtifact(completed.filter(isBackupArtifact)), + latestPBS: newestArtifact(completed.filter((artifact) => artifact.sourceKind === 'pbs')), latestArchive: newestArtifact( - artifacts.filter((artifact) => artifact.sourceKind === 'archive'), + completed.filter((artifact) => artifact.sourceKind === 'archive'), ), latestSnapshot: newestArtifact( - artifacts.filter((artifact) => artifact.sourceKind === 'snapshot'), + completed.filter((artifact) => artifact.sourceKind === 'snapshot'), ), pbsCount: artifacts.filter((artifact) => artifact.sourceKind === 'pbs').length, archiveCount: artifacts.filter((artifact) => artifact.sourceKind === 'archive').length, @@ -385,7 +389,7 @@ export const ProxmoxBackupsTable: Component<{ case 'workload': return cmpString(a.workload.label, b.workload.label, direction); case 'latest': - return cmpNumber(a.latestRecovery?.createdMs, b.latestRecovery?.createdMs, direction); + return cmpNumber(a.latestBackup?.createdMs, b.latestBackup?.createdMs, direction); case 'pbs': return cmpNumber(a.latestPBS?.createdMs, b.latestPBS?.createdMs, direction); case 'archive': diff --git a/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx index c963a6e98d..5c58e4c8f7 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx @@ -50,6 +50,10 @@ import { useProxmoxBackupTableWindowing } from './useProxmoxBackupTableWindowing // "None" instead of restating the source ("No PVE backup file"). The long form // truncated at most widths and stays available as the hover title. const COVERAGE_EMPTY_LABEL = 'None'; +const COVERAGE_NO_BACKUP_TITLE = 'No PBS snapshot or PVE backup file'; +// Matches the posture engine's own wording for a snapshot-only subject. +const COVERAGE_SNAPSHOT_ONLY_TITLE = + 'No PBS snapshot or PVE backup file. Guest snapshots alone do not prove independent recovery.'; const coveragePostureVariant = ( posture: WorkloadCoverageRow['posture'], @@ -240,7 +244,13 @@ export function ProxmoxCoverageTable(props: { headClass={getPlatformTableHeadClassForKind('text')} /> + {COVERAGE_EMPTY_LABEL} } > - {(artifact) => } + {(artifact) => ( + + )} @@ -429,7 +451,12 @@ export function ProxmoxCoverageTable(props: { } > - {(artifact) => } + {(artifact) => ( + + )} @@ -703,7 +730,7 @@ export function ProxmoxCoverageTable(props: { > {artifact.detail || '—'} diff --git a/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx index 6da1dd7948..4c29fd190c 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx @@ -123,6 +123,9 @@ export function ProxmoxRecoverableTable(props: { artifact.workload.name || artifact.workload.label, artifact.workload.typeLabel, artifact.workload.vmid, + // The phone projection has no location column, so the row's + // hover title carries the repository instead. + columnVisible('location') ? undefined : artifact.location, ] .filter(Boolean) .join(' · ')} @@ -163,7 +166,7 @@ export function ProxmoxRecoverableTable(props: { - + @@ -189,7 +192,10 @@ export function ProxmoxRecoverableTable(props: { - + {artifact.detail || '—'} diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx index 50adeb3dd8..b9b7e75eaf 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx @@ -80,7 +80,7 @@ describe('ProxmoxCoverageTable column visibility', () => { expect(headers).toContain('Target ID'); expect(headers).toContain('Node'); expect(headers).toContain('Posture▲'); - expect(headers).toContain('Restore'); + expect(headers).toContain('Last backup'); expect(headers).toContain('PBS snapshot'); expect(headers).toContain('Guest snapshot'); expect(headers).not.toContain('PVE file'); @@ -239,7 +239,7 @@ describe('ProxmoxCoverageTable column visibility', () => { // cannot truncate to "No PVE backup f…". const empty = (title: string) => document.querySelector(`td span[title="${title}"]`); for (const title of [ - 'No restore point', + 'No PBS snapshot or PVE backup file', 'No PBS snapshot', 'No PVE backup file', 'No guest snapshot', diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx index 0dea167612..a8c0248ff5 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx @@ -55,12 +55,13 @@ describe('ProxmoxRecoverableTable responsive columns', () => { expect([...document.querySelectorAll('thead th')].map((th) => th.textContent?.trim())).toEqual([ 'Workload', 'Via', - 'Loc', 'Age▼', 'State', ]); expect(document.body.textContent).toContain('VM 100'); - expect(document.body.textContent).toContain('main / pve1'); + // The phone projection has no location column; the row title carries it. + expect(document.body.textContent).not.toContain('main / pve1'); + expect(document.querySelector('td [title*="main / pve1"]')).not.toBeNull(); expect(document.body.textContent).not.toContain('5 PBS files'); }); diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts index 8ae3acda04..6ab7884a78 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts @@ -522,7 +522,7 @@ describe('buildProxmoxBackupRecoveryModel summary, sort, and empty edges', () => expect(model.coverageSummary.attention).toBe(0); }); - it('sorts rows by postureRank asc, then latestRecovery createdMs desc', () => { + it('sorts rows by postureRank asc, then latestBackup createdMs desc', () => { const postures = new Map([ ['vm-830', posture('vm-830', 'unprotected')], ['vm-840', posture('vm-840', 'protected')], diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts index a7e222ac29..43fc7e5a41 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts @@ -162,6 +162,35 @@ describe('proxmoxBackupRecoveryModel', () => { ); }); + it('shows the archive format as its detail and keeps the volid on hover and in search', () => { + const model = buildProxmoxBackupRecoveryModel({ + workloads: [workload({})], + pbsBackups: [], + archives: [archive({ format: 'tar.zst' })], + snapshots: [], + tasks: [], + nowMs: Date.parse('2026-05-26T08:00:00Z'), + }); + + const [artifact] = model.recoverableArtifacts; + expect(artifact.detail).toBe('tar.zst'); + expect(artifact.detailTitle).toBe('local:backup/vzdump-lxc-112-2026_05_24-02_00_00.tar.zst'); + expect(recoverableArtifactMatchesSearch(artifact, 'vzdump-lxc-112')).toBe(true); + }); + + it('falls back to the archive file name when the provider reports no format', () => { + const model = buildProxmoxBackupRecoveryModel({ + workloads: [workload({})], + pbsBackups: [], + archives: [archive({ format: '' })], + snapshots: [], + tasks: [], + nowMs: Date.parse('2026-05-26T08:00:00Z'), + }); + + expect(model.recoverableArtifacts[0].detail).toBe('vzdump-lxc-112-2026_05_24-02_00_00.tar.zst'); + }); + it('keeps a terminally incomplete PBS artifact out of latest recovery', () => { const model = buildProxmoxBackupRecoveryModel({ workloads: [workload({})], @@ -186,10 +215,49 @@ describe('proxmoxBackupRecoveryModel', () => { const failed = model.recoverableArtifacts.find((artifact) => artifact.id.includes('failed')); expect(failed).toMatchObject({ failed: true, running: false, verified: undefined }); - expect(model.coverageRows[0].latestRecovery?.createdAt).toBe('2026-05-25T01:34:25Z'); + expect(model.coverageRows[0].latestBackup?.createdAt).toBe('2026-05-25T01:34:25Z'); expect(recoverableArtifactMatchesSearch(failed!, 'failed incomplete')).toBe(true); }); + it('keeps a fresh guest snapshot from standing in for the last backup', () => { + const model = buildProxmoxBackupRecoveryModel({ + workloads: [workload({})], + pbsBackups: [pbsBackup({ backupTime: '2026-05-05T01:34:25Z' })], + archives: [], + snapshots: [snapshot({ time: '2026-05-26T03:00:00Z' })], + tasks: [], + nowMs: Date.parse('2026-05-26T08:00:00Z'), + }); + + const row = model.coverageRows[0]; + expect(row.latestSnapshot?.createdAt).toBe('2026-05-26T03:00:00Z'); + expect(row.latestBackup?.sourceKind).toBe('pbs'); + expect(row.latestBackup?.createdAt).toBe('2026-05-05T01:34:25Z'); + expect( + getRecoveryAgeBand( + row.latestBackup?.createdMs, + model.coverageRows.length && Date.parse('2026-05-26T08:00:00Z'), + ), + ).toBe('aging'); + }); + + it('reports no last backup for a snapshot-only workload', () => { + const model = buildProxmoxBackupRecoveryModel({ + workloads: [workload({})], + pbsBackups: [], + archives: [], + snapshots: [snapshot()], + tasks: [], + nowMs: Date.parse('2026-05-26T08:00:00Z'), + }); + + const row = model.coverageRows[0]; + expect(row.latestBackup).toBeUndefined(); + expect(row.latestSnapshot?.sourceKind).toBe('snapshot'); + expect(row.artifacts).toHaveLength(1); + expect(model.coverageSummary.recoverableArtifacts).toBe(1); + }); + it('uses canonical workload attention while retaining the failed task as evidence', () => { const model = buildProxmoxBackupRecoveryModel({ workloads: [workload({})], diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts index 8817787792..a52bd7c075 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts @@ -44,10 +44,10 @@ describe('Proxmox backups responsive table presentation', () => { 'status', 'datastore', 'used', - 'backups', ]); expect(getBackupServerColumnWidthStyle('server', 'compact')).toEqual({ width: '40%' }); - expect(getBackupServerColumnWidthStyle('used', 'compact')).toEqual({ width: '20%' }); + expect(getBackupServerColumnWidthStyle('status', 'compact')).toEqual({ width: '23%' }); + expect(getBackupServerColumnWidthStyle('used', 'compact')).toEqual({ width: '15%' }); expect(ids(getBackupServerColumns('basic'))).toEqual([ 'server', 'status', @@ -124,12 +124,11 @@ describe('Proxmox backups responsive table presentation', () => { expect(ids(getRecoverableColumns('compact'))).toEqual([ 'workload', 'source', - 'location', 'created', 'state', ]); expect(getRecoverableColumnWidthStyle('workload', 'compact')).toEqual({ width: '40%' }); - expect(getRecoverableColumnWidthStyle('state', 'compact')).toEqual({ width: '16%' }); + expect(getRecoverableColumnWidthStyle('state', 'compact')).toEqual({ width: '30%' }); expect(ids(getRecoverableColumns('basic'))).toEqual([ 'workload', 'source', diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx index cb34f14569..3694f78460 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx @@ -8,8 +8,10 @@ import proxmoxBackupsTableSharedSource from '../proxmoxBackupsTableShared.tsx?ra import { ArtifactSourceBadge, ArtifactStateBadge, + ProxmoxBackupAgeText, ProxmoxBackupWorkloadTypeBadge, SortableHead, + formatCompactBackupAge, } from '../proxmoxBackupsTableShared'; import type { RecoverableArtifact } from '../proxmoxBackupRecoveryModel'; @@ -86,6 +88,28 @@ describe('proxmoxBackupsTableShared', () => { } }); + it('drops the age suffix in the phone projection and keeps the timestamp on hover', () => { + const createdAt = new Date(Date.now() - 18 * 60 * 60 * 1000).toISOString(); + expect(formatCompactBackupAge(createdAt)).toBe('18h'); + expect(formatCompactBackupAge(new Date().toISOString())).toBe('now'); + + const artifact = { + id: 'pbs:1', + nativeId: '1', + sourceKind: 'pbs', + sourceLabel: 'PBS', + workload: { key: 'w', type: 'vm', typeLabel: 'VM', vmid: '100', label: 'VM 100' }, + createdAt, + createdMs: Date.parse(createdAt), + location: 'main', + detail: '', + protected: false, + } as RecoverableArtifact; + render(() => ); + const age = screen.getByText('18h'); + expect(age.closest('[title]')?.getAttribute('title')).toContain(createdAt); + }); + it('keeps backup age text on the shared relative-time primitive', () => { expect(proxmoxBackupsTableSharedSource).toContain('PlatformTableRelativeTimeValue'); expect(proxmoxBackupsTableSharedSource).not.toContain('formatRelativeTime('); diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts index 1e23674fb7..305232fb26 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts @@ -19,16 +19,16 @@ describe('proxmoxHostTableModel', () => { expect(getProxmoxHostColumnWidthStyle('uptime', 'narrow', ids)).toEqual({ width: '15%' }); }); - it('shows the full operational metric track at phone width', () => { + it('matches the guest table columns and keeps node names whole at phone width', () => { const columns = getProxmoxHostVisibleColumnsForLayout('phone'); const ids = columns.map((column) => column.id); - expect(ids).toEqual(['node', 'cpu', 'memory', 'disk', 'temp', 'uptime']); + expect(ids).toEqual(['node', 'cpu', 'memory', 'disk', 'uptime']); expect(getProxmoxHostTableMinWidthClass('phone')).toBe('min-w-[0px]'); - expect(getProxmoxHostColumnWidthStyle('node', 'phone', ids)).toEqual({ width: '30%' }); - expect(getProxmoxHostColumnWidthStyle('disk', 'phone', ids)).toEqual({ width: '13.3824%' }); - expect(getProxmoxHostColumnWidthStyle('temp', 'phone', ids)).toEqual({ width: '15.4412%' }); - expect(getProxmoxHostColumnWidthStyle('uptime', 'phone', ids)).toEqual({ width: '14.4118%' }); + expect(getProxmoxHostColumnWidthStyle('node', 'phone', ids)).toEqual({ width: '38%' }); + expect(getProxmoxHostColumnWidthStyle('cpu', 'phone', ids)).toEqual({ width: '15.5%' }); + expect(getProxmoxHostColumnWidthStyle('disk', 'phone', ids)).toEqual({ width: '15.5%' }); + expect(getProxmoxHostColumnWidthStyle('uptime', 'phone', ids)).toEqual({ width: '15.5%' }); }); it('prioritizes live utilization columns in the mobile host table', () => { diff --git a/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts b/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts index c71846a6ae..b4288cf9f6 100644 --- a/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts +++ b/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts @@ -47,6 +47,8 @@ export interface RecoverableArtifact { /** Operator-facing source/repository label used by the backup-location filter. */ locationLabel?: string; detail: string; + /** Full provider identifier behind a shortened `detail`, shown on hover. */ + detailTitle?: string; protected: boolean; verified?: boolean; /** A still-running backup is writing this artifact; it is not recoverable yet. */ @@ -70,7 +72,13 @@ export interface WorkloadCoverageRow { key: string; workload: WorkloadReference; artifacts: RecoverableArtifact[]; - latestRecovery?: RecoverableArtifact; + /** + * Newest completed PBS snapshot or PVE backup file. Guest snapshots never + * fill this slot: they live on the guest's own storage and the protection + * posture engine does not count them as independent recovery, so a fresh + * snapshot must not read as a fresh backup beside a stale posture. + */ + latestBackup?: RecoverableArtifact; latestPBS?: RecoverableArtifact; latestArchive?: RecoverableArtifact; latestSnapshot?: RecoverableArtifact; @@ -138,12 +146,25 @@ export function getRecoveryAgeBand( return 'stale'; } +// A guest snapshot shares the guest's storage, so it is listed as restore +// evidence but never counted as a backup. Mirrors the server posture rule that +// "snapshots alone do not prove independent recovery". +export function isBackupArtifact(artifact: RecoverableArtifact): boolean { + return artifact.sourceKind !== 'snapshot'; +} + function parseTimestampMs(value: string | undefined): number | undefined { if (!value) return undefined; const ms = Date.parse(value); return Number.isFinite(ms) ? ms : undefined; } +function archiveFileName(volid: string | undefined): string { + const trimmed = volid?.trim() ?? ''; + if (!trimmed) return ''; + return trimmed.slice(trimmed.lastIndexOf('/') + 1); +} + function pbsBackupFileDetailLabel(fileCount: number): string { if (fileCount <= 0) return 'PBS files not listed'; if (fileCount === 1) return '1 PBS file'; @@ -572,7 +593,12 @@ export function buildProxmoxBackupRecoveryModel( size: archive.size, location: archive.storage || archive.node || '—', ...artifactLocationScope('archive', archive.instance, archive.storage || archive.node), - detail: archive.volid || archive.format || archiveSource.detailFallbackLabel, + // The volid repeats the storage, guest type, VMID, and timestamp the row + // already shows in their own columns and clips at every width; the + // archive format is the one fact it adds, so that is the cell and the + // volid stays on hover and in search. + detail: archive.format || archiveFileName(archive.volid) || archiveSource.detailFallbackLabel, + detailTitle: archive.volid || undefined, protected: archive.protected, verified: archive.inProgress ? undefined : archive.isPBS ? archive.verified : undefined, running: archive.inProgress === true, @@ -626,6 +652,7 @@ export function buildProxmoxBackupRecoveryModel( // the "latest" pointers (which answer "what can I recover to?") skip them. // The artifacts themselves stay listed with an honest state. const completed = row.artifacts.filter((artifact) => !artifact.running && !artifact.failed); + row.latestBackup = newest(completed.filter(isBackupArtifact), (artifact) => artifact.createdMs); row.latestPBS = newest( completed.filter((artifact) => artifact.sourceKind === 'pbs'), (artifact) => artifact.createdMs, @@ -638,7 +665,6 @@ export function buildProxmoxBackupRecoveryModel( completed.filter((artifact) => artifact.sourceKind === 'snapshot'), (artifact) => artifact.createdMs, ); - row.latestRecovery = newest(completed, (artifact) => artifact.createdMs); } const coverageRows = Array.from(rows.values()).map((row) => { @@ -665,7 +691,7 @@ export function buildProxmoxBackupRecoveryModel( coverageRows.sort((left, right) => { if (left.postureRank !== right.postureRank) return left.postureRank - right.postureRank; - return (right.latestRecovery?.createdMs ?? 0) - (left.latestRecovery?.createdMs ?? 0); + return (right.latestBackup?.createdMs ?? 0) - (left.latestBackup?.createdMs ?? 0); }); artifacts.sort((left, right) => (right.createdMs ?? 0) - (left.createdMs ?? 0)); @@ -730,6 +756,7 @@ export function recoverableArtifactMatchesSearch( artifact.sourceTitle, artifact.location, artifact.detail, + artifact.detailTitle, artifact.verified === true ? 'verified' : artifact.verified === false diff --git a/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts b/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts index b1342d3396..92e3e2f3a0 100644 --- a/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts +++ b/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts @@ -93,8 +93,10 @@ const BACKUP_SERVER_VISIBLE: Record< > = { // Reachability and datastore exhaustion are the two risks that can stop all // future backups. Host telemetry and space-efficiency context return only as - // the table gains enough room to keep those headline answers readable. - compact: ['server', 'status', 'datastore', 'used', 'backups'], + // the table gains enough room to keep those headline answers readable. The + // phone projection keeps those two answers whole ("Healthy", "33.6%") and + // lets the backup count return with the first wider layout. + compact: ['server', 'status', 'datastore', 'used'], basic: ['server', 'status', 'datastore', 'used', 'backups'], operational: ['server', 'status', 'cpu', 'memory', 'datastore', 'used', 'backups'], expanded: ['server', 'status', 'cpu', 'memory', 'uptime', 'datastore', 'used', 'backups'], @@ -105,8 +107,10 @@ const BACKUP_SERVER_WEIGHTS: Record< ProxmoxBackupsTableLayoutMode, Partial> > = { - compact: { server: 40, status: 15, datastore: 15, used: 20, backups: 10 }, - basic: { server: 24, status: 14, datastore: 20, used: 28, backups: 14 }, + compact: { server: 40, status: 23, datastore: 22, used: 15 }, + // Just above the phone projection the used cell carries its used/total + // pair again, the longest value in the row, so it takes the most room. + basic: { server: 24, status: 13.6, datastore: 18.2, used: 28.4, backups: 9.8 }, operational: { server: 20, status: 12, @@ -175,7 +179,7 @@ export const COVERAGE_COLUMNS: readonly BackupTableColumn[] = { id: 'targetId', label: 'Target ID', kind: 'text' }, { id: 'node', label: 'Node', kind: 'text' }, { id: 'posture', label: 'Posture', kind: 'text' }, - { id: 'latest', label: 'Restore', kind: 'numeric-value' }, + { id: 'latest', label: 'Last backup', kind: 'numeric-value' }, { id: 'pbs', label: 'PBS snapshot', kind: 'text' }, { id: 'archive', label: 'PVE file', kind: 'text' }, { id: 'snapshot', label: 'Guest snapshot', kind: 'text' }, @@ -184,7 +188,7 @@ export const COVERAGE_COLUMNS: readonly BackupTableColumn[] = const COVERAGE_VISIBLE: Record = { // On narrow surfaces answer: which workload, what posture, how recent is the - // newest restore point, and did the latest task succeed? Provider-by-provider + // newest independent backup, and did the latest task succeed? Provider-by-provider // evidence is progressive detail in the expansion row; target identity folds // beneath the name so the scan stays legible at 320px. compact: ['workload', 'posture', 'latest', 'pbs', 'task'], @@ -198,11 +202,14 @@ const COVERAGE_WEIGHTS: Record< ProxmoxBackupsTableLayoutMode, Partial> > = { - compact: { workload: 40, posture: 18, latest: 16, pbs: 16, task: 10 }, + // Posture keeps its whole compact word ("Unknown"), the two age cells hold + // the suffix-free compact age, and the job dot takes what a "Job" header + // needs. + compact: { workload: 40, posture: 21, latest: 14.5, pbs: 14.5, task: 10 }, // Above the phone projection the identity columns (workload, node) take the // slack that the short type badge, target id, and age cells cannot use, so // names stay whole instead of truncating beside empty space. - basic: { workload: 31, node: 20, posture: 20, latest: 14, task: 15 }, + basic: { workload: 31, node: 18, posture: 22, latest: 15, task: 14 }, operational: { workload: 27, type: 8, node: 17, posture: 17, latest: 15, task: 16 }, expanded: { workload: 19, @@ -287,7 +294,10 @@ const RECOVERABLE_VISIBLE: Record getRecoveryAgeBand(props.artifact.createdMs); const title = () => { const parts = [recoveryAgeTitleByBand[band()], props.artifact.createdAt].filter(Boolean); @@ -228,7 +239,12 @@ export function ProxmoxBackupAgeText(props: { artifact: RecoverableArtifact }) { return ( - + } + > + {formatCompactBackupAge(props.artifact.createdAt)} + ); } diff --git a/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts b/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts index b053bdb00f..1a9d7db7cd 100644 --- a/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts +++ b/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts @@ -31,7 +31,7 @@ const HOST_COLUMN_MIN_LAYOUT: Record = { - node: 18, - version: 7, + node: 20, + version: 6, uptime: 7, cpu: 13, memory: 13, @@ -55,7 +55,7 @@ const HOST_COLUMN_DESKTOP_WIDTHS: Record = { temp: 6, vms: 5, cts: 5, - cluster: 13, + cluster: 12, }; const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record< @@ -69,13 +69,15 @@ const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record< disk: 15, uptime: 15, }, + // Phones carry the same five columns as the guest table below them. A sixth + // (temperature) left the node name with four characters, which made a + // cluster of "Analytics A".."F" rows unreadable. phone: { node: 32, - cpu: 13, - memory: 13, - disk: 13, - temp: 15, - uptime: 14, + cpu: 15, + memory: 15, + disk: 15, + uptime: 15, }, mobile: { node: 32, @@ -146,6 +148,11 @@ export const getProxmoxHostVisibleColumnsForLayout = ( ); }; +// The node cell carries the expansion toggle and status dot before the name, +// so it needs more than the shared 30% phone identity anchor to show a +// typical node name whole. +const PROXMOX_HOST_PHONE_IDENTITY_WIDTH_PERCENT = 38; + export const getProxmoxHostColumnWidthStyle = ( columnId: ProxmoxHostTableColumnId, layoutMode: WorkloadTableLayoutMode, @@ -159,9 +166,11 @@ export const getProxmoxHostColumnWidthStyle = ( visibleColumnIds, layoutMode === 'narrow' ? { columnId: 'node', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT } - : layoutMode === 'phone' || layoutMode === 'mobile' - ? { columnId: 'node', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT } - : undefined, + : layoutMode === 'phone' + ? { columnId: 'node', widthPercent: PROXMOX_HOST_PHONE_IDENTITY_WIDTH_PERCENT } + : layoutMode === 'mobile' + ? { columnId: 'node', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT } + : undefined, ); }; diff --git a/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts index 8edf3ca711..89a8caacb3 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts @@ -4,6 +4,7 @@ import { DISK_DETAIL_LIVE_CHARTS, getDiskAttributeValueTextClass, getDiskDetailAttributeCards, + getDiskDetailHealthPresentation, getDiskDetailHistoryCharts, getDiskDetailHistoryFallbackMessage, getDiskDetailLiveBadgeLabel, @@ -34,6 +35,41 @@ describe('diskDetailPresentation', () => { ); }); + it('carries the row verdict and its reason into the drawer header', () => { + const disk = { + node: 'tower', + instance: 'cluster-main', + devPath: '/dev/sda', + model: 'Archive HDD', + serial: 'SERIAL-1', + wwn: '', + size: 1, + health: 'PASSED', + wearout: -1, + type: 'hdd', + temperature: 42, + rpm: 7200, + used: '', + riskReasons: [] as string[], + }; + expect(getDiskDetailHealthPresentation(disk)).toEqual({ + label: 'Healthy', + summary: '', + tone: 'text-emerald-700 dark:text-emerald-300', + }); + expect( + getDiskDetailHealthPresentation({ + ...disk, + health: 'FAILED', + riskReasons: ['SMART failed.'], + }), + ).toEqual({ + label: 'Replace Now', + summary: 'SMART failed.', + tone: 'text-red-700 dark:text-red-300', + }); + }); + it('builds canonical SATA and NVMe attribute cards', () => { expect( getDiskDetailAttributeCards({ diff --git a/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts index 4d177523c1..b6823bd0f7 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts @@ -19,9 +19,11 @@ import { PHYSICAL_DISK_TABLE_CLASS, PHYSICAL_DISK_TABLE_ROW_HOVER_CLASS, getPhysicalDiskEmptyStatePresentation, - getPhysicalDiskColumnWidthPercent, + getPhysicalDiskCellPaddingClass, + getPhysicalDiskColumnWidthStyle, getPhysicalDiskCollectionMessages, getPhysicalDiskFieldStatusMessage, + getPhysicalDiskHealthCompactLabel, getPhysicalDiskHealthStatus, getPhysicalDiskHealthSummary, getPhysicalDiskHostLabel, @@ -158,14 +160,55 @@ describe('diskPresentation', () => { ), ), ).toBe(true); - expect(getPhysicalDiskColumnWidthPercent('compact', 'disk')).toBe(33); - expect(getPhysicalDiskColumnWidthPercent('compact', 'size')).toBe(14); - expect(getPhysicalDiskColumnWidthPercent('operational', 'life')).toBe(9); - expect(getPhysicalDiskColumnWidthPercent('compact', 'device')).toBe(0); + expect(getPhysicalDiskColumnWidthStyle('compact', 'disk')).toEqual({ width: '34%' }); + expect(getPhysicalDiskColumnWidthStyle('compact', 'temp')).toEqual({ width: '12%' }); + expect(getPhysicalDiskColumnWidthStyle('compact', 'size')).toEqual({ width: '15%' }); + expect(getPhysicalDiskColumnWidthStyle('operational', 'life')).toEqual({ width: '9%' }); + expect(getPhysicalDiskColumnWidthStyle('compact', 'device')).toEqual({ width: '0%' }); expect(isPhysicalDiskColumnVisible('narrow', 'life')).toBe(false); expect(isPhysicalDiskColumnVisible('narrow', 'temp')).toBe(true); - expect(getPhysicalDiskColumnWidthPercent('narrow', 'disk')).toBe(40); - expect(getPhysicalDiskColumnWidthPercent('narrow', 'size')).toBe(15); + expect(getPhysicalDiskColumnWidthStyle('narrow', 'disk')).toEqual({ width: '41%' }); + expect(getPhysicalDiskColumnWidthStyle('narrow', 'temp')).toEqual({ width: '12%' }); + expect(getPhysicalDiskColumnWidthStyle('narrow', 'size')).toEqual({ width: '16%' }); + }); + + it('keeps the desktop disk column widths on the canonical weighted helper', () => { + const columns = [ + 'disk', + 'device', + 'host', + 'role', + 'parent', + 'health', + 'life', + 'temp', + 'size', + ] as const; + expect(columns.map((column) => getPhysicalDiskColumnWidthStyle('full', column).width)).toEqual([ + '19%', + '9%', + '10%', + '8%', + '12%', + '17%', + '6%', + '7%', + '12%', + ]); + }); + + it('sheds the shared cell padding only on the phone disk layouts', () => { + expect(getPhysicalDiskCellPaddingClass('narrow')).toBe('!px-1'); + expect(getPhysicalDiskCellPaddingClass('compact')).toBe('!px-1'); + expect(getPhysicalDiskCellPaddingClass('basic')).toBe(''); + expect(getPhysicalDiskCellPaddingClass('full')).toBe(''); + }); + + it('shortens only the health words that cannot fit a phone health column', () => { + expect(getPhysicalDiskHealthCompactLabel('Needs Attention')).toBe('Attention'); + expect(getPhysicalDiskHealthCompactLabel('Replace Now')).toBe('Replace'); + expect(getPhysicalDiskHealthCompactLabel('Healthy')).toBe('Healthy'); + expect(getPhysicalDiskHealthCompactLabel('Unknown')).toBe('Unknown'); }); it('detects SMART warnings from counters', () => { diff --git a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov0712b.test.ts b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov0712b.test.ts index 99ee1f17b7..a23aa645a0 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov0712b.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov0712b.test.ts @@ -219,12 +219,12 @@ describe('getStoragePoolStateLabel branch coverage', () => { expect(getStoragePoolStateLabel(record)).toBe('Online'); }); - it('returns the raw pool state when it is anything other than "ONLINE"', () => { + it('titleizes a non-ONLINE zfs pool state like every other state path', () => { const record = { ...baseRecord(), details: { zfsPool: { state: 'FAULTED', devices: [] } }, } as unknown as StorageRecord; - expect(getStoragePoolStateLabel(record)).toBe('FAULTED'); + expect(getStoragePoolStateLabel(record)).toBe('Faulted'); }); it('skips a zfs pool whose state is the empty string (pool?.state truthiness guard false)', () => { diff --git a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov2.test.ts b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov2.test.ts index 0c2c8f0b59..27d8fff8e6 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov2.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov2.test.ts @@ -239,7 +239,7 @@ describe('getStoragePoolStateLabel branch coverage', () => { expect(getStoragePoolStateLabel(record)).toBe('Online'); }); - it('returns the raw pool state when it is anything other than "ONLINE"', () => { + it('titleizes a non-ONLINE zfs pool state like every other state path', () => { const record = { ...baseRecord(), details: { @@ -249,7 +249,7 @@ describe('getStoragePoolStateLabel branch coverage', () => { }, }, } as unknown as StorageRecord; - expect(getStoragePoolStateLabel(record)).toBe('FAULTED'); + expect(getStoragePoolStateLabel(record)).toBe('Faulted'); }); it('titleizes the derived status when arrayState and pool are both absent', () => { diff --git a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.test.ts index 1afe8dff92..8787f8637d 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.test.ts @@ -101,6 +101,47 @@ describe('storage row presentation', () => { expect(getCompactStoragePoolProtectionTitle(record)).not.toContain('without parity'); }); + it('shows a zfs rebuild as its activity word and keeps the sentence as the title', () => { + const resilvering = { + ...baseRecord(), + rebuildInProgress: true, + protectionLabel: 'ZFS pool tank is resilvering', + details: { + zfsPool: { state: 'DEGRADED', scan: 'resilver in progress', devices: [] }, + }, + } as unknown as StorageRecord; + expect(getCompactStoragePoolProtectionLabel(resilvering)).toBe('Resilvering'); + expect(getCompactStoragePoolProtectionTitle(resilvering)).toBe('ZFS pool tank is resilvering'); + + const scrubbing = { + ...baseRecord(), + rebuildInProgress: true, + protectionLabel: 'ZFS pool tank scrub is running (45.2%)', + protectionSummary: 'ZFS pool tank scrub is running (45.2%)', + details: { + zfsPool: { + state: 'ONLINE', + scan: '', + scanDetails: { function: 'SCRUB', state: 'SCANNING', percentage: 45.2 }, + devices: [], + }, + }, + } as unknown as StorageRecord; + expect(getCompactStoragePoolProtectionLabel(scrubbing)).toBe('Scrubbing 45%'); + expect(getCompactStoragePoolProtectionTitle(scrubbing)).toBe( + 'ZFS pool tank scrub is running (45.2%)', + ); + + // A rebuild without zfs scan data keeps the label its platform supplied. + const parity = { + ...baseRecord(), + rebuildInProgress: true, + protectionLabel: 'Parity sync 12%', + } as unknown as StorageRecord; + expect(getCompactStoragePoolProtectionLabel(parity)).toBe('Parity sync 12%'); + expect(getCompactStoragePoolProtectionTitle(parity)).toBe('Parity sync 12%'); + }); + it('derives zfs issue fallback from pool state and errors', () => { const record = { ...baseRecord(), diff --git a/frontend-modern/src/features/storageBackups/__tests__/storageBarPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storageBarPresentation.test.ts index a2b1c04bed..86ec88080b 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storageBarPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storageBarPresentation.test.ts @@ -13,6 +13,7 @@ import { STORAGE_BAR_ZFS_SECTION_CLASS, STORAGE_BAR_ZFS_STATE_LABEL_CLASS, STORAGE_BAR_ZFS_STATE_ROW_CLASS, + getStorageBarCompactLabel, getStorageBarLabel, getStorageBarTooltipRowClass, getStorageBarTooltipRows, @@ -39,6 +40,7 @@ describe('storageBarPresentation', () => { expect(STORAGE_BAR_ZFS_STATE_LABEL_CLASS).toBe('text-slate-400'); expect(getStorageBarUsagePercent(40, 100)).toBe(40); expect(getStorageBarLabel(40, 100)).toBe('40% (40.0 B/100 B)'); + expect(getStorageBarCompactLabel(40, 100)).toBe('40%'); expect(getStorageBarTooltipTitle()).toBe('Storage Details'); expect(getStorageBarTooltipRowClass()).toBe('flex justify-between gap-3 py-0.5 '); expect(getStorageBarTooltipRowClass(true)).toBe( diff --git a/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts index a8971d42b9..b909d2e8f5 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { - getStoragePoolColumnWidthPercent, + getStoragePoolCellPaddingClass, + getStoragePoolColumnWidthStyle, getStoragePoolTableColumns, getStoragePoolTableLayoutModeForContainer, getStorageTableHeading, @@ -46,22 +47,44 @@ describe('storagePagePresentation', () => { expect(getStoragePoolTableLayoutModeForContainer(0)).toBe('compact'); expect(getStoragePoolTableLayoutModeForContainer(359)).toBe('narrow'); expect(getStoragePoolTableLayoutModeForContainer(360)).toBe('compact'); - expect(getStoragePoolTableLayoutModeForContainer(559)).toBe('compact'); - expect(getStoragePoolTableLayoutModeForContainer(560)).toBe('operational'); + // The compact/operational boundary is the shared phone container query + // (34rem), so the usage bar label and the column set change together. + expect(getStoragePoolTableLayoutModeForContainer(543)).toBe('compact'); + expect(getStoragePoolTableLayoutModeForContainer(544)).toBe('operational'); expect(getStoragePoolTableLayoutModeForContainer(1_039)).toBe('operational'); expect(getStoragePoolTableLayoutModeForContainer(1_040)).toBe('full'); expect(isStoragePoolColumnVisible('operational', 'host')).toBe(true); expect(isStoragePoolColumnVisible('operational', 'growth')).toBe(false); - expect(getStoragePoolColumnWidthPercent('operational', 'usage')).toBe(21); + expect(getStoragePoolColumnWidthStyle('operational', 'usage')).toEqual({ width: '25%' }); + expect(getStoragePoolColumnWidthStyle('operational', 'name')).toEqual({ width: '27%' }); expect(isStoragePoolColumnVisible('compact', 'host')).toBe(true); expect(isStoragePoolColumnVisible('compact', 'type')).toBe(false); expect(isStoragePoolColumnVisible('compact', 'protection')).toBe(false); - expect(getStoragePoolColumnWidthPercent('compact', 'name')).toBe(35); - expect(getStoragePoolColumnWidthPercent('compact', 'usage')).toBe(25); - expect(getStoragePoolColumnWidthPercent('compact', 'growth')).toBe(0); + expect(getStoragePoolColumnWidthStyle('compact', 'name')).toEqual({ width: '37%' }); + expect(getStoragePoolColumnWidthStyle('compact', 'host')).toEqual({ width: '31.5%' }); + expect(getStoragePoolColumnWidthStyle('compact', 'usage')).toEqual({ width: '13%' }); + expect(getStoragePoolColumnWidthStyle('compact', 'growth')).toEqual({ width: '0%' }); expect(isStoragePoolColumnVisible('narrow', 'type')).toBe(false); expect(isStoragePoolColumnVisible('narrow', 'protection')).toBe(false); - expect(getStoragePoolColumnWidthPercent('narrow', 'name')).toBe(40); - expect(getStoragePoolColumnWidthPercent('narrow', 'usage')).toBe(20); + expect(getStoragePoolColumnWidthStyle('narrow', 'name')).toEqual({ width: '35%' }); + expect(getStoragePoolColumnWidthStyle('narrow', 'usage')).toEqual({ width: '13%' }); + }); + + it('sheds the desktop cell gutter only on the phone pool layouts', () => { + expect(getStoragePoolCellPaddingClass('narrow')).toBe('!px-1'); + expect(getStoragePoolCellPaddingClass('compact')).toBe('!px-1'); + expect(getStoragePoolCellPaddingClass('operational')).toBe(''); + expect(getStoragePoolCellPaddingClass('full')).toBe(''); + }); + + it('keeps the desktop pool column widths on the canonical weighted helper', () => { + const fullWidths = ['name', 'state', 'type', 'host', 'protection', 'usage', 'growth'].map( + (column) => + getStoragePoolColumnWidthStyle( + 'full', + column as 'name' | 'state' | 'type' | 'host' | 'protection' | 'usage' | 'growth', + ).width, + ); + expect(fullWidths).toEqual(['20%', '14%', '10%', '12%', '13%', '20%', '11%']); }); }); diff --git a/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts b/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts index 12cc6a8f12..449020cb8f 100644 --- a/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts +++ b/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts @@ -1,4 +1,8 @@ -import type { PhysicalDiskPresentationData } from '@/features/storageBackups/diskPresentation'; +import { + getPhysicalDiskHealthStatus, + getPhysicalDiskHealthSummary, + type PhysicalDiskPresentationData, +} from '@/features/storageBackups/diskPresentation'; import type { HistoryTimeRange } from '@/api/charts'; import { formatPowerOnHours } from '@/utils/format'; import { getMetricSeverity, type MetricDisplayThresholds } from '@/utils/metricThresholds'; @@ -78,6 +82,32 @@ export const getDiskDetailLiveBadgeLabel = (): string => 'Real-time'; export const getDiskDetailHistoryFallbackMessage = (): string => 'Historical disk charts are unavailable until Pulse can resolve a stable identity for this disk.'; +export type DiskDetailHealthPresentation = { + label: string; + summary: string; + tone: string; +}; + +// The drawer header carries the disk's verdict and its reason. The table row +// shows only the verdict word, so this is where the reason is read in full. +export const DISK_DETAIL_HEADER_STACK_CLASS = 'min-w-0 space-y-1'; +export const DISK_DETAIL_HEALTH_ROW_CLASS = + 'flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-[11px]'; +export const DISK_DETAIL_HEALTH_LABEL_CLASS = 'font-semibold'; +export const DISK_DETAIL_HEALTH_SUMMARY_CLASS = + 'min-w-0 max-w-full whitespace-normal break-words text-muted'; + +export function getDiskDetailHealthPresentation( + disk: PhysicalDiskPresentationData, +): DiskDetailHealthPresentation { + const status = getPhysicalDiskHealthStatus(disk); + return { + label: status.label, + summary: getPhysicalDiskHealthSummary(status), + tone: status.tone, + }; +} + export function getDiskDetailAttributeCards( disk: PhysicalDiskPresentationData, diskTempThresholds?: MetricDisplayThresholds | null, diff --git a/frontend-modern/src/features/storageBackups/diskPresentation.ts b/frontend-modern/src/features/storageBackups/diskPresentation.ts index 9cf9707e53..5d4aee37ca 100644 --- a/frontend-modern/src/features/storageBackups/diskPresentation.ts +++ b/frontend-modern/src/features/storageBackups/diskPresentation.ts @@ -1,8 +1,10 @@ +import type { JSX } from 'solid-js'; import type { PhysicalDiskCollectionStatus, PhysicalDiskFieldStatus, Resource, } from '@/types/resource'; +import { getPlatformTableWeightedColumnWidthStyle } from '@/features/platformPage/sharedPlatformPage'; import { getSourcePlatformLabel, getSourcePlatformPresentation, @@ -141,12 +143,16 @@ const PHYSICAL_DISK_VISIBLE_COLUMNS: Record< full: ['disk', 'device', 'host', 'role', 'parent', 'health', 'life', 'temp', 'size'], }; -const PHYSICAL_DISK_COLUMN_WIDTHS: Record< +// Relative weights per layout, resolved through the canonical weighted-width +// helper. The phone layouts are sized from the measured values they show: +// a temperature ("100°C"), a capacity ("5.46 TB"), a compact health word and +// a short node name must each fit whole, and the disk model takes the rest. +const PHYSICAL_DISK_COLUMN_WEIGHTS: Record< PhysicalDiskTableLayoutMode, Partial> > = { - narrow: { disk: 40, host: 15, health: 20, temp: 10, size: 15 }, - compact: { disk: 33, host: 15, health: 18, life: 10, temp: 10, size: 14 }, + narrow: { disk: 41, host: 12, health: 19, temp: 12, size: 16 }, + compact: { disk: 34, host: 11, health: 17, life: 11, temp: 12, size: 15 }, basic: { disk: 33, host: 17, health: 22, temp: 12, size: 16 }, operational: { disk: 25, host: 12, parent: 15, health: 17, life: 9, temp: 9, size: 13 }, expanded: { disk: 22, host: 10, role: 9, parent: 14, health: 15, life: 8, temp: 8, size: 14 }, @@ -168,10 +174,31 @@ export const isPhysicalDiskColumnVisible = ( columnId: PhysicalDiskTableColumnId, ): boolean => PHYSICAL_DISK_VISIBLE_COLUMNS[layout].includes(columnId); -export const getPhysicalDiskColumnWidthPercent = ( +export const getPhysicalDiskColumnWidthStyle = ( layout: PhysicalDiskTableLayoutMode, columnId: PhysicalDiskTableColumnId, -): number => PHYSICAL_DISK_COLUMN_WIDTHS[layout][columnId] ?? 0; +): JSX.CSSProperties => + getPlatformTableWeightedColumnWidthStyle( + columnId, + PHYSICAL_DISK_COLUMN_WEIGHTS[layout], + PHYSICAL_DISK_VISIBLE_COLUMNS[layout], + ); + +// The shared table cell primitives pad every cell for desktop density. On the +// phone layouts that padding alone is a fifth of the row, so rendered cells +// and headers shed it the same way the Proxmox replication table does. +export const PHYSICAL_DISK_PHONE_CELL_PADDING_CLASS = '!px-1'; + +export const getPhysicalDiskCellPaddingClass = (layout: PhysicalDiskTableLayoutMode): string => + layout === 'narrow' || layout === 'compact' ? PHYSICAL_DISK_PHONE_CELL_PADDING_CLASS : ''; + +// Health words that do not fit a phone-width health column fall back to a +// shorter form; the full label stays in the wider projections and the detail. +export const getPhysicalDiskHealthCompactLabel = (label: string): string => { + if (label === 'Needs Attention') return 'Attention'; + if (label === 'Replace Now') return 'Replace'; + return label; +}; export const PHYSICAL_DISK_COL_DISK_CLASS = ''; export const PHYSICAL_DISK_COL_DEVICE_CLASS = ''; @@ -221,8 +248,10 @@ export const PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS = 'text-[11px] text-muted'; export const PHYSICAL_DISK_LIFE_CLASS = 'text-[11px] font-medium'; export const PHYSICAL_DISK_HEALTH_WRAP_CLASS = 'flex min-w-0 items-center gap-1.5 whitespace-nowrap'; -export const PHYSICAL_DISK_HEALTH_LABEL_CLASS = 'min-w-0 truncate text-[11px] font-semibold'; -export const PHYSICAL_DISK_HEALTH_SUMMARY_CLASS = 'hidden xl:block truncate text-[11px] text-muted'; +// The health word is the row's verdict and always renders whole. Its reason +// lives in the cell title and in the disk drawer, not in a truncating sibling +// that would compete with it for the track. +export const PHYSICAL_DISK_HEALTH_LABEL_CLASS = 'shrink-0 text-[11px] font-semibold'; export const PHYSICAL_DISK_TEMPERATURE_CLASS = 'text-[11px] font-medium'; export const PHYSICAL_DISK_SIZE_VALUE_CLASS = 'text-[11px] text-base-content'; diff --git a/frontend-modern/src/features/storageBackups/rowPresentation.ts b/frontend-modern/src/features/storageBackups/rowPresentation.ts index 10e4c68f7a..f9bacbab41 100644 --- a/frontend-modern/src/features/storageBackups/rowPresentation.ts +++ b/frontend-modern/src/features/storageBackups/rowPresentation.ts @@ -66,9 +66,32 @@ export function getStoragePoolStateTextClass(record: StorageRecord): string { return 'text-base-content'; } +// The rebuild summary the backend promotes into the protection label is a +// sentence ("ZFS pool tank is resilvering (45.2%)"). The protection column is +// a badge, so a ZFS scan reads as the activity word, derived from the pool's +// own scan state (the same source the backend keys its risk reason on); the +// sentence stays available as the cell title. Non-ZFS rebuilds keep the label +// their platform supplies. +export function getStoragePoolRebuildLabel(record: StorageRecord): string { + const pool = getStorageRecordZfsPool(record); + if (!pool) return ''; + const scan = `${pool.scanDetails?.function || ''} ${pool.scan || ''}`.toLowerCase(); + const percentage = pool.scanDetails?.percentage; + const progress = + typeof percentage === 'number' && Number.isFinite(percentage) && percentage > 0 + ? ` ${Math.round(percentage)}%` + : ''; + if (scan.includes('resilver')) return `Resilvering${progress}`; + if (scan.includes('scrub')) return `Scrubbing${progress}`; + return ''; +} + export function getCompactStoragePoolProtectionLabel(record: StorageRecord): string { const label = (record.protectionLabel || '').trim(); - if (record.rebuildInProgress || record.protectionReduced) { + if (record.rebuildInProgress) { + return getStoragePoolRebuildLabel(record) || label || '—'; + } + if (record.protectionReduced) { return label || '—'; } if (label && label.toLowerCase() !== 'healthy') { @@ -87,7 +110,9 @@ export function getStoragePoolStateLabel(record: StorageRecord): string { } const pool = getStorageRecordZfsPool(record); if (pool?.state) { - return pool.state === 'ONLINE' ? 'Online' : pool.state; + // zpool reports upper-case states; present them like every other state + // path so a DEGRADED pool reads the same as a degraded array. + return titleize(pool.state); } const status = getStorageRecordStatus(record); return status ? titleize(status) : '—'; @@ -122,7 +147,9 @@ export function getCompactStoragePoolProtectionTitle(record: StorageRecord): str return issueSummary; } } - return label; + // When the badge shows a derived word, the platform's full label is the title. + const fullLabel = (record.protectionLabel || '').trim(); + return fullLabel && fullLabel.toLowerCase() !== label.toLowerCase() ? fullLabel : label; } export function getCompactStoragePoolImpactLabel(record: StorageRecord): string { diff --git a/frontend-modern/src/features/storageBackups/storageBarPresentation.ts b/frontend-modern/src/features/storageBackups/storageBarPresentation.ts index 02b7fda274..533641e90a 100644 --- a/frontend-modern/src/features/storageBackups/storageBarPresentation.ts +++ b/frontend-modern/src/features/storageBackups/storageBarPresentation.ts @@ -38,8 +38,13 @@ export const getStorageBarUsagePercent = (used: number, total: number): number = return (used / total) * 100; }; +// Phone-width tables only have room for the percentage inside the bar; the +// used/total sublabel stays in the tooltip and in the wider projections. +export const getStorageBarCompactLabel = (used: number, total: number): string => + formatPercent(getStorageBarUsagePercent(used, total)); + export const getStorageBarLabel = (used: number, total: number): string => - `${formatPercent(getStorageBarUsagePercent(used, total))} (${formatBytes(used)}/${formatBytes(total)})`; + `${getStorageBarCompactLabel(used, total)} (${formatBytes(used)}/${formatBytes(total)})`; export const getStorageBarTooltipTitle = (): string => 'Storage Details'; diff --git a/frontend-modern/src/features/storageBackups/storagePagePresentation.ts b/frontend-modern/src/features/storageBackups/storagePagePresentation.ts index 6d1d641452..26c156fd0e 100644 --- a/frontend-modern/src/features/storageBackups/storagePagePresentation.ts +++ b/frontend-modern/src/features/storageBackups/storagePagePresentation.ts @@ -1,5 +1,7 @@ +import type { JSX } from 'solid-js'; import type { StorageSortKey } from './storageModelCore'; import type { PlatformTableColumnKind } from '@/features/platformPage/columnAlignment'; +import { getPlatformTableWeightedColumnWidthStyle } from '@/features/platformPage/sharedPlatformPage'; export type StorageViewOption = { value: 'pools' | 'disks'; @@ -21,11 +23,14 @@ export type StoragePoolTableColumn = { export type StoragePoolTableLayoutMode = 'narrow' | 'compact' | 'operational' | 'full'; +// The compact/operational boundary matches the shared phone projection of the +// table container (`@container (max-width: 33.999rem)` = 544px), so the +// columns and the in-cell labels always change projection together. export const getStoragePoolTableLayoutModeForContainer = ( containerWidth: number, ): StoragePoolTableLayoutMode => { if (containerWidth >= 1_040) return 'full'; - if (containerWidth >= 560) return 'operational'; + if (containerWidth >= 544) return 'operational'; if (containerWidth > 0 && containerWidth < 360) return 'narrow'; return 'compact'; }; @@ -43,13 +48,20 @@ const STORAGE_POOL_VISIBLE_COLUMNS: Record< full: ['name', 'state', 'type', 'host', 'protection', 'usage', 'growth'], }; -const STORAGE_POOL_COLUMN_WIDTHS: Record< +// Relative weights per layout, resolved through the canonical weighted-width +// helper. On phones the usage bar carries only its percentage, so most of the +// row goes to the two identity strings (pool name and host); a state word such +// as "Degraded" must still fit whole. The shared 30% identity anchor is not +// used here because a four-column row cannot keep the host readable with it. +const STORAGE_POOL_COLUMN_WEIGHTS: Record< StoragePoolTableLayoutMode, Partial> > = { - narrow: { name: 40, state: 20, host: 20, usage: 20 }, - compact: { name: 35, state: 20, host: 20, usage: 25 }, - operational: { name: 29, state: 20, host: 15, protection: 15, usage: 21 }, + narrow: { name: 35, state: 21, host: 31, usage: 13 }, + compact: { name: 37, state: 18.5, host: 31.5, usage: 13 }, + // The full "59% (1.18 TB/2.00 TB)" bar label returns at 544px, so the usage + // track must hold ~120px of text from the first operational width. + operational: { name: 27, state: 18, host: 15, protection: 15, usage: 25 }, full: { name: 20, state: 14, type: 10, host: 12, protection: 13, usage: 20, growth: 11 }, }; @@ -58,10 +70,24 @@ export const isStoragePoolColumnVisible = ( columnId: StoragePoolTableColumnId, ): boolean => STORAGE_POOL_VISIBLE_COLUMNS[layout].includes(columnId); -export const getStoragePoolColumnWidthPercent = ( +// On phones the pool row sheds the desktop cell gutter the same way its +// headers (via the shared container query) and the physical-disk rows do: +// four cells at 12px each were 48px of a 361px row, and the measured values +// fit the tracks with only a pixel or two to spare. +export const STORAGE_POOL_PHONE_CELL_PADDING_CLASS = '!px-1'; + +export const getStoragePoolCellPaddingClass = (layout: StoragePoolTableLayoutMode): string => + layout === 'narrow' || layout === 'compact' ? STORAGE_POOL_PHONE_CELL_PADDING_CLASS : ''; + +export const getStoragePoolColumnWidthStyle = ( layout: StoragePoolTableLayoutMode, columnId: StoragePoolTableColumnId, -): number => STORAGE_POOL_COLUMN_WIDTHS[layout][columnId] ?? 0; +): JSX.CSSProperties => + getPlatformTableWeightedColumnWidthStyle( + columnId, + STORAGE_POOL_COLUMN_WEIGHTS[layout], + STORAGE_POOL_VISIBLE_COLUMNS[layout], + ); const STORAGE_POOL_TABLE_HEADER_CLASS = 'overflow-hidden text-ellipsis whitespace-nowrap text-[10px] sm:text-[11px] lg:text-xs uppercase tracking-wider';