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 1/5] 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'; From 480be082887b3b6434997a996892b0bd0e6a29fe Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:49:44 +0100 Subject: [PATCH 2/5] Preserve REST operator evidence and readable platform rows Adopt the five frontend maintenance constituents of #2426 against current source, retaining PMG counters, source health, server action refusals and technology through REST projection. Pin stale, failed and removed evidence at the real lifecycle consumer without inventing command grants. Preserve native storage vocabulary and shared-location identity, expose the fullest measured disk in Bars, and keep Docker phone columns and disclosure evidence usable. The production-component browser pass caught a clipped phone Swarm rollout label; wrap that state and compact the Running header, preserving raw keyboard/touch disclosure evidence. Include dependent subsystem contracts, connected regressions and the owning-parent browser receipt. The unchanged dependency advisory and returned backend compiler/native proof remain separate unfinished work. Original-PR: https://github.com/rcourtman/Pulse/pull/2426 Original-Commit: b08abe7471bdc07a3393f32e6ebc37531c56fee7 Original-Commit: 04d76e684ca6b3d7111794ad991545bc6aba56b7 Original-Commit: fad69433a144f943637b90c2c8c50f1e20a45600 Original-Commit: 563506fd6b7da77a563f4b93db662a0e4b5f088f Original-Commit: c2778fba1ee5b3ab9723617334f5d1fdbedeb549 Co-authored-by: rcourtman <8825017+rcourtman@users.noreply.github.com> Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 22 + .../subsystems/performance-and-scalability.md | 15 + .../internal/subsystems/storage-recovery.md | 14 + .../internal/subsystems/unified-resources.md | 23 + .../browser-tests/proxmox-docker-rows.cjs | 481 ++++++++++++++++++ .../browser-tests/proxmox-docker-rows.html | 12 + .../browser-tests/proxmox-docker-rows.tsx | 182 +++++++ frontend-modern/browser-verification.json | 130 ++--- .../components/Workloads/StackedDiskBar.tsx | 69 ++- .../__tests__/StackedDiskBar.test.tsx | 32 ++ ...stackedDiskBarModel.branchcov0712b.test.ts | 4 + .../Workloads/stackedDiskBarModel.ts | 8 + .../src/features/docker/DockerImagesTable.tsx | 14 +- .../features/docker/DockerServicesTable.tsx | 12 +- .../src/features/docker/DockerTasksTable.tsx | 10 +- .../__tests__/DockerNativeTables.test.tsx | 14 +- .../dockerContainerTableModel.test.ts | 4 +- .../docker/dockerContainerTableModel.ts | 30 +- .../platformOverviewLayout.guardrails.test.ts | 2 +- .../resourceStoragePresentation.test.ts | 8 + .../__tests__/storageAdapters.test.ts | 31 ++ .../resourceStoragePresentation.ts | 24 + .../storageBackups/storageAdapters.ts | 11 +- .../__tests__/useUnifiedResources.test.ts | 168 ++++++ .../src/hooks/useUnifiedResources.ts | 15 + 25 files changed, 1199 insertions(+), 136 deletions(-) create mode 100644 frontend-modern/browser-tests/proxmox-docker-rows.cjs create mode 100644 frontend-modern/browser-tests/proxmox-docker-rows.html create mode 100644 frontend-modern/browser-tests/proxmox-docker-rows.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 930119719d..a51a26ce5a 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -805,6 +805,28 @@ that manual-channel consequence must stay visible at desktop and narrow widths. ## Shared Boundaries +### Transport-independent operator rows + +Proxmox/Storage and Docker presenters retain the shared table/detail, responsive +column weighting, relative-time and governed action primitives. Display names +for Proxmox storage plugins preserve their operator vocabulary (LVM-Thin, NFS, +SMB/CIFS and the other supported acronyms); only explicitly shared storage on a +synthetic cluster/shared location gets the shared label. That label does not +alter canonical identity, parent, History target or pool-to-disk ownership. + +The Docker image phone projection pairs hidden headers and cells and gives +measured size and update verdict room without hiding identity or dropping the +existing detail disclosure. The container projection retains its shared 30% +phone identity reservation and five scan signals; off-row restart/host/consumer +evidence remains in detail. Swarm rollout tokens read as words, with compact +Running headings and wrapping rollout states on phones; raw rollout and +placement evidence remain reachable through keyboard/touch row disclosure. +`platformOverviewLayout.guardrails.test.ts`, mounted +Docker table tests and the desktop/touch-phone production-component browser +fixture check these priorities, rather than treating an old percentage as an +immutable primitive contract. No new public route or navigation entry is added. + + ### Maintenance schedule copy Operator maintenance banners show local absolute start and end timestamps, 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 045e6db6a8..ecccb3fe0a 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -311,6 +311,21 @@ the existing bounded HTTP client and artifact-size checks. ## Shared Boundaries +### Multi-disk Bars summary + +The existing vertical-bars workload/node disk cell shows the fullest measured +disk's percentage beside equal-weight per-disk micro-bars. Its title retains +the actual mount and percentage. Unknown/invalid usage cannot produce a new +summary; an aggregate fallback must not masquerade as measured member usage. +The displayed maximum uses the existing shared disk measurement and threshold +logic; no collector, sorting key, metric units, History request, resampling or +performance threshold changes. `StackedDiskBar.test.tsx` and the model branch +controls cover real/unknown measurements and unchanged per-member bars. +`browser-tests/proxmox-docker-rows.cjs` checks the actual label, bars and title +at desktop and phone widths; this is presentation proof, not native collection +or a measured fleet performance improvement. + + 1. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` shared with `unified-resources`: the infrastructure selector pipeline is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. 2. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` shared with `unified-resources`: resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. 3. `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx` shared with `unified-resources`: the unified resource host table card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 6726bf78c3..ef5654ebc6 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -400,6 +400,20 @@ command-capable profile. ## Shared Boundaries +### Operator storage vocabulary without identity changes + +Storage topology names preserve Proxmox plugin vocabulary, including LVM-Thin, +NFS, SMB/CIFS, iSCSI, ZFS and BTRFS. Explicit topology overrides still win. +Only a storage explicitly marked shared whose retained location is the synthetic +cluster/shared marker reads Shared, with the supplied reporting-node count when +more than one node is present. Node-local storage and shared storage with a +real native location keep that location. This is host-label presentation, not +resource consolidation: IDs, parent/node hints, ownership evidence, metrics +targets, alerts, health and recovery authority are unchanged. Adapter/presentation +regressions and the production storage table browser fixture verify labels +without treating shared capacity as a backup or demonstrating native recovery. + + - After password configuration has been persisted and the runtime local-admin identity changes, authorizer synchronisation must also occur if subsequent API-token persistence fails. Development first-run reset must clear the configured-admin bypass only on successful auth reset, not on its persistence-failure rollback path. No role-store deletion or migration is part of this recovery. ### PBS host history correlation diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 93e6fa2f6b..c93d87e6c8 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -617,6 +617,29 @@ about the same disk cannot diverge. ## Shared Boundaries +### REST facets and container table projection + +The unified-resource REST mapper carries PMG metadata, per-source status, +server action-readiness refusals and technology at the same top-level paths +used by canonical websocket rows. Existing platformData compatibility paths +remain; transport order must not decide whether mail counters or lifecycle +refusal explanations are present. A replaced REST snapshot clears removed +facets instead of retaining old counters or refusal reasons. Mapping does not +invent capabilities or bypass planning, review, server authorisation or execution. +`useUnifiedResources.test.ts` checks actual lifecycle refusal consumers for stale, +failed and server-denied observations alongside a no-invented-grant control. + +The Docker phone container projection keeps identity, state when applicable, +CPU, memory and update; restart counts remain in the existing detail expansion +and return at mobile width. Images retain image, size and update verdict on +phones; host and consumers stay reachable in detail. Hidden header/body columns +are symmetric. Swarm rollout tokens render as words with raw state retained in +the title; task starts use the shared relative-time primitive with their exact +timestamp retained. Native table/model/guardrail tests and the production +`browser-tests/proxmox-docker-rows.cjs` exercise these surfaces without executing +a container action or claiming native appliance or release acceptance. + + ### PBS host history correlation PBS presentation correlation accepts standalone agents and VM/system-container resources with an agent facet. Preserve unique identity matching and canonical metricsTarget type and ID. Guests without telemetry and mixed ambiguous agent/guest matches must not replace the PBS target. diff --git a/frontend-modern/browser-tests/proxmox-docker-rows.cjs b/frontend-modern/browser-tests/proxmox-docker-rows.cjs new file mode 100644 index 0000000000..5ca4cc46f1 --- /dev/null +++ b/frontend-modern/browser-tests/proxmox-docker-rows.cjs @@ -0,0 +1,481 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); + +const now = '2026-10-03T12:00:00Z'; +const base = (id, type, extra = {}) => ({ + id, + type, + name: id, + status: 'online', + lastSeen: now, + sources: ['docker'], + ...extra, +}); +const observations = (mode) => [ + base('mail-gateway-eu', 'pmg', { + sources: ['pmg'], + technology: 'pmg', + uptime: 86400, + sourceStatus: { pmg: { status: 'online', lastSeen: now } }, + ...(mode === 'missing' + ? {} + : { + pmg: { + instanceId: 'pmg-eu', + hostname: 'mail-gateway-eu', + version: '8.1-2', + nodeCount: 2, + mailCountTotal: 2740, + spamIn: 321, + virusIn: 14, + quarantine: 3, + queueTotal: mode === 'fresh' ? 19 : 11, + queueDeferred: 5, + }, + }), + }), + base('nfs-iso', 'storage', { + sources: ['proxmox'], + parentName: 'cluster', + parentId: 'pve-cluster-a', + technology: 'nfs', + metrics: { disk: { total: 1e12, used: 3e11, percent: 30 } }, + storage: { type: 'nfs', shared: true, nodes: ['pve1', 'pve2', 'pve3'] }, + }), + base('local-lvm', 'storage', { + sources: ['proxmox'], + parentName: 'pve2', + parentId: 'pve2', + metrics: { disk: { total: 1e12, used: 6e11, percent: 60 } }, + storage: { type: 'lvmthin', shared: false }, + }), + base('edge-web', 'app-container', { + status: 'running', + technology: 'docker', + sourceStatus: { docker: { status: 'online', lastSeen: now } }, + actionReadiness: [{ name: 'restart', available: false, reason: 'Command agent disconnected' }], + metrics: { cpu: { percent: 42 }, memory: { percent: 50, used: 5e8, total: 1e9 } }, + docker: { + agentId: 'agent-edge', + hostSourceId: 'agent-edge', + containerId: 'native-edge-web', + hostname: 'edge-long-hostname', + runtime: 'docker', + runtimeVersion: '27.5.1', + image: 'nginx:latest', + containerState: 'running', + restartCount: 7, + updateStatus: { + updateAvailable: true, + currentDigest: 'sha256:current', + latestDigest: 'sha256:latest', + lastChecked: now, + }, + }, + }), + base('edge-cache', 'app-container', { + status: 'offline', + metrics: { cpu: { percent: 0 }, memory: { percent: 0 } }, + docker: { + agentId: 'agent-edge', + hostname: 'edge-long-hostname', + runtime: 'podman', + runtimeVersion: '5.2.1', + image: 'redis:7.4', + containerState: 'exited', + restartCount: 0, + }, + }), + base('nginx:latest', 'docker-image', { + docker: { + hostname: 'edge-long-hostname', + imageId: 'image-nginx', + repoTags: ['nginx:latest'], + sizeBytes: 2e8, + containers: ['edge-web'], + containerCount: 1, + }, + }), + base('web-service', 'docker-service', { + docker: { + hostname: 'edge-long-hostname', + image: 'nginx:latest', + serviceMode: 'replicated', + desiredTasks: 4, + runningTasks: 2, + mode: 'replicated', + serviceUpdate: { state: 'rollback_started', message: 'Synthetic rollout observation' }, + }, + }), + base('web.2', 'docker-task', { + docker: { + hostname: 'edge-long-hostname', + serviceName: 'web-service', + nodeName: 'worker-1', + taskState: 'running', + desiredState: 'running', + startedAt: '2026-10-03T11:30:00.123456Z', + }, + }), +]; + +(async () => { + const root = '/workspace/frontend-modern'; + const output = '/workspace/tmp/proxmox-docker-rows-verified'; + 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: 5272, strictPort: true }, + }); + const results = [], + screenshots = []; + let browser; + try { + await server.listen(); + for (const [name, engine, width] of [ + ['desktop', chromium, 1440], + ['phone', webkit, 390], + ]) { + 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(now)); + page.setDefaultTimeout(15000); + const checks = [], + errors = [], + offOrigin = [], + mutations = []; + let mode = 'initial'; + page.on('pageerror', (e) => errors.push(e.message)); + await page.route('**/*', async (route) => { + const request = route.request(), + url = new URL(request.url()); + if (url.origin !== 'http://127.0.0.1:5272') { + offOrigin.push(url.origin); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (request.method() !== 'GET') { + mutations.push({ path: url.pathname, method: request.method() }); + return route.fulfill({ status: 409, json: { error: 'No fixture mutation' } }); + } + if (url.pathname === '/api/resources') + return route.fulfill({ json: { data: observations(mode) } }); + if (url.pathname === '/api/license/runtime-capabilities') + return route.fulfill({ + json: { + capabilities: [], + limits: [], + max_history_days: 7, + hosted_mode: false, + blocked_capabilities: [], + runtime: { build: 'community' }, + }, + }); + if (url.pathname === '/api/security/status') + return route.fulfill({ json: { hasAuthentication: true, requiresAuth: true } }); + return route.fulfill({ + json: { + enabled: false, + data: [], + capabilities: [], + facets: [], + relationships: [], + recentChanges: [], + alerts: {}, + }, + }); + }); + 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 = (view) => + page + .getByRole('navigation', { name: 'Fixture views' }) + .getByRole('button', { name: view, exact: true }) + .click(); + const capture = async (state) => { + await page.waitForTimeout(180); // ResizeObserver and entrance layout, not freshness evidence. + const dimensions = await page.evaluate(() => ({ + width: innerWidth, + scroll: document.documentElement.scrollWidth, + })); + checks.push({ + label: `No horizontal page overflow: ${state}`, + passed: dimensions.scroll <= dimensions.width + 1, + dimensions, + }); + const file = `${name}-${state}.png`; + await page.screenshot({ path: path.join(output, file), fullPage: true }); + screenshots.push({ file, state, engine: name, width, dimensions }); + }; + await page.goto('http://127.0.0.1:5272/browser-tests/proxmox-docker-rows.html', { + waitUntil: 'domcontentloaded', + timeout: 120000, + }); + if (width === 390) await page.evaluate(() => document.documentElement.classList.add('dark')); + await page.getByText('8 REST-mapped rows', { exact: true }).waitFor(); + await check( + 'REST-first PMG counters and facets are present before any websocket hydration', + async () => { + const table = page.getByRole('region', { name: 'Mail table' }); + await table.getByText('2,740', { exact: true }).waitFor(); + await table.getByText('11', { exact: true }).waitFor(); + const row = await page.evaluate(() => + window.__operatorRows.rows().find((r) => r.type === 'pmg'), + ); + assert.equal(row.technology, 'pmg'); + assert.equal(row.sourceStatus.pmg.status, 'online'); + assert.equal(row.pmg.queueTotal, 11); + assert.deepEqual(row.pmg, row.platformData.pmg); + if (width > 390) await table.getByText('8.1-2', { exact: true }).waitFor(); + }, + ); + await capture('rest-mail'); + await check( + 'Canonical store hydration followed by REST refresh keeps the new PMG counters', + async () => { + await page.evaluate(() => window.__operatorRows.seedWebsocket()); + mode = 'fresh'; + await page.evaluate(() => window.__operatorRows.refresh()); + await page + .getByRole('region', { name: 'Mail table' }) + .getByText('19', { exact: true }) + .waitFor(); + }, + ); + await capture('refreshed-mail'); + await check('A removed REST PMG facet does not retain obsolete queue evidence', async () => { + mode = 'missing'; + await page.evaluate(() => window.__operatorRows.refresh()); + assert.equal( + await page + .getByRole('region', { name: 'Mail table' }) + .getByText('19', { exact: true }) + .count(), + 0, + ); + const row = await page.evaluate(() => + window.__operatorRows.rows().find((r) => r.type === 'pmg'), + ); + assert.equal(row.pmg, undefined); + mode = 'fresh'; + await page.evaluate(() => window.__operatorRows.refresh()); + }); + await select('Storage'); + await check('Shared storage vocabulary preserves native locations and IDs', async () => { + const table = page.getByRole('region', { name: 'Storage table' }); + await table.getByText('NFS', { exact: true }).waitFor({ state: 'attached' }); + await table.getByText('LVM-Thin', { exact: true }).waitFor({ state: 'attached' }); + await table.getByText('Shared · 3 nodes', { exact: true }).waitFor({ state: 'attached' }); + await table.getByText('pve2', { exact: true }).waitFor({ state: 'attached' }); + const rows = await page.evaluate(() => + window.__operatorRows.rows().filter((r) => r.type === 'storage'), + ); + assert.deepEqual( + rows.map((r) => [r.id, r.parentId]), + [ + ['nfs-iso', 'pve-cluster-a'], + ['local-lvm', 'pve2'], + ], + ); + }); + await capture('storage'); + await select('Containers'); + await check( + 'REST lifecycle refusal remains available; phone row keeps update and moves restarts to detail', + async () => { + await page + .locator('[data-lifecycle-refusal]') + .getByText('Command agent disconnected', { exact: true }) + .waitFor(); + const table = page.getByRole('region', { name: 'Container table' }); + const update = table.locator('.docker-container-update-cell button'); + await update.waitFor(); + assert.match(await update.innerText(), /Update/); + const bounds = await update.evaluate((e) => ({ + w: e.getBoundingClientRect().width, + cell: e.closest('td').getBoundingClientRect().width, + scroll: e.scrollWidth, + client: e.clientWidth, + })); + assert.ok( + bounds.w <= bounds.cell + 1 && bounds.scroll <= bounds.client + 1, + JSON.stringify(bounds), + ); + const headers = await table.locator('th:visible').allTextContents(); + if (width === 390) { + assert.ok(!headers.some((h) => h.includes('Restarts'))); + assert.ok(headers.some((h) => h.includes('State'))); + } + }, + ); + await capture('containers'); + await check( + 'Restart count and native identity remain reachable via row disclosure', + async () => { + const row = page.locator('[data-docker-container-row="edge-web"]'); + if (width === 390) await row.locator('td').first().tap(); + else await row.getByRole('button', { name: /details/i }).click(); + const detail = page.getByTestId('resource-docker-container-section'); + await detail.getByText('Restarts', { exact: true }).waitFor(); + await detail.getByText('7', { exact: true }).waitFor(); + }, + ); + await capture('container-detail'); + await select('Images'); + await check('Image verdict fits; phone hidden headers and cells are symmetric', async () => { + const table = page.getByRole('region', { name: 'Image table' }); + await table.getByText('nginx:latest', { exact: true }).waitFor(); + const headers = await table.locator('th:visible').allTextContents(); + const row = table.locator('[data-docker-image-row]'); + assert.equal(await row.locator('td:visible').count(), headers.length); + if (width === 390) { + assert.ok(!headers.some((h) => h.includes('Host') || h.includes('Used by'))); + assert.ok(headers.some((h) => h.includes('Size'))); + } + }); + await capture('images'); + await select('Swarm'); + await check( + 'Swarm state and started time use operator wording with raw evidence retained', + async () => { + const state = page.getByText('Rollback started', { exact: true }); + await state.waitFor({ state: 'attached' }); + assert.match( + await state.locator('xpath=ancestor-or-self::*[@title][1]').getAttribute('title'), + /rollback_started/, + ); + const visibleState = await state.evaluate((element) => { + const cell = element.closest('td').getBoundingClientRect(); + const range = document.createRange(); + range.selectNodeContents(element); + return [...range.getClientRects()].every( + (line) => + line.left >= cell.left && + line.right <= cell.right && + line.top >= cell.top && + line.bottom <= cell.bottom, + ); + }); + assert.equal(visibleState, true, 'Every word of the rollout state must fit its cell'); + if (width === 390) { + await page.getByText('Run', { exact: true }).waitFor({ state: 'visible' }); + } + const started = page.locator('[title="2026-10-03T11:30:00.123456Z"]'); + await started.waitFor({ state: 'attached' }); + assert.match(await started.innerText(), /ago/); + }, + ); + await capture('swarm'); + await check( + 'Swarm touch or keyboard disclosure exposes raw rollout and placement evidence', + async () => { + const row = page.locator('[data-docker-service-row="web-service"]'); + if (width === 390) await row.locator('td').first().tap(); + else { + const toggle = row.getByRole('button', { + name: 'Expand details for web-service', + exact: true, + }); + await toggle.focus(); + await page.keyboard.press('Enter'); + } + const detail = page.locator('[data-inline-docker-service-detail-for="web-service"]'); + await detail + .getByText('rollback_started | Synthetic rollout observation', { exact: true }) + .waitFor(); + await detail.getByText('edge-long-hostname', { exact: true }).waitFor(); + await detail.getByText('4', { exact: true }).waitFor(); + await detail.getByText('2', { exact: true }).waitFor(); + }, + ); + await capture('swarm-detail'); + await select('Disk'); + await check( + 'Multi-disk Bars show the measured fullest disk and unchanged equal-weight bars', + async () => { + const label = page.locator('[data-stacked-disk-max-label]'); + assert.equal(await label.innerText(), '92%'); + assert.equal(await label.getAttribute('title'), 'Highest usage: /data 92%'); + assert.equal(await page.locator('[data-stacked-disk-fill="vertical"]').count(), 2); + }, + ); + await capture('disks'); + checks.push({ + label: 'No page errors, external requests or mutation requests', + passed: !errors.length && !offOrigin.length && !mutations.length, + errors, + offOrigin, + mutations, + }); + results.push({ engine: name, version: browser.version(), width, checks }); + await browser.close(); + browser = undefined; + } + } finally { + if (browser) await browser.close(); + await server.close(); + } + const result = { + playwright, + verified_at: new Date().toISOString(), + paths, + hashes, + results, + screenshots, + }; + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2)); + const failures = results.flatMap((r) => + r.checks.filter((c) => !c.passed).map((c) => ({ engine: r.engine, ...c })), + ); + console.log( + JSON.stringify({ + checked: results.reduce((n, r) => n + r.checks.length, 0), + screenshots: screenshots.length, + failures, + }), + ); + assert.equal(failures.length, 0, JSON.stringify(failures)); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/proxmox-docker-rows.html b/frontend-modern/browser-tests/proxmox-docker-rows.html new file mode 100644 index 0000000000..5e96968d98 --- /dev/null +++ b/frontend-modern/browser-tests/proxmox-docker-rows.html @@ -0,0 +1,12 @@ + + + + + + Transport facets and operator rows verification + + +
+ + + diff --git a/frontend-modern/browser-tests/proxmox-docker-rows.tsx b/frontend-modern/browser-tests/proxmox-docker-rows.tsx new file mode 100644 index 0000000000..2f0285c0be --- /dev/null +++ b/frontend-modern/browser-tests/proxmox-docker-rows.tsx @@ -0,0 +1,182 @@ +// Synthetic REST observations through the real hook, tables, detail disclosures +// and CSS. No real websocket, appliance or installed/released acceptance claim. +import { createSignal, Show } from 'solid-js'; +import { createStore, unwrap } from 'solid-js/store'; +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { useUnifiedResources } from '../src/hooks/useUnifiedResources'; +import { ProxmoxMailGatewayTable } from '../src/features/proxmox/ProxmoxMailGatewayTable'; +import { DockerContainersTable } from '../src/features/docker/DockerContainersTable'; +import { DockerImagesTable } from '../src/features/docker/DockerImagesTable'; +import { DockerServicesTable } from '../src/features/docker/DockerServicesTable'; +import { DockerTasksTable } from '../src/features/docker/DockerTasksTable'; +import { StackedDiskBar } from '../src/components/Workloads/StackedDiskBar'; +import { StoragePoolsTable } from '../src/components/Storage/StoragePoolsTable'; +import { Table, TableBody, TableRow, TableCell } from '../src/components/shared/Table'; +import { getDockerContainerLifecycleDisabledReason } from '../src/features/docker/dockerContainerLifecycleActions'; +import { buildStorageRecords } from '../src/features/storageBackups/storageAdapters'; +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 type { Resource } from '../src/types/resource'; +import '../src/index.css'; + +const [wsState, setWsState] = createStore({ + resources: [] as Resource[], + lastUpdate: 0, + nodes: [], + pbs: [], + pmg: [], + vms: [], + containers: [], +}); +const [connected, setConnected] = createSignal(false); +const ws = { + state: wsState, + connected, + initialDataReceived: () => true, + resourceChange: () => ({ version: wsState.lastUpdate, changedIds: null, changedKeys: null }), + activeAlerts: () => ({}), + shutdown: () => {}, +}; +window.__pulseWsStore = ws as any; + +function Fixture() { + const [view, setView] = createSignal('Mail'); + const [expandedPool, setExpandedPool] = createSignal(null); + const rows = useUnifiedResources({ query: '', cacheKey: 'operator-rows-browser' }); + const ofType = (type: string) => rows.resources().filter((row) => row.type === type); + const container = () => ofType('app-container')[0]; + const records = () => + buildStorageRecords({ state: wsState as any, resources: ofType('storage') }); + const grouped = () => groupStorageRecords(records(), 'none'); + (window as any).__operatorRows = { + rows: () => JSON.parse(JSON.stringify(unwrap(rows.resources()))), + refresh: () => rows.refetch(), + seedWebsocket: () => { + setWsState('resources', JSON.parse(JSON.stringify(unwrap(rows.resources())))); + setWsState('lastUpdate', Date.now()); + setConnected(true); + }, + }; + const nativeProps = { + emptyIcon: , + emptyTitle: 'No fixture rows', + emptyDescription: 'No fixture rows', + showToolbar: false, + }; + return ( + + document.documentElement.classList.contains('dark')}> +
+

Transport facets and operator rows

+ +

{rows.resources().length} REST-mapped rows

+ +
+ +
+
+ +
+ {}} + expandedPoolId={expandedPool()} + setExpandedPoolId={setExpandedPool} + storageGrowthBySeriesId={new Map()} + storageGrowthColumnLabel="Growth" + physicalDisks={[]} + nodeOnlineByLabel={new Map()} + highlightedRecordId={null} + getRecordAlertState={() => EMPTY_STORAGE_ALERT_STATE} + isLoading={false} + /> +
+
+ +
+

+ {container() && getDockerContainerLifecycleDisabledReason(container(), 'restart')} +

+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+ +
+ + + + pve-edge + + + + + +
+
+
+
+
+
+ ); +} +render( + () => ( + + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 34fd143392..9ccfa044bd 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,101 +1,79 @@ { "version": 1, "result": "passed", - "base_sha": "697ad83e19fa5e8afee634c212c945600d2292b4", - "verified_at": "2026-10-03T06:30:17.630790Z", + "base_sha": "7127054c659913b75abe302c5d377b8b6f49e9f2", + "verified_at": "2026-10-03T08:47:10.724Z", "changed_paths": [ - "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" + "frontend-modern/src/components/Workloads/StackedDiskBar.tsx", + "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts", + "frontend-modern/src/features/docker/DockerImagesTable.tsx", + "frontend-modern/src/features/docker/DockerServicesTable.tsx", + "frontend-modern/src/features/docker/DockerTasksTable.tsx", + "frontend-modern/src/features/docker/dockerContainerTableModel.ts", + "frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts", + "frontend-modern/src/features/storageBackups/storageAdapters.ts", + "frontend-modern/src/hooks/useUnifiedResources.ts" ], "content_sha256": { - "frontend-modern/src/components/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" + "frontend-modern/src/components/Workloads/StackedDiskBar.tsx": "54f1f84dac768c6e0e333254d5c65e59751fa78b5ac55826cdaa37085a73c608", + "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts": "9fde1740e300724ef9cab4a3b036c942bdab6e5d2060f262606b27f4b1a40ea6", + "frontend-modern/src/features/docker/DockerImagesTable.tsx": "29284b34d9631f118a449da1c274b1028e3cea57aed37a3ac056e48e05b2b613", + "frontend-modern/src/features/docker/DockerServicesTable.tsx": "5a153e4b3533849ae952dadb50e512fadb87fcc9fba05403509168d8bb5caba6", + "frontend-modern/src/features/docker/DockerTasksTable.tsx": "58cf1656ba22475b52dc9a582a4d8f0cc8c01f38bfb3775518edac73dbf22b80", + "frontend-modern/src/features/docker/dockerContainerTableModel.ts": "b821b9caf65d5c247e543a6d6dbf0925f735b617c6393066d0f8c15a10e05e72", + "frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts": "933723fb9afeeb53afa46946a346daca19d50c0d61a81fc0730498ebd8c09bb2", + "frontend-modern/src/features/storageBackups/storageAdapters.ts": "daa85c5d99903d5a5fb10070837106b91f06ad130cae320472b2d980b979fb48", + "frontend-modern/src/hooks/useUnifiedResources.ts": "166e45835c56d00f1a3ddbc37f7127348a0e36dc7c42c921f972db7750c603c6" }, "routes": [ - "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" + "Existing Proxmox Mail Gateway table through production REST mapper", + "Existing storage-pool table through production adapter", + "Existing Docker container, image, Swarm service and task tables", + "Existing multi-disk Bars cell" ], "viewports": [ { - "width": 1365, + "width": 1440, "height": 900 }, { "width": 390, "height": 844 - }, - { - "width": 768, - "height": 900 } ], "states": [ - "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" + "PMG REST-first queue 11, canonical in-memory store hydration then newer REST queue 19, removed PMG facet withdrawn", + "LVM-Thin and NFS plugin names; explicit shared cluster location reads Shared (3), local node remains pve2", + "Phone container identity, State/CPU/memory/Updates remain visible; Restarts 7 reachable by touch disclosure", + "Image phone projection keeps size and verdict; Host/Used by headers and cells hidden together", + "Swarm raw rollback_started reads as Rollback started; every word fits phone cell and Running heading uses Run; raw rollout and placement in keyboard/touch disclosure", + "Task started time reads as relative time on desktop with original timestamp retained; phone hides the started column", + "Fullest measured disk label 92% with /data evidence and unchanged two equal-weight member bars" ], "interactions": [ - "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" + "REST-first load before in-memory canonical-store hydration", + "Canonical store hydration, newer REST refresh, removed facet refresh and restored observation", + "Touch container row expansion, keyboard Enter on Swarm disclosure and touch service row expansion", + "Fixture-only tab switching between production tables; no mutation clicked" ], + "browsers": [ + { + "engine": "desktop", + "version": "141.0.7390.37" + }, + { + "engine": "phone", + "version": "26.0" + } + ], + "playwright_version": "1.56.1", + "checks": 40, + "screenshots": 18, + "command": "pulse-worker-browser frontend-modern/browser-tests/proxmox-docker-rows.cjs", + "evidence": "browser-tests/proxmox-docker-rows.cjs and invocation browser-verified-result.json / browser-images.json", "limitations": [ - "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." + "Synthetic REST and canonical in-memory store hydration, not a native websocket wire or appliance test", + "Not backend adoption, authorisation grant, mutative lifecycle execution, installed recovery or release acceptance", + "Seven high braces advisory findings in unchanged dependency graph remain unresolved; offline browser proof does not clear required audit" ] } diff --git a/frontend-modern/src/components/Workloads/StackedDiskBar.tsx b/frontend-modern/src/components/Workloads/StackedDiskBar.tsx index 6c1f78d5fe..1ae9b1098d 100644 --- a/frontend-modern/src/components/Workloads/StackedDiskBar.tsx +++ b/frontend-modern/src/components/Workloads/StackedDiskBar.tsx @@ -189,44 +189,57 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
} > - {/* Vertical micro-bars: one per disk, fill height = utilization, color = threshold */} + {/* Vertical micro-bars: one per disk, fill height = utilization, color = threshold. + The fullest disk's percentage sits beside them so the cell still reads + as a number at a glance; every bar keeps equal weight. */}
-
- - {(bar) => ( -
+
+ + + {presentation().verticalBarsLabel} + + +
+ + {(bar) => (
- + +
-
- )} - + )} + +
diff --git a/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx b/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx index e550f069a0..391101ac73 100644 --- a/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx @@ -587,4 +587,36 @@ describe('StackedDiskBar', () => { expect(bar?.getAttribute('fill')).toContain('239, 68, 68'); }); }); + + describe('vertical-bars mode', () => { + it('labels the micro-bars with the fullest disk so the cell reads as a number', () => { + const { container } = render(() => ( + + )); + + const label = container.querySelector('[data-stacked-disk-max-label]'); + expect(label).toHaveTextContent('52%'); + expect(label).toHaveAttribute('title', 'Highest usage: /data 52%'); + // Every disk keeps its own bar with equal weight; the label is derived. + expect(container.querySelectorAll('[data-stacked-disk-fill="vertical"]')).toHaveLength(2); + }); + + it('omits the label when no disk has measured usage', () => { + const { container } = render(() => ( + + )); + + expect(container.querySelector('[data-stacked-disk-max-label]')).toBeNull(); + }); + }); }); diff --git a/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts b/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts index 054068a04c..b56964dfe4 100644 --- a/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts @@ -372,6 +372,10 @@ describe('stackedDiskBarModel (branch coverage 2)', () => { title: '/x: 40% (40.0 B/100 B)', }); expect(p.verticalBars[1].color).toBe(CRITICAL); // 120% -> critical + // The fullest disk's usage labels the cell so a multi-disk host still + // reads as a number without a hover. + expect(p.verticalBarsLabel).toBe('120%'); + expect(p.verticalBarsLabelTitle).toBe('Highest usage: /y 120%'); expect(p.tooltipContent).toHaveLength(3); expect(p.tooltipContent[2]).toMatchObject({ label: '/z', percent: '—', used: '?' }); }); diff --git a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts index 7b2adc3f65..ed72f85037 100644 --- a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts +++ b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts @@ -87,6 +87,10 @@ export interface StackedDiskBarPresentation { tooltipTitle: string; useStackedSegments: boolean; verticalBars: StackedDiskVerticalBar[]; + // The fullest disk's usage, shown beside the micro-bars so a multi-disk + // host answers "how close is this host to running out?" without a hover. + verticalBarsLabel: string; + verticalBarsLabelTitle: string; verticalBarsMode: boolean; } @@ -359,6 +363,8 @@ export function buildStackedDiskBarPresentation( }; }) : []; + const verticalBarsLabel = verticalBarsMode && maxInfo ? formatPercent(maxInfo.percent) : ''; + const verticalBarsLabelTitle = verticalBarsMode && maxInfo ? maxLabelFull : ''; const tooltipContent = buildTooltipContent(allDisks, { aggregateDisk: props.aggregateDisk, aggregateMode, @@ -402,6 +408,8 @@ export function buildStackedDiskBarPresentation( tooltipTitle: allDisks.length > 1 ? 'Disk Breakdown' : 'Disk Usage', useStackedSegments, verticalBars, + verticalBarsLabel, + verticalBarsLabelTitle, verticalBarsMode, }; } diff --git a/frontend-modern/src/features/docker/DockerImagesTable.tsx b/frontend-modern/src/features/docker/DockerImagesTable.tsx index 88345be440..744bc18b71 100644 --- a/frontend-modern/src/features/docker/DockerImagesTable.tsx +++ b/frontend-modern/src/features/docker/DockerImagesTable.tsx @@ -152,7 +152,7 @@ export const DockerImagesTable: Component< kind="text" sort={sort} sortKey="host" - class="platform-table-mobile-w-15 md:w-[18%]" + class="platform-table-phone-hidden md:w-[18%]" > Host @@ -160,7 +160,7 @@ export const DockerImagesTable: Component< kind="text" sort={sort} sortKey="usedBy" - class="platform-table-mobile-w-15 md:w-[24%]" + class="platform-table-phone-hidden md:w-[24%]" > Used by @@ -168,7 +168,7 @@ export const DockerImagesTable: Component< kind="numeric-value" sort={sort} sortKey="size" - class="platform-table-mobile-w-15 md:w-[12%]" + class="platform-table-mobile-w-20 md:w-[12%]" > Size @@ -176,7 +176,7 @@ export const DockerImagesTable: Component< kind="badge" sort={sort} sortKey="update" - class="platform-table-mobile-w-15 md:w-[16%]" + class="platform-table-mobile-w-25 md:w-[16%]" > @@ -211,13 +211,15 @@ export const DockerImagesTable: Component< /> } /> + {/* Host and consumers fit the row expansion on a phone; the + row keeps the image, its size and the update verdict. */} {dockerHostName(resource)} c.toUpperCase()) + : 'Updating'; const title = [state, message, completedAt].filter(Boolean).join(' | ') || label; return { label, title }; }; @@ -256,7 +262,7 @@ export const DockerServicesTable: Component<{ sortKey="running" class="platform-table-mobile-w-10 w-[10%] md:w-[8%]" > - Running + {update().label} diff --git a/frontend-modern/src/features/docker/DockerTasksTable.tsx b/frontend-modern/src/features/docker/DockerTasksTable.tsx index d5f01c409a..50a7e7e929 100644 --- a/frontend-modern/src/features/docker/DockerTasksTable.tsx +++ b/frontend-modern/src/features/docker/DockerTasksTable.tsx @@ -12,6 +12,7 @@ import { createPlatformTableSortState, getPlatformTableCellClassForKind, getPlatformTableDateTimeSortValue, + PlatformTableRelativeTimeValue, PlatformTableShell, type PlatformTableSortValue, withPlatformStatusCounts, @@ -255,11 +256,10 @@ export const DockerTasksTable: Component = (props) => { diff --git a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx index 1695ae6c76..0e34de84ec 100644 --- a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx +++ b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx @@ -1505,6 +1505,8 @@ describe('Docker native tables', () => { )); expect(screen.getByText('Update')).toBeInTheDocument(); + expect(screen.getByText('Run')).toHaveClass('platform-table-label-compact'); + expect(screen.getByText('Running')).toHaveClass('platform-table-label-full'); expect(screen.getByText('Stack')).toBeInTheDocument(); expect(screen.getByText('shop')).toBeInTheDocument(); expect(screen.getByText('checkout-api')).toBeInTheDocument(); @@ -1512,7 +1514,12 @@ describe('Docker native tables', () => { expect(screen.getByText('replicated')).toBeInTheDocument(); expect(screen.getByText('4')).toBeInTheDocument(); expect(screen.getByText('2')).toBeInTheDocument(); - expect(screen.getByText('rollback_started')).toBeInTheDocument(); + // The snake_case rollout token reads as words; the raw token stays in the title. + expect(screen.getByText('Rollback started')).toBeInTheDocument(); + expect(screen.getByText('Rollback started')).toHaveClass('whitespace-normal', 'break-words'); + expect( + screen.getByText('Rollback started').closest('[title]')?.getAttribute('title'), + ).toContain('rollback_started'); expect(screen.getByText('18080:8080/tcp')).toBeInTheDocument(); expect(screen.getByText('manager-1')).toBeInTheDocument(); expect(document.querySelector('[data-docker-service-row="service-1"]')).not.toBeNull(); @@ -1651,7 +1658,10 @@ describe('Docker native tables', () => { expect(screen.getByText('web.2')).toBeInTheDocument(); expect(screen.getByText('web')).toBeInTheDocument(); expect(screen.getByText('running 2 minutes')).toBeInTheDocument(); - expect(screen.getByText('2026-05-24T13:05:00Z')).toBeInTheDocument(); + // The raw RFC3339 stamp moves to the title; the cell shows an age. + const started = screen.getByTitle('2026-05-24T13:05:00Z'); + expect(started).toBeInTheDocument(); + expect(started.textContent).toMatch(/ago$/); }); it('renders Docker Swarm secret API metadata without secret data', () => { diff --git a/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts b/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts index f8a8fc36f4..41f10a0869 100644 --- a/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts +++ b/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts @@ -30,7 +30,9 @@ describe('dockerContainerTableModel', () => { const columns = getDockerContainerVisibleColumnsForLayout('phone', true, true, true); const ids = columns.map((column) => column.id); - expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'restarts', 'updates']); + // Restarts waits for mobile width: a phone row could not fit its header + // beside the Update control, and the count stays in the row expansion. + expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'updates']); expect(getDockerContainerColumnWidthStyle('container', 'phone', ids)).toEqual({ width: '30%', }); diff --git a/frontend-modern/src/features/docker/dockerContainerTableModel.ts b/frontend-modern/src/features/docker/dockerContainerTableModel.ts index b573f3aba2..2961092c89 100644 --- a/frontend-modern/src/features/docker/dockerContainerTableModel.ts +++ b/frontend-modern/src/features/docker/dockerContainerTableModel.ts @@ -70,7 +70,9 @@ const DOCKER_CONTAINER_COLUMN_MIN_LAYOUT: Record< state: 'narrow', cpu: 'narrow', memory: 'narrow', - restarts: 'phone', + // A phone row cannot fit the Restarts header beside the Update control; + // the count stays in the row expansion there and returns at mobile width. + restarts: 'mobile', updates: 'narrow', actions: 'mobile', host: 'tablet', @@ -126,11 +128,10 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record< }, phone: { container: 32, - state: 13, - cpu: 15, - memory: 17, - restarts: 10, - updates: 13, + state: 14, + cpu: 14, + memory: 14, + updates: 16, }, mobile: { container: 30, @@ -151,17 +152,20 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record< updates: 9, actions: 6, }, + // Compact is what a 1280-1536px laptop window gets. Host, Engine and the + // Update control are short values that were clipped ("docker 2…") while the + // percentage-only CPU and memory bars had room to spare. compact: { container: 18, - host: 10, - runtime: 8, - image: 18, + host: 12, + runtime: 9.5, + image: 16.5, state: 7, - cpu: 11, - memory: 12, - restarts: 9, + cpu: 9.5, + memory: 10, + restarts: 8.5, ports: 12, - updates: 9, + updates: 10, actions: 8, }, }; diff --git a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts index 2d6a7f9cf7..4bc6ed695f 100644 --- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts @@ -322,7 +322,7 @@ describe('platform overview layout guardrails', () => { expect(source).toContain('platform-table-mobile-w-30'); } expect(dockerImagesTableSource).toMatch( - /sortKey="size"[\s\S]{0,120}?class="platform-table-mobile-w-15 md:w-\[12%\]"/, + /sortKey="size"[\s\S]{0,120}?class="platform-table-mobile-w-20 md:w-\[12%\]"/, ); expect(dockerServicesTableSource).toMatch( /sortKey="mode"[\s\S]{0,120}?class="platform-table-phone-hidden md:w-\[8%\]"/, diff --git a/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts index 14cd9deebd..5185db68c3 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts @@ -62,6 +62,14 @@ describe('resourceStoragePresentation', () => { ), ).toBe('Datastore'); expect(getResourceStorageTopologyLabel(makeResource(), 'rbd')).toBe('Cluster Storage'); + // Proxmox plugin names are acronyms or compound words; title-casing them + // produced "Lvmthin" for every default local-lvm storage and "Nfs". + expect(getResourceStorageTopologyLabel(makeResource(), 'lvmthin')).toBe('LVM-Thin'); + expect(getResourceStorageTopologyLabel(makeResource(), 'lvm')).toBe('LVM'); + expect(getResourceStorageTopologyLabel(makeResource(), 'nfs')).toBe('NFS'); + expect(getResourceStorageTopologyLabel(makeResource(), 'cifs')).toBe('SMB/CIFS'); + expect(getResourceStorageTopologyLabel(makeResource(), 'iscsi')).toBe('iSCSI'); + expect(getResourceStorageTopologyLabel(makeResource(), 'btrfs')).toBe('BTRFS'); expect(getResourceStorageTopologyLabel(makeResource(), 'ignored', 'rebuild target')).toBe( 'Rebuild Target', ); diff --git a/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts index 96866fbeda..7e5917d601 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts @@ -467,6 +467,37 @@ describe('storageAdapters', () => { expect(records[0].hostLabel).toBe('pve1'); }); + it('labels a poller-merged shared storage as shared with its node count', () => { + const records = buildStorageRecords({ + state: baseState(), + resources: [ + makeResourceStorage({ + id: 'cluster-a-cluster-nfs-iso', + name: 'nfs-iso', + parentName: 'cluster', + platformData: { type: 'nfs', node: 'cluster', instance: 'cluster-a', shared: true }, + storage: { + type: 'nfs', + shared: true, + nodes: ['pve1', 'pve2', 'pve3'], + } as Resource['storage'], + }), + makeResourceStorage({ + id: 'pve2-local-lvm', + name: 'local-lvm', + parentName: 'pve2', + platformData: { type: 'lvmthin', node: 'pve2', instance: 'cluster-a', shared: false }, + storage: { type: 'lvmthin', shared: false } as Resource['storage'], + }), + ], + }); + + // The poller folds a shared storage into one entry on the synthetic + // "cluster" node; a node-local storage keeps its node name. + expect(records.map((record) => record.hostLabel)).toEqual(['Shared · 3 nodes', 'pve2']); + expect(records.map((record) => record.topologyLabel)).toEqual(['NFS', 'LVM-Thin']); + }); + it('keeps dependency impact separate from healthy storage primary issues', () => { const records = buildStorageRecords({ state: baseState(), diff --git a/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts b/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts index 0c3b5d618a..9302b84d66 100644 --- a/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts +++ b/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts @@ -244,6 +244,30 @@ export const getResourceStorageTopologyLabel = ( case 'rbd': case 'cephfs': return 'Cluster Storage'; + // Proxmox storage plugin names are acronyms or compound words that + // title-casing mangles ("Lvmthin" for the default local-lvm, "Nfs"); use + // the names the Proxmox UI shows for them. + case 'nfs': + return 'NFS'; + case 'cifs': + case 'smb': + return 'SMB/CIFS'; + case 'lvm': + return 'LVM'; + case 'lvmthin': + case 'lvm-thin': + return 'LVM-Thin'; + case 'iscsi': + case 'iscsidirect': + return 'iSCSI'; + case 'zfs': + return 'ZFS'; + case 'btrfs': + return 'BTRFS'; + case 'glusterfs': + return 'GlusterFS'; + case 'esxi': + return 'ESXi'; default: return titleize(storageType) || titleize(resource.type) || 'Storage'; } diff --git a/frontend-modern/src/features/storageBackups/storageAdapters.ts b/frontend-modern/src/features/storageBackups/storageAdapters.ts index 19b8ac6598..7e22fdd6d7 100644 --- a/frontend-modern/src/features/storageBackups/storageAdapters.ts +++ b/frontend-modern/src/features/storageBackups/storageAdapters.ts @@ -129,7 +129,16 @@ const mapResourceStorageRecord = (resource: Resource, adapterId: string): Storag const usedBytes = asNumberOrNull(resource.disk?.used); const freeBytes = asNumberOrNull(resource.disk?.free); const canonicalPlatform = getCanonicalStoragePlatformKey(resource, resource.storage?.platform); - const hostLabel = locationLabel; + // The poller folds a shared storage into one entry whose node is the + // synthetic "cluster" marker (the mock uses "shared"); show that as a + // shared storage with its reporting node count rather than a node name. + const sharedLocationMarkers = new Set(['cluster', 'shared']); + const hostLabel = + shared && sharedLocationMarkers.has(locationLabel.trim().toLowerCase()) + ? storageNodes.length > 1 + ? `Shared · ${storageNodes.length} nodes` + : 'Shared' + : locationLabel; const platformLabel = getResourceStoragePlatformLabel(canonicalPlatform); const issueLabel = getResourceStorageIssueLabel(resource); const issueSummary = getResourceStorageIssueSummary(resource); diff --git a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts index 3408c0079d..ef2dfae357 100644 --- a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts +++ b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts @@ -2,6 +2,7 @@ import { batch, createRoot, createSignal } from 'solid-js'; import { createStore, reconcile, type SetStoreFunction } from 'solid-js/store'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { buildProxmoxPageModel } from '@/features/proxmox/proxmoxPageModel'; +import { getDockerContainerLifecycleDisabledReason } from '@/features/docker/dockerContainerLifecycleActions'; import type { State } from '@/types/api'; import type { Resource } from '@/types/resource'; import useUnifiedResourcesSource from '../useUnifiedResources.ts?raw'; @@ -1626,6 +1627,173 @@ describe('useUnifiedResources', () => { dispose(); }); + it('keeps PMG metadata on the canonical resource for REST-first paints', async () => { + // The Mail Gateway table reads `resource.pmg`. The mapper used to keep it + // only under platformData, so a REST-sourced row rendered every PMG column + // as a dash until a websocket row happened to replace it. + apiFetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [ + { + id: 'pmg-1', + type: 'pmg', + name: 'mail-gateway-eu', + status: 'online', + lastSeen: '2026-02-06T12:00:00Z', + sources: ['pmg'], + uptime: 1_555_222, + technology: 'pmg', + sourceStatus: { pmg: { status: 'online', lastSeen: '2026-02-06T12:00:00Z' } }, + actionReadiness: [{ name: 'restart', available: true }], + pmg: { + instanceId: 'pmg-eu', + hostname: 'pmg.example.lan', + version: '8.1-2', + nodeCount: 1, + mailCountTotal: 2_740, + spamIn: 321, + virusIn: 14, + queueTotal: 11, + queueDeferred: 5, + }, + }, + ], + }), + }); + + let dispose = () => {}; + let result: ReturnType | undefined; + createRoot((d) => { + dispose = d; + result = useUnifiedResources({ query: 'type=pmg&source=pmg', cacheKey: 'pmg-first-paint' }); + }); + + await result!.refetch(); + const row = result!.resources().find((resource) => resource.id === 'pmg-1'); + expect(row?.pmg).toEqual( + expect.objectContaining({ version: '8.1-2', spamIn: 321, queueDeferred: 5 }), + ); + expect(row?.platformData?.pmg).toEqual(row?.pmg); + // The per-source status, action readiness, and technology facets ride the + // same race: Docker lifecycle actions and storage freshness read them at + // the top level, where only websocket rows used to carry them. + expect(row?.sourceStatus?.pmg?.status).toBe('online'); + expect(row?.actionReadiness?.[0]?.name).toBe('restart'); + expect(row?.technology).toBe('pmg'); + + dispose(); + }); + + it.each([ + { + label: 'stale inventory', + sourceStatus: { docker: { status: 'stale', lastSeen: '2026-02-06T12:00:00Z' } }, + actionReadiness: [], + expected: 'Docker inventory is stale. Refresh inventory before running lifecycle actions.', + }, + { + label: 'failed inventory', + sourceStatus: { + docker: { status: 'online', lastSeen: '2026-02-06T12:00:00Z', error: 'Collector refused' }, + }, + actionReadiness: [], + expected: 'Docker inventory is not healthy: Collector refused', + }, + { + label: 'server action refusal', + sourceStatus: { docker: { status: 'online', lastSeen: '2026-02-06T12:00:00Z' } }, + actionReadiness: [ + { name: 'restart', available: false, reason: 'Command agent disconnected' }, + ], + expected: 'Command agent disconnected', + }, + { + label: 'no invented action grant', + sourceStatus: { docker: { status: 'online', lastSeen: '2026-02-06T12:00:00Z' } }, + actionReadiness: [], + expected: + 'Pulse does not currently advertise a fresh restart command capability for this container.', + }, + ])('preserves $label through REST mapping into lifecycle controls', async (testCase) => { + apiFetchMock.mockResolvedValue( + resourceResponse([ + { + id: 'docker-container-1', + type: 'app-container', + name: 'edge-web', + status: 'running', + lastSeen: '2026-02-06T12:00:00Z', + sources: ['docker'], + technology: 'docker', + docker: { runtime: 'docker', agentId: 'agent-edge', containerState: 'running' }, + sourceStatus: testCase.sourceStatus, + actionReadiness: testCase.actionReadiness, + }, + ]), + ); + let dispose = () => {}; + let result: ReturnType | undefined; + createRoot((d) => { + dispose = d; + result = useUnifiedResources({ query: 'type=app-container', cacheKey: testCase.label }); + }); + try { + await result!.refetch(); + const row = result!.resources()[0]; + expect(row.sourceStatus).toEqual(testCase.sourceStatus); + expect(row.actionReadiness).toEqual(testCase.actionReadiness); + expect(row.technology).toBe('docker'); + expect(getDockerContainerLifecycleDisabledReason(row, 'restart')).toBe(testCase.expected); + } finally { + dispose(); + } + }); + + it('replaces REST facets without retaining a removed PMG counter or action refusal', async () => { + const base = { + id: 'pmg-1', + type: 'pmg', + name: 'mail-gateway', + status: 'online', + sources: ['pmg'], + lastSeen: '2026-02-06T12:00:00Z', + }; + apiFetchMock.mockResolvedValue( + resourceResponse([ + { + ...base, + technology: 'pmg', + pmg: { version: '8.1-2', queueTotal: 11 }, + sourceStatus: { pmg: { status: 'stale', lastSeen: base.lastSeen } }, + actionReadiness: [{ name: 'restart', available: false, reason: 'Old refusal' }], + }, + ]), + ); + let dispose = () => {}; + let result: ReturnType | undefined; + createRoot((d) => { + dispose = d; + result = useUnifiedResources({ query: 'type=pmg', cacheKey: 'removed-rest-facets' }); + }); + try { + await result!.refetch(); + expect(result!.resources()[0].pmg?.queueTotal).toBe(11); + apiFetchMock.mockResolvedValue( + resourceResponse([{ ...base, actionReadiness: [], sourceStatus: {} }]), + ); + await result!.refetch(); + const row = result!.resources()[0]; + expect(row.pmg).toBeUndefined(); + expect(row.platformData?.pmg).toBeUndefined(); + expect(row.actionReadiness).toEqual([]); + expect(row.sourceStatus).toEqual({}); + expect(row.technology).toBeUndefined(); + } finally { + dispose(); + } + }); + it('falls back to canonical resource.uptime when no platform-specific field is set (vSphere)', async () => { // vSphere ESXi hosts and VMs populate only the canonical // Resource.Uptime field — there's no vmware-specific UptimeSeconds diff --git a/frontend-modern/src/hooks/useUnifiedResources.ts b/frontend-modern/src/hooks/useUnifiedResources.ts index 9093092194..dbb5d9b3d8 100644 --- a/frontend-modern/src/hooks/useUnifiedResources.ts +++ b/frontend-modern/src/hooks/useUnifiedResources.ts @@ -18,8 +18,10 @@ import type { ResourceDockerMeta, ResourceHealth, ResourceMetricsTarget, + ResourceActionReadiness, ResourceAvailabilityMeta, ResourcePBSMeta, + ResourcePMGMeta, ResourcePolicyPostureSummary, ResourcePhysicalDiskMeta, ResourcePoolHealth, @@ -210,6 +212,8 @@ type APIResource = { sources?: string[]; platformScopes?: string[]; sourceStatus?: Record; + actionReadiness?: ResourceActionReadiness[]; + technology?: string; health?: ResourceHealth; identity?: { machineId?: string; @@ -868,6 +872,17 @@ const toResource = (v2: APIResource): Resource => { truenas: v2.truenas as ResourceTrueNASMeta | undefined, vmware: v2.vmware as ResourceVMwareMeta | undefined, pbs: v2.pbs as ResourcePBSMeta | undefined, + // Mail Gateway rows read `resource.pmg` directly. Leaving it only under + // platformData meant a REST-first paint rendered every PMG column as a + // dash until a websocket row happened to replace it, and never did when + // the REST snapshot landed last. + pmg: v2.pmg as ResourcePMGMeta | undefined, + // Same race for the per-source status, action readiness, and technology + // facets: Docker lifecycle actions and storage freshness read them from + // the top level, and the websocket rows carry them there. + sourceStatus: v2.sourceStatus, + actionReadiness: v2.actionReadiness, + technology: v2.technology, availability: v2.availability as ResourceAvailabilityMeta | undefined, availabilityChecks: v2.availabilityChecks as ResourceAvailabilityMeta[] | undefined, physicalDisk: v2.physicalDisk, From 2839333474fa51550157907edfa452cce05d2260 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:01:49 +0100 Subject: [PATCH 3/5] Make operator-row browser fixture self-contained Derive the nine verified source paths from the committed fixture rather than a temporary per-run JSON file, so another maintainer can run the same checks from a clean checkout. Use the canonical Swarm currentState field and assert that actual task presentation. Runtime frontend files and their owning-parent receipt remain byte-identical; this is not a receipt rebind. Change-source: pulse-maintainer --- .../browser-tests/proxmox-docker-rows.cjs | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/frontend-modern/browser-tests/proxmox-docker-rows.cjs b/frontend-modern/browser-tests/proxmox-docker-rows.cjs index 5ca4cc46f1..c75b745ed8 100644 --- a/frontend-modern/browser-tests/proxmox-docker-rows.cjs +++ b/frontend-modern/browser-tests/proxmox-docker-rows.cjs @@ -115,7 +115,7 @@ const observations = (mode) => [ hostname: 'edge-long-hostname', serviceName: 'web-service', nodeName: 'worker-1', - taskState: 'running', + currentState: 'running', desiredState: 'running', startedAt: '2026-10-03T11:30:00.123456Z', }, @@ -124,7 +124,7 @@ const observations = (mode) => [ (async () => { const root = '/workspace/frontend-modern'; - const output = '/workspace/tmp/proxmox-docker-rows-verified'; + const output = '/workspace/tmp/proxmox-docker-rows-selfcontained'; fs.mkdirSync(output, { recursive: true }); process.chdir(root); const playwright = require('playwright/package.json').version; @@ -134,7 +134,19 @@ const observations = (mode) => [ 'node_modules/@playwright/test' ].version, ); - const paths = JSON.parse(fs.readFileSync(path.join(output, 'runtime-paths.json'))); + // Keep the fixture runnable from clean committed source, without requiring + // an invocation-specific untracked runtime-paths input. + const paths = [ + 'frontend-modern/src/components/Workloads/StackedDiskBar.tsx', + 'frontend-modern/src/components/Workloads/stackedDiskBarModel.ts', + 'frontend-modern/src/features/docker/DockerImagesTable.tsx', + 'frontend-modern/src/features/docker/DockerServicesTable.tsx', + 'frontend-modern/src/features/docker/DockerTasksTable.tsx', + 'frontend-modern/src/features/docker/dockerContainerTableModel.ts', + 'frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts', + 'frontend-modern/src/features/storageBackups/storageAdapters.ts', + 'frontend-modern/src/hooks/useUnifiedResources.ts', + ]; const hashes = Object.fromEntries( paths.map((p) => [ p, @@ -403,6 +415,10 @@ const observations = (mode) => [ const started = page.locator('[title="2026-10-03T11:30:00.123456Z"]'); await started.waitFor({ state: 'attached' }); assert.match(await started.innerText(), /ago/); + await page + .getByRole('region', { name: 'Swarm tasks' }) + .getByText('running', { exact: true }) + .waitFor(); }, ); await capture('swarm'); From 9c3d79ba6a1c0e4985baf5d18df5e71e3275ba1d Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:13:12 +0100 Subject: [PATCH 4/5] Scope Swarm browser check to the current-state evidence Desired and current task states can both read running. Select the canonical current-state title instead of an ambiguous text match, preserving the failed fixture-only attempt and unchanged runtime source/browser binding. Change-source: pulse-maintainer --- frontend-modern/browser-tests/proxmox-docker-rows.cjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend-modern/browser-tests/proxmox-docker-rows.cjs b/frontend-modern/browser-tests/proxmox-docker-rows.cjs index c75b745ed8..bcc376eded 100644 --- a/frontend-modern/browser-tests/proxmox-docker-rows.cjs +++ b/frontend-modern/browser-tests/proxmox-docker-rows.cjs @@ -124,7 +124,7 @@ const observations = (mode) => [ (async () => { const root = '/workspace/frontend-modern'; - const output = '/workspace/tmp/proxmox-docker-rows-selfcontained'; + const output = '/workspace/tmp/proxmox-docker-rows-final-source'; fs.mkdirSync(output, { recursive: true }); process.chdir(root); const playwright = require('playwright/package.json').version; @@ -417,7 +417,7 @@ const observations = (mode) => [ assert.match(await started.innerText(), /ago/); await page .getByRole('region', { name: 'Swarm tasks' }) - .getByText('running', { exact: true }) + .getByTitle('running', { exact: true }) .waitFor(); }, ); From 9df89142d3ffff5ac81c423537e287b194b589f5 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:45:44 +0100 Subject: [PATCH 5/5] Keep notification activity truthful when evidence reads fail Track held-event availability independently of delivery attempts. Do not call a pending or partial read empty, withdraw only the unreadable source's rows, and retain the warning through retry. Preserve current-request ownership, editor state and all notification policies. Qualify the production Notifications tab with desktop Chromium and phone WebKit keyboard/touch read-and-retry controls. Record the owning-parent browser binding and both frontend/alert contracts. Change-source: pulse-maintainer --- .../v6/internal/subsystems/alerts.md | 22 + .../subsystems/frontend-primitives.md | 18 + .../browser-tests/notification-evidence.cjs | 383 ++++++++++++++++++ .../browser-tests/notification-evidence.html | 12 + .../browser-tests/notification-evidence.tsx | 69 ++++ frontend-modern/browser-verification.json | 98 +++-- .../alerts/AlertDeliveryLogCard.test.tsx | 111 ++++- .../features/alerts/AlertDeliveryLogCard.tsx | 206 ++++++---- .../DestinationsTab.recoveryediting.test.tsx | 2 +- .../useAlertDestinationsTabState.test.tsx | 11 + .../useNotificationDeliveryLog.test.tsx | 58 +++ .../features/alerts/tabs/DestinationsTab.tsx | 2 + .../alerts/useAlertDestinationsTabState.ts | 4 + .../alerts/useNotificationDeliveryLog.ts | 15 +- .../alertDestinationsPresentation.test.ts | 25 ++ .../utils/alertDestinationsPresentation.ts | 14 +- 16 files changed, 909 insertions(+), 141 deletions(-) create mode 100644 frontend-modern/browser-tests/notification-evidence.cjs create mode 100644 frontend-modern/browser-tests/notification-evidence.html create mode 100644 frontend-modern/browser-tests/notification-evidence.tsx diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index fefabf9828..20fd82c066 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -3236,3 +3236,25 @@ The monitor delegates recovery eligibility to persisted occurrence/destination receipts even when an old retry remains or restart has lost RAM cooldown state. The partial-destination monitor control establishes a real local HTTP 200/503 split and preserves the newer firing and the old recipient's recovery. + +### Notification activity reports each evidence source independently + +The existing Notifications activity card distinguishes an initial or pending +read from a confirmed empty window. Held/deferred event reads have their own +loading and unavailable state; a failed read withdraws their prior rows and +keeps the warning until a later successful read. Only the current refresh owns +those states, and disposed readers cannot publish late completions. A slow +held-event read does not delay delivery attempts or disable their refresh. + +A delivery-attempt failure withdraws only attempt rows, not independently +readable held events. Conversely, missing held evidence does not hide readable +attempts or imply that no notifications were held. The combined empty state +requires both sources to be readable and no refresh pending. Retention, event +limits, queue actions, permissions and notification delivery policy are unchanged. + +`useNotificationDeliveryLog.test.tsx`, `useAlertDestinationsTabState.test.tsx` +and `AlertDeliveryLogCard.test.tsx` exercise independent failure, recovery, +initial loading and current-request ownership; the production Notifications +tab browser fixture verifies the connected read/retry states at desktop and +phone widths. These are synthetic frontend controls, not recipient delivery +or containing-release acceptance. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index a51a26ce5a..698506168e 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -7908,3 +7908,21 @@ kind normalisation, the kind switch, the email-only payload, and the plain cover the gating, the reduced form, the pinned fields, and the saved payload. This is a presentation and form-state boundary; the schedule API contract, report rendering, and email delivery are unchanged. + +### Notifications activity has independent evidence-read states + +The Notifications tab composes the delivery-log card with both attempt-read +and held/deferred-event loading and unavailable state from its owned reader. +Pending or failed evidence is never rendered as a confirmed empty activity +window. Each failed source withdraws its own rows without hiding independently +readable rows from the other source. Source-specific warnings have `role="alert"` +and loading text has `role="status"`; a slow held-event read does not block the +existing attempt-refresh button. Recovery clears a warning only after a +successful read, without remounting the destination editors or changing their +unfinished input. + +The connected Notifications browser fixture uses the real tab/state/API/card +chain at desktop and phone widths with keyboard/touch refresh, class-dark CSS, +403/503 controls and pending recovery. Unit controls separately cover current +request ownership and disposal. No mutation, route, navigation entry, queue +policy, permission or recipient-delivery contract is added by these states. diff --git a/frontend-modern/browser-tests/notification-evidence.cjs b/frontend-modern/browser-tests/notification-evidence.cjs new file mode 100644 index 0000000000..8a6fef7319 --- /dev/null +++ b/frontend-modern/browser-tests/notification-evidence.cjs @@ -0,0 +1,383 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); +const output = '/workspace/tmp/notification-evidence-proof'; +const root = '/workspace/frontend-modern'; +const runtime = [ + 'frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx', + 'frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts', + 'frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts', + 'frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx', + 'frontend-modern/src/utils/alertDestinationsPresentation.ts', +]; +const log = (entries = []) => ({ + entries, + window_days: 30, + completed_retention_days: 7, + dead_letter_retention_days: 30, +}); +const sent = { + notificationId: 'synthetic-attempt', + type: 'email', + outcome: 'sent', + alertIds: ['synthetic-disk-alert'], + alertCount: 1, + attempts: 1, + success: true, + timestamp: '2026-10-03T10:00:00Z', +}; +const held = [ + { + id: 42, + type: 'notification_deferred', + alertId: 'synthetic-held-alert', + resourceName: 'synthetic-nas', + alertType: 'usage', + reason: 'quiet_hours:performance', + occurredAt: '2026-10-03T10:01:00Z', + }, +]; +(async () => { + fs.mkdirSync(output, { recursive: true }); + const playwrightVersion = require('playwright/package.json').version; + assert.equal( + playwrightVersion, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + const hashes = Object.fromEntries( + runtime.map((p) => [ + p, + crypto + .createHash('sha256') + .update(fs.readFileSync(path.join('/workspace', p))) + .digest('hex'), + ]), + ); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(output, 'vite-cache'), + server: { host: '127.0.0.1', port: 5291, strictPort: true }, + }); + const results = [], + screens = []; + let browser; + try { + await server.listen(); + for (const [name, engine, width, dark] of [ + ['desktop', chromium, 1365, false], + ['phone', webkit, 390, true], + ]) { + 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', + }); + page.setDefaultTimeout(20000); + const errors = [], + offOrigin = [], + writes = [], + requests = [], + checks = [], + releases = []; + let attemptMode = 'hold-empty', + heldMode = 'hold-empty'; + page.on('pageerror', (error) => errors.push(error.message)); + await page.route('**/*', async (route) => { + const request = route.request(), + url = new URL(request.url()); + if (url.origin !== 'http://127.0.0.1:5291') { + offOrigin.push(url.origin); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (request.method() !== 'GET') { + writes.push({ method: request.method(), path: url.pathname }); + return route.abort(); + } + if ( + url.pathname === '/api/notifications/delivery-log' || + url.pathname === '/api/alerts/events' + ) { + const isHeld = url.pathname === '/api/alerts/events'; + const mode = isHeld ? heldMode : attemptMode; + requests.push({ path: url.pathname, query: url.search, mode }); + if (mode.startsWith('hold-')) + await new Promise((resolve) => releases.push({ isHeld, resolve })); + if (mode.endsWith('denied')) + return route.fulfill({ + status: isHeld ? 403 : 503, + json: { error: 'Synthetic unavailable evidence' }, + }); + return route.fulfill({ + json: isHeld + ? mode.endsWith('known') + ? held + : [] + : log(mode.endsWith('known') ? [sent] : []), + }); + } + if (url.pathname === '/api/notifications/health') + return route.fulfill({ + json: { + overall_healthy: true, + queue: { + pending: 0, + sending: 0, + sent: 1, + failed: 0, + dlq: 0, + healthy: true, + status: 'healthy', + attention_required: 0, + reason_codes: [], + completed_retention_days: 7, + dead_letter_retention_days: 30, + counts_are_retention_bounded: true, + }, + }, + }); + 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 === '/api/security/status') + return route.fulfill({ json: { hasAuthentication: true, requiresAuth: false } }); + return route.fulfill({ json: [] }); + }); + const check = async (label, action) => { + await action(); + checks.push({ label, passed: true }); + }; + const activity = page.locator('#notification-delivery-activity'); + const empty = activity.getByText(/No alert deliveries were attempted/); + const heldWarning = activity.getByText(/could not read held or deferred notifications/); + const attemptWarning = activity.getByText(/could not read the delivery log/); + const refresh = activity.getByRole('button', { name: 'Refresh delivery status' }); + const release = (isHeld) => { + for (const pending of releases.splice(0).filter((item) => { + if (item.isHeld === isHeld) { + item.resolve(); + return false; + } + return true; + })) + releases.push(pending); + }; + const capture = async (state) => { + const filename = `${name}-${state}.png`; + await activity.screenshot({ path: path.join(output, filename) }); + screens.push({ + file: filename, + state, + viewport: { width, height: width === 390 ? 844 : 900 }, + sha256: crypto + .createHash('sha256') + .update(fs.readFileSync(path.join(output, filename))) + .digest('hex'), + }); + }; + try { + await page.goto('http://127.0.0.1:5291/browser-tests/notification-evidence.html'); + await page.evaluate( + (dark) => document.documentElement.classList.toggle('dark', dark), + dark, + ); + await check('Initial reads are loading, not empty', async () => { + await activity.getByText('Loading delivery attempts...').waitFor(); + await activity.getByText('Loading held and deferred notifications...').waitFor(); + assert.equal(await empty.count(), 0); + }); + await check( + 'A slow held read does not block the successful attempts read or its refresh', + async () => { + release(false); + await activity.getByText('Loading delivery attempts...').waitFor({ state: 'hidden' }); + assert.equal(await refresh.isEnabled(), true); + assert.equal(await empty.count(), 0); + await activity.getByText('Loading held and deferred notifications...').waitFor(); + }, + ); + await capture('held-pending'); + await check('Only two completed empty reads establish the empty window', async () => { + release(true); + await empty.waitFor(); + assert.equal(await activity.getByRole('status').count(), 0); + }); + await check( + 'Keyboard or touch refresh shows attempts and held events from their real API adapters', + async () => { + attemptMode = heldMode = 'known'; + if (width === 390) await refresh.tap(); + else { + await refresh.focus(); + await page.keyboard.press('Enter'); + } + await activity.getByText('Delivered', { exact: true }).waitFor(); + await activity.getByText('synthetic-nas (usage)', { exact: true }).waitFor(); + assert.equal(await empty.count(), 0); + }, + ); + await check( + 'Held-event 403 withdraws held rows and warns without hiding delivered evidence', + async () => { + heldMode = 'denied'; + await refresh.click(); + await heldWarning.waitFor(); + assert.equal( + await activity.getByText('synthetic-nas (usage)', { exact: true }).count(), + 0, + ); + assert.equal(await activity.getByText('Delivered', { exact: true }).count(), 1); + assert.equal(await empty.count(), 0); + }, + ); + await capture('held-unavailable'); + await check('The held warning persists during a pending retry', async () => { + heldMode = 'hold-empty'; + await refresh.click(); + await activity.getByText('Loading held and deferred notifications...').waitFor(); + assert.equal(await heldWarning.count(), 1); + await refresh.waitFor({ state: 'visible' }); + await page.waitForFunction( + () => !document.querySelector('#notification-delivery-activity button').disabled, + ); + assert.equal(await empty.count(), 0); + release(true); + await heldWarning.waitFor({ state: 'hidden' }); + }); + await check( + 'An attempt-read failure keeps independently readable held evidence', + async () => { + attemptMode = 'denied'; + heldMode = 'known'; + await refresh.click(); + await attemptWarning.waitFor(); + await activity.getByText('synthetic-nas (usage)', { exact: true }).waitFor(); + assert.equal(await activity.getByText('Delivered', { exact: true }).count(), 0); + assert.equal(await empty.count(), 0); + }, + ); + await capture('attempts-unavailable'); + await check('Both failed sources are reported, not called empty', async () => { + heldMode = 'denied'; + await refresh.click(); + await heldWarning.waitFor(); + assert.equal(await attemptWarning.count(), 1); + assert.equal(await activity.getByRole('listitem').count(), 0); + assert.equal(await empty.count(), 0); + }); + await check( + 'Both successful reads clear the warnings and restore a genuine empty state', + async () => { + attemptMode = heldMode = 'empty'; + await refresh.click(); + await empty.waitFor(); + assert.equal(await activity.getByRole('alert').count(), 0); + assert.equal(await activity.getByRole('status').count(), 0); + }, + ); + await check( + 'No horizontal clipping, JavaScript page error, off-origin request or mutation', + async () => { + assert.equal( + await activity.evaluate((element) => element.scrollWidth <= element.clientWidth + 1), + true, + ); + assert.deepEqual(errors, []); + assert.deepEqual(offOrigin, []); + assert.deepEqual(writes, []); + assert( + requests + .filter((r) => r.path === '/api/alerts/events') + .every( + (r) => r.query.includes('limit=100') && r.query.includes('notification_deferred'), + ), + ); + assert( + requests + .filter((r) => r.path === '/api/notifications/delivery-log') + .every((r) => r.query === '?limit=200'), + ); + }, + ); + results.push({ + name, + browser_version: browser.version(), + dark, + checks, + requests, + errors, + offOrigin, + writes, + }); + } finally { + for (const pending of releases) pending.resolve(); + await browser.close(); + browser = null; + } + } + fs.writeFileSync( + path.join(output, 'result.json'), + JSON.stringify( + { + passed: true, + playwright_version: playwrightVersion, + content_sha256: hashes, + changed_paths: runtime, + results, + screenshots: screens, + }, + null, + 2, + ) + '\n', + ); + console.log( + JSON.stringify({ + passed: true, + checks: results.reduce((n, r) => n + r.checks.length, 0), + screenshots: screens.length, + playwright_version: playwrightVersion, + results: results.map((r) => ({ name: r.name, browser_version: r.browser_version })), + }), + ); + } catch (error) { + fs.writeFileSync( + path.join(output, 'failed.json'), + JSON.stringify( + { passed: false, error: error.message, results, screenshots: screens }, + null, + 2, + ) + '\n', + ); + throw error; + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/notification-evidence.html b/frontend-modern/browser-tests/notification-evidence.html new file mode 100644 index 0000000000..71c910e8fe --- /dev/null +++ b/frontend-modern/browser-tests/notification-evidence.html @@ -0,0 +1,12 @@ + + + + + + Notification evidence verification + + +
+ + + diff --git a/frontend-modern/browser-tests/notification-evidence.tsx b/frontend-modern/browser-tests/notification-evidence.tsx new file mode 100644 index 0000000000..cd358e54b1 --- /dev/null +++ b/frontend-modern/browser-tests/notification-evidence.tsx @@ -0,0 +1,69 @@ +// The production Notifications tab and API readers with synthetic read responses. +// No notification is sent, retried or dismissed by this fixture. +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { DestinationsTab } from '../src/features/alerts/tabs/DestinationsTab'; +import type { UIEmailConfig, UIAppriseConfig } from '../src/features/alerts/types'; +import '../src/index.css'; + +function Fixture() { + const [emailConfig, setEmailConfig] = createSignal({ + enabled: false, + provider: 'smtp', + server: '', + port: 587, + username: '', + password: '', + from: '', + to: [], + tls: true, + startTLS: true, + replyTo: '', + maxRetries: 3, + retryDelay: 5, + rateLimit: 60, + }); + const [appriseConfig, setAppriseConfig] = createSignal({ + enabled: false, + mode: 'cli', + targetsText: '', + cliPath: '', + timeoutSeconds: 20, + serverUrl: '', + configKey: '', + apiKey: '', + apiKeyHeader: 'X-API-KEY', + skipTlsVerify: false, + hasApiKey: false, + }); + return ( +
+

Notifications

+ {}} + configLoadError={() => null} + isRetrying={() => false} + isLoadingDestinations={() => false} + onRetryLoad={() => {}} + webhooks={() => []} + deadManPingUrl={() => ''} + setDeadManPingUrl={() => {}} + pushMinimumSeverity={() => 'all'} + setPushMinimumSeverity={() => {}} + /> +
+ ); +} +render( + () => ( + + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 9ccfa044bd..453fe2f009 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,39 +1,28 @@ { "version": 1, "result": "passed", - "base_sha": "7127054c659913b75abe302c5d377b8b6f49e9f2", - "verified_at": "2026-10-03T08:47:10.724Z", + "base_sha": "2d6a10ab59d327b461ef762ec69a9c5d0440929a", + "verified_at": "2026-10-03T11:37:53.905511Z", "changed_paths": [ - "frontend-modern/src/components/Workloads/StackedDiskBar.tsx", - "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts", - "frontend-modern/src/features/docker/DockerImagesTable.tsx", - "frontend-modern/src/features/docker/DockerServicesTable.tsx", - "frontend-modern/src/features/docker/DockerTasksTable.tsx", - "frontend-modern/src/features/docker/dockerContainerTableModel.ts", - "frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts", - "frontend-modern/src/features/storageBackups/storageAdapters.ts", - "frontend-modern/src/hooks/useUnifiedResources.ts" + "frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx", + "frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts", + "frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts", + "frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx", + "frontend-modern/src/utils/alertDestinationsPresentation.ts" ], "content_sha256": { - "frontend-modern/src/components/Workloads/StackedDiskBar.tsx": "54f1f84dac768c6e0e333254d5c65e59751fa78b5ac55826cdaa37085a73c608", - "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts": "9fde1740e300724ef9cab4a3b036c942bdab6e5d2060f262606b27f4b1a40ea6", - "frontend-modern/src/features/docker/DockerImagesTable.tsx": "29284b34d9631f118a449da1c274b1028e3cea57aed37a3ac056e48e05b2b613", - "frontend-modern/src/features/docker/DockerServicesTable.tsx": "5a153e4b3533849ae952dadb50e512fadb87fcc9fba05403509168d8bb5caba6", - "frontend-modern/src/features/docker/DockerTasksTable.tsx": "58cf1656ba22475b52dc9a582a4d8f0cc8c01f38bfb3775518edac73dbf22b80", - "frontend-modern/src/features/docker/dockerContainerTableModel.ts": "b821b9caf65d5c247e543a6d6dbf0925f735b617c6393066d0f8c15a10e05e72", - "frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts": "933723fb9afeeb53afa46946a346daca19d50c0d61a81fc0730498ebd8c09bb2", - "frontend-modern/src/features/storageBackups/storageAdapters.ts": "daa85c5d99903d5a5fb10070837106b91f06ad130cae320472b2d980b979fb48", - "frontend-modern/src/hooks/useUnifiedResources.ts": "166e45835c56d00f1a3ddbc37f7127348a0e36dc7c42c921f972db7750c603c6" + "frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx": "cac90b57e94b42360989262b550c9b0beaca8dd6eecdd7f7882911bb49de9fc2", + "frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts": "e8ce66a9ca6ae82861a9d03a7d565f6c246f9b5fc547e7c2e64f0a3e6bd12014", + "frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts": "eaf519a7f205be0b0e88ea17b19552f30afef025251262a1ea1b8896442e2673", + "frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx": "9278ab7cf249eea451040b722b244d1447b05c07557d641187fa51f3bc88a225", + "frontend-modern/src/utils/alertDestinationsPresentation.ts": "5afa5dbce8eb1602377b7b27f6298438da76e3ebf2f80986677c9988bf36c96c" }, "routes": [ - "Existing Proxmox Mail Gateway table through production REST mapper", - "Existing storage-pool table through production adapter", - "Existing Docker container, image, Swarm service and task tables", - "Existing multi-disk Bars cell" + "Existing Alerts Notifications tab through production DestinationsTab, destination state, notification/alert API adapters and activity card, under synthetic read-only responses" ], "viewports": [ { - "width": 1440, + "width": 1365, "height": 900 }, { @@ -42,19 +31,18 @@ } ], "states": [ - "PMG REST-first queue 11, canonical in-memory store hydration then newer REST queue 19, removed PMG facet withdrawn", - "LVM-Thin and NFS plugin names; explicit shared cluster location reads Shared (3), local node remains pve2", - "Phone container identity, State/CPU/memory/Updates remain visible; Restarts 7 reachable by touch disclosure", - "Image phone projection keeps size and verdict; Host/Used by headers and cells hidden together", - "Swarm raw rollback_started reads as Rollback started; every word fits phone cell and Running heading uses Run; raw rollout and placement in keyboard/touch disclosure", - "Task started time reads as relative time on desktop with original timestamp retained; phone hides the started column", - "Fullest measured disk label 92% with /data evidence and unchanged two equal-weight member bars" + "Initial attempt and held-event reads pending without an empty claim", + "Successful attempts do not wait for held events; refresh remains usable", + "Both completed empty reads establish the empty window", + "Independent readable attempts and held-event rows", + "Held-event HTTP403 clears its rows and warns, without hiding attempt evidence", + "Pending held retry retains the warning until success", + "Attempt HTTP503 clears only attempt evidence, preserving readable held rows", + "Both failures warn without empty claims; successful reads clear warnings" ], "interactions": [ - "REST-first load before in-memory canonical-store hydration", - "Canonical store hydration, newer REST refresh, removed facet refresh and restored observation", - "Touch container row expansion, keyboard Enter on Swarm disclosure and touch service row expansion", - "Fixture-only tab switching between production tables; no mutation clicked" + "Desktop keyboard Enter and phone touch refresh through the real existing Notifications tab", + "Partial failure, pending retry and recovery via synthetic API replies; no mutation" ], "browsers": [ { @@ -67,13 +55,35 @@ } ], "playwright_version": "1.56.1", - "checks": 40, - "screenshots": 18, - "command": "pulse-worker-browser frontend-modern/browser-tests/proxmox-docker-rows.cjs", - "evidence": "browser-tests/proxmox-docker-rows.cjs and invocation browser-verified-result.json / browser-images.json", - "limitations": [ - "Synthetic REST and canonical in-memory store hydration, not a native websocket wire or appliance test", - "Not backend adoption, authorisation grant, mutative lifecycle execution, installed recovery or release acceptance", - "Seven high braces advisory findings in unchanged dependency graph remain unresolved; offline browser proof does not clear required audit" + "checks": 20, + "screenshots": [ + { + "path": "tmp/notification-evidence-proof/desktop-held-pending.png", + "sha256": "58ddaf1dae1e797e6f938c3e74b705fe75276eaf560de307fe7bfe1644c2df21" + }, + { + "path": "tmp/notification-evidence-proof/desktop-held-unavailable.png", + "sha256": "fe343a2b7eb67ae1283e4eb411462073e3db26d36d226ef62d9fd49cf9a095c7" + }, + { + "path": "tmp/notification-evidence-proof/desktop-attempts-unavailable.png", + "sha256": "a4ae26cc87b0bbf91b4dc3150f375a10c6701201deed4eaa3bdfbfce08ade4de" + }, + { + "path": "tmp/notification-evidence-proof/phone-held-pending.png", + "sha256": "66262c32b745e68f32aba48db19dee2a03c7c62d763efd765ed4da154ec9e00f" + }, + { + "path": "tmp/notification-evidence-proof/phone-held-unavailable.png", + "sha256": "534cd02e8b5392bd5e73d7f4a7a3d8e2a78596a230d055daef4479ecf0d1fdbc" + }, + { + "path": "tmp/notification-evidence-proof/phone-attempts-unavailable.png", + "sha256": "09652b8023be5e527712d42384e9fee8e114575026aefaf703e62cfa30bc0884" + } + ], + "proof_script": "frontend-modern/browser-tests/notification-evidence.cjs", + "limits": [ + "Synthetic source/UI evidence only, not notification receipt, native provider delivery, release availability or dependency advisory clearance" ] } diff --git a/frontend-modern/src/features/alerts/AlertDeliveryLogCard.test.tsx b/frontend-modern/src/features/alerts/AlertDeliveryLogCard.test.tsx index 312340f152..4fb6b60f9a 100644 --- a/frontend-modern/src/features/alerts/AlertDeliveryLogCard.test.tsx +++ b/frontend-modern/src/features/alerts/AlertDeliveryLogCard.test.tsx @@ -273,7 +273,7 @@ describe('AlertDeliveryLogCard', () => { expect(screen.queryByRole('list')).not.toBeInTheDocument(); }); - it('hides held rows during an outage and restores their current state on recovery', () => { + it('hides held rows when their own read fails and restores them on recovery', () => { const [unavailable, setUnavailable] = createSignal(false); const [heldEvents, setHeldEvents] = createSignal([ { @@ -290,7 +290,8 @@ describe('AlertDeliveryLogCard', () => { render(() => ( { expect(screen.getByText('Deferred')).toBeInTheDocument(); setUnavailable(true); - expect(screen.getByRole('alert')).toHaveTextContent(/could not read the delivery log/); + expect(screen.getByRole('alert')).toHaveTextContent( + /could not read held or deferred notifications/, + ); expect(screen.queryByRole('list')).not.toBeInTheDocument(); expect(screen.queryByText('nas-quiet (usage)')).not.toBeInTheDocument(); expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument(); @@ -332,4 +335,106 @@ describe('AlertDeliveryLogCard', () => { expect(screen.getByRole('alert')).toHaveTextContent(/could not read the delivery log/); expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument(); }); + it('keeps independently readable held evidence when delivery attempts are unavailable', () => { + render(() => ( + + )); + expect(screen.getByRole('alert')).toHaveTextContent(/could not read the delivery log/); + expect(screen.getByText('held-known')).toBeInTheDocument(); + expect(screen.getByText('Quiet hours')).toBeInTheDocument(); + expect(screen.queryByText('Delivered')).not.toBeInTheDocument(); + expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument(); + }); + + it('reports missing held evidence without hiding readable attempts', () => { + render(() => ( + + )); + expect(screen.getByRole('alert')).toHaveTextContent( + /could not read held or deferred notifications/, + ); + expect(screen.getByText('Delivered')).toBeInTheDocument(); + expect(screen.getByText('Ops Discord')).toBeInTheDocument(); + expect(screen.queryByText('stale-held')).not.toBeInTheDocument(); + }); + + it('does not claim an empty activity window while held evidence is pending', () => { + render(() => ( + + )); + expect(screen.getByRole('status')).toHaveTextContent( + 'Loading held and deferred notifications...', + ); + expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument(); + // A slow held read must not prevent a new delivery-attempt refresh. + expect(screen.getByRole('button', { name: 'Refresh delivery status' })).toBeEnabled(); + }); + + it('does not claim an empty activity window when held evidence is unavailable', () => { + render(() => ( + + )); + expect(screen.getByRole('alert')).toHaveTextContent( + /could not read held or deferred notifications/, + ); + expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument(); + }); + + it('labels the initial attempt read as loading rather than empty', () => { + render(() => ( + + )); + expect(screen.getByRole('status')).toHaveTextContent('Loading delivery attempts...'); + expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument(); + }); }); diff --git a/frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx b/frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx index c11b0f8c07..b45de97225 100644 --- a/frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx +++ b/frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx @@ -15,6 +15,9 @@ import { getAlertDestinationsDeliveryLogEmpty, getAlertDestinationsDeliveryLogTitle, getAlertDestinationsDeliveryLogUnavailable, + getAlertDestinationsDeliveryLogLoading, + getAlertDestinationsHeldEventsLoading, + getAlertDestinationsHeldEventsUnavailable, getAlertDestinationsDeliveryRefreshLabel, } from '@/utils/alertDestinationsPresentation'; import { formatRelativeTime } from '@/utils/format'; @@ -28,6 +31,8 @@ interface AlertDeliveryLogCardProps { onRefresh: () => void; webhooks: Webhook[]; heldEvents?: AlertEvent[]; + heldEventsUnavailable?: boolean; + refreshingHeldEvents?: boolean; } type DeliveryLogRow = @@ -99,10 +104,18 @@ export function AlertDeliveryLogCard(props: AlertDeliveryLogCardProps) { return rest.length > 0 ? `${first} +${rest.length} more` : first; }; - const entries = () => props.log?.entries ?? []; + const entries = () => (props.unavailable ? [] : (props.log?.entries ?? [])); const completedRetentionDays = () => props.log?.completedRetentionDays ?? 7; const deadLetterRetentionDays = () => props.log?.deadLetterRetentionDays ?? 30; - const rows = () => mergeDeliveryLogRows(entries(), props.heldEvents ?? []); + const rows = () => + mergeDeliveryLogRows(entries(), props.heldEventsUnavailable ? [] : (props.heldEvents ?? [])); + const attemptsLoading = () => props.refreshing || (!props.log && !props.unavailable); + const confirmedEmpty = () => + props.log !== null && + !props.unavailable && + !attemptsLoading() && + !props.heldEventsUnavailable && + !props.refreshingHeldEvents; const absoluteTimestamp = (value: string): string => { const parsed = new Date(value); @@ -149,100 +162,113 @@ export function AlertDeliveryLogCard(props: AlertDeliveryLogCardProps) {
+ + + + + + + +

+ {getAlertDestinationsDeliveryLogLoading()} +

+
+ +

+ {getAlertDestinationsHeldEventsLoading()} +

+
0} fallback={ - - } - > - 0} - fallback={ +

{getAlertDestinationsDeliveryLogEmpty()}

- } - > -
    - - {(row) => - row.kind === 'attempt' ? ( -
  • -
    - - {getAlertDeliveryLogOutcomeLabel(row.entry.outcome)} - - - {destinationLabel(row.entry)} - - - {alertSummary(row.entry)} - - -
    - + } + > +
      + + {(row) => + row.kind === 'attempt' ? ( +
    • +
      + -

      - - {(failureClass) => ( - - {getAlertDeliveryLogFailureClassLabel(failureClass())} - {row.entry.errorMessage ? ': ' : ''} - - )} - - {row.entry.errorMessage} -

      - -
    • - ) : ( -
    • -
      - - {row.event.type === 'notification_deferred' ? 'Deferred' : 'Held'} - - - {heldResourceLabel(row.event)} - - - {describeAlertEventReason(row.event.reason)} - - -
      -
    • - ) - } -
      -
    -
    + {getAlertDeliveryLogOutcomeLabel(row.entry.outcome)} + + + {destinationLabel(row.entry)} + + + {alertSummary(row.entry)} + + +
+ +

+ + {(failureClass) => ( + + {getAlertDeliveryLogFailureClassLabel(failureClass())} + {row.entry.errorMessage ? ': ' : ''} + + )} + + {row.entry.errorMessage} +

+
+ + ) : ( +
  • +
    + + {row.event.type === 'notification_deferred' ? 'Deferred' : 'Held'} + + + {heldResourceLabel(row.event)} + + + {describeAlertEventReason(row.event.reason)} + + +
    +
  • + ) + } + +
    diff --git a/frontend-modern/src/features/alerts/__tests__/DestinationsTab.recoveryediting.test.tsx b/frontend-modern/src/features/alerts/__tests__/DestinationsTab.recoveryediting.test.tsx index 2c07536cad..2d3589247e 100644 --- a/frontend-modern/src/features/alerts/__tests__/DestinationsTab.recoveryediting.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/DestinationsTab.recoveryediting.test.tsx @@ -259,7 +259,7 @@ describe('DestinationsTab recovery while editing SMTP', () => { ).toBeInTheDocument(); expect( await screen.findByText( - 'Pulse could not read the delivery log, so recent delivery activity cannot be shown.', + 'Pulse could not read the delivery log. Refresh to check delivery attempts.', ), ).toBeInTheDocument(); expect(screen.queryByText('SMTP fixture rejected')).not.toBeInTheDocument(); diff --git a/frontend-modern/src/features/alerts/__tests__/useAlertDestinationsTabState.test.tsx b/frontend-modern/src/features/alerts/__tests__/useAlertDestinationsTabState.test.tsx index b01d8e43b0..2515c6f393 100644 --- a/frontend-modern/src/features/alerts/__tests__/useAlertDestinationsTabState.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/useAlertDestinationsTabState.test.tsx @@ -156,6 +156,17 @@ describe('useAlertDestinationsTabState', () => { await Promise.resolve(); expect(result.deliveryLog()).toEqual(current); expect(result.deliveryLogUnavailable()).toBe(false); + expect(result.heldEventsUnavailable()).toBe(false); + expect(result.refreshingHeldEvents()).toBe(false); + vi.mocked(AlertsAPI.getEvents).mockRejectedValueOnce(new Error('held events denied')); + vi.mocked(NotificationsAPI.getDeliveryLog).mockResolvedValueOnce(current); + await result.loadDeliveryLog(); + expect(result.heldEvents()).toEqual([]); + expect(result.heldEventsUnavailable()).toBe(true); + expect(result.refreshingHeldEvents()).toBe(false); + expect(result.deliveryLogUnavailable()).toBe(false); + expect(result.deliveryLog()).toEqual(current); + expect(result.refreshingDeliveryLog()).toBe(false); } finally { cleanup(); diff --git a/frontend-modern/src/features/alerts/__tests__/useNotificationDeliveryLog.test.tsx b/frontend-modern/src/features/alerts/__tests__/useNotificationDeliveryLog.test.tsx index e4398987af..ad497217a0 100644 --- a/frontend-modern/src/features/alerts/__tests__/useNotificationDeliveryLog.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/useNotificationDeliveryLog.test.tsx @@ -107,6 +107,8 @@ describe('useNotificationDeliveryLog', () => { expect(state.deliveryLog()).toEqual(emptyLog); expect(state.refreshingDeliveryLog()).toBe(false); expect(state.heldEvents()).toEqual([]); + expect(state.refreshingHeldEvents()).toBe(true); + expect(state.heldEventsUnavailable()).toBe(false); } finally { held.resolve([]); dispose(); @@ -132,6 +134,8 @@ describe('useNotificationDeliveryLog', () => { expect(state.heldEvents()).toHaveLength(1); await state.loadDeliveryLog(); expect(state.heldEvents()).toEqual([]); + expect(state.heldEventsUnavailable()).toBe(true); + expect(state.refreshingHeldEvents()).toBe(false); expect(state.deliveryLog()).toEqual(emptyLog); expect(state.deliveryLogUnavailable()).toBe(false); expect(state.refreshingDeliveryLog()).toBe(false); @@ -222,6 +226,8 @@ describe('useNotificationDeliveryLog', () => { else older.reject(new Error('stale held read')); await Promise.resolve(); expect(state.heldEvents()).toEqual(current); + expect(state.heldEventsUnavailable()).toBe(false); + expect(state.refreshingHeldEvents()).toBe(false); } finally { dispose(); } @@ -264,4 +270,56 @@ describe('useNotificationDeliveryLog', () => { expect(AlertsAPI.getEvents).toHaveBeenCalledTimes(1); }), ); + it('retains the held-read warning during retry until that read succeeds', () => + createRoot(async (dispose) => { + const recovery = deferred>>(); + vi.mocked(NotificationsAPI.getDeliveryLog).mockResolvedValue(emptyLog); + vi.mocked(AlertsAPI.getEvents) + .mockRejectedValueOnce(new Error('permission denied')) + .mockReturnValueOnce(recovery.promise); + const state = useNotificationDeliveryLog(); + try { + await state.loadDeliveryLog(); + expect(state.heldEventsUnavailable()).toBe(true); + await state.loadDeliveryLog(); + expect(state.heldEventsUnavailable()).toBe(true); + expect(state.refreshingHeldEvents()).toBe(true); + expect(state.refreshingDeliveryLog()).toBe(false); + recovery.resolve([]); + await recovery.promise; + expect(state.heldEventsUnavailable()).toBe(false); + expect(state.refreshingHeldEvents()).toBe(false); + } finally { + dispose(); + } + })); + + it.each(['success', 'failure'])( + 'keeps the newest held read pending after an older %s', + (outcome) => + createRoot(async (dispose) => { + const older = deferred>>(); + const newer = deferred>>(); + vi.mocked(NotificationsAPI.getDeliveryLog).mockResolvedValue(emptyLog); + vi.mocked(AlertsAPI.getEvents) + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise); + const state = useNotificationDeliveryLog(); + try { + await state.loadDeliveryLog(); + await state.loadDeliveryLog(); + if (outcome === 'success') older.resolve([]); + else older.reject(new Error('stale denied request')); + await Promise.resolve(); + expect(state.refreshingHeldEvents()).toBe(true); + expect(state.heldEventsUnavailable()).toBe(false); + newer.resolve([]); + await newer.promise; + expect(state.refreshingHeldEvents()).toBe(false); + expect(state.heldEventsUnavailable()).toBe(false); + } finally { + dispose(); + } + }), + ); }); diff --git a/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx b/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx index 3f2e0f7718..d6462cefe9 100644 --- a/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx +++ b/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx @@ -102,6 +102,8 @@ export function DestinationsTab(props: DestinationsTabProps) { onRefresh={() => void state.loadDeliveryLog()} webhooks={state.webhooks()} heldEvents={state.heldEvents()} + heldEventsUnavailable={state.heldEventsUnavailable()} + refreshingHeldEvents={state.refreshingHeldEvents()} /> diff --git a/frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts b/frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts index 26d815d52c..86c90f37f1 100644 --- a/frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts +++ b/frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts @@ -53,6 +53,8 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro deliveryLogUnavailable, refreshingDeliveryLog, heldEvents, + heldEventsUnavailable, + refreshingHeldEvents, loadDeliveryLog, } = useNotificationDeliveryLog(); const webhookState = useAlertWebhookDestinationsState({ @@ -173,6 +175,8 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro deliveryLogUnavailable, deliveryNeedsAttention, heldEvents, + heldEventsUnavailable, + refreshingHeldEvents, dismissTerminalFailures, dismissingTerminalFailures, handleRetry, diff --git a/frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts b/frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts index 1ddceb830f..e36c6036df 100644 --- a/frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts +++ b/frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts @@ -19,12 +19,15 @@ const DELIVERY_LOG_LIMIT = 200; // Held events are the third half of that story: notifications that were // deliberately not attempted, with the mechanism that held them. They come // from the alert event log and degrade independently — a failed events read -// hides the held rows without marking delivery attempts unavailable. +// withdraws the held rows and reports that gap without hiding readable attempts. export function useNotificationDeliveryLog() { const [deliveryLog, setDeliveryLog] = createSignal(null); const [deliveryLogUnavailable, setDeliveryLogUnavailable] = createSignal(false); const [refreshingDeliveryLog, setRefreshingDeliveryLog] = createSignal(false); const [heldEvents, setHeldEvents] = createSignal([]); + const [heldEventsUnavailable, setHeldEventsUnavailable] = createSignal(false); + // Before the first read, an empty array means unknown, not no held alerts. + const [refreshingHeldEvents, setRefreshingHeldEvents] = createSignal(true); // Both reads belong to the same refresh, but held events never block the log. let latestRequest = 0; @@ -35,6 +38,7 @@ export function useNotificationDeliveryLog() { const ownsRequest = (request: number) => !disposed && request === latestRequest; const loadHeldEvents = async (request: number) => { + setRefreshingHeldEvents(true); try { const since = new Date( Date.now() - HELD_EVENT_WINDOW_DAYS * 24 * 60 * 60 * 1000, @@ -44,11 +48,16 @@ export function useNotificationDeliveryLog() { since, limit: HELD_EVENT_LIMIT, }); - if (ownsRequest(request)) setHeldEvents(events); + if (!ownsRequest(request)) return; + setHeldEvents(events); + setHeldEventsUnavailable(false); } catch (error) { if (!ownsRequest(request)) return; logger.error('Failed to load held alert notification events', error); setHeldEvents([]); + setHeldEventsUnavailable(true); + } finally { + if (ownsRequest(request)) setRefreshingHeldEvents(false); } }; @@ -82,6 +91,8 @@ export function useNotificationDeliveryLog() { deliveryLogUnavailable, refreshingDeliveryLog, heldEvents, + heldEventsUnavailable, + refreshingHeldEvents, loadDeliveryLog, }; } diff --git a/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts b/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts index 839f7bc178..8cf26ffba5 100644 --- a/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts @@ -35,6 +35,9 @@ import { getAlertDestinationsDeliveryLogEmpty, getAlertDestinationsDeliveryLogTitle, getAlertDestinationsDeliveryLogUnavailable, + getAlertDestinationsDeliveryLogLoading, + getAlertDestinationsHeldEventsLoading, + getAlertDestinationsHeldEventsUnavailable, getAlertDestinationsTestPausedWarning, getAlertDestinationsAppriseTargetsHelp, getAlertDestinationsAppriseTestLabel, @@ -267,6 +270,28 @@ describe('alert destinations delivery log copy', () => { ); }); + it('distinguishes pending and unavailable activity sources without implying delivery failure', () => { + expect(getAlertDestinationsDeliveryLogLoading()).toBe('Loading delivery attempts...'); + expect(getAlertDestinationsHeldEventsLoading()).toBe( + 'Loading held and deferred notifications...', + ); + expect(getAlertDestinationsHeldEventsUnavailable()).toBe( + 'Pulse could not read held or deferred notifications. Refresh to try again.', + ); + expect(getAlertDestinationsDeliveryLogUnavailable()).toBe( + 'Pulse could not read the delivery log. Refresh to check delivery attempts.', + ); + for (const message of [ + getAlertDestinationsDeliveryLogLoading(), + getAlertDestinationsHeldEventsLoading(), + getAlertDestinationsHeldEventsUnavailable(), + getAlertDestinationsDeliveryLogUnavailable(), + ]) { + expect(message).not.toContain('No alert deliveries'); + expect(message).not.toContain('not delivered'); + } + }); + it('warns that a passing test does not mean live alerts flow while delivery is paused', () => { const warning = getAlertDestinationsTestPausedWarning(); expect(warning).toContain('Test sent'); diff --git a/frontend-modern/src/utils/alertDestinationsPresentation.ts b/frontend-modern/src/utils/alertDestinationsPresentation.ts index 92ad797dc7..1fc3454103 100644 --- a/frontend-modern/src/utils/alertDestinationsPresentation.ts +++ b/frontend-modern/src/utils/alertDestinationsPresentation.ts @@ -277,7 +277,19 @@ export const ALERT_DESTINATIONS_DELIVERY_LOG_TITLE = 'Recent delivery activity'; export const ALERT_DESTINATIONS_DELIVERY_LOG_EMPTY = 'No alert deliveries were attempted in this window.'; export const ALERT_DESTINATIONS_DELIVERY_LOG_UNAVAILABLE = - 'Pulse could not read the delivery log, so recent delivery activity cannot be shown.'; + 'Pulse could not read the delivery log. Refresh to check delivery attempts.'; + +export function getAlertDestinationsHeldEventsUnavailable() { + return 'Pulse could not read held or deferred notifications. Refresh to try again.'; +} + +export function getAlertDestinationsDeliveryLogLoading() { + return 'Loading delivery attempts...'; +} + +export function getAlertDestinationsHeldEventsLoading() { + return 'Loading held and deferred notifications...'; +} export function getAlertDestinationsDeliveryLogTitle() { return ALERT_DESTINATIONS_DELIVERY_LOG_TITLE;