From 8f09a5999617cb79a4e2b0a43b35de2f3b8f0d87 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:54:32 +0100 Subject: [PATCH] Keep failed PBS datastore reads out of healthy capacity Preserve named unavailable and unusable datastore evidence without reporting healthy empty capacity. Keep measured zero, warning thresholds, independent backup inventory and row identity, with readable phone notices and connected contracts and controls. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 17 ++ .../internal/subsystems/storage-recovery.md | 21 ++ .../internal/subsystems/unified-resources.md | 13 + .../browser-tests/pbs-capacity.cjs | 228 ++++++++++++++++++ .../browser-tests/pbs-capacity.html | 12 + .../browser-tests/pbs-capacity.tsx | 80 ++++++ .../proxmox/ProxmoxBackupServersTable.tsx | 123 +++++++--- ...roxmoxBackupServersTable.capacity.test.tsx | 146 +++++++++++ .../ProxmoxBackupServersTable.drawer.test.tsx | 4 +- .../ProxmoxBackupServersTable.test.ts | 19 ++ .../__tests__/ProxmoxBackupsTable.test.tsx | 46 ++++ .../proxmoxBackupsTablePresentation.test.ts | 5 +- .../proxmoxBackupsTablePresentation.ts | 4 +- frontend-modern/src/types/resource.ts | 1 + 14 files changed, 683 insertions(+), 36 deletions(-) create mode 100644 frontend-modern/browser-tests/pbs-capacity.cjs create mode 100644 frontend-modern/browser-tests/pbs-capacity.html create mode 100644 frontend-modern/browser-tests/pbs-capacity.tsx create mode 100644 frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.capacity.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index b43e1bb73f..23a1cfed3e 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -20,6 +20,23 @@ ## Purpose +### PBS capacity warnings stay truthful and readable + +The existing Backups server table distinguishes unavailable or unusable named +datastore capacity from an explicitly measured empty store. Fixed **Unavailable** +and **Unknown** notices replace unreliable percentages, bytes and deduplication +figures without exposing a provider error body. Server reachability, independent +backup inventory and drawer identity retain their own evidence and semantics. +Measured zero and the existing high-capacity warning thresholds stay unchanged. + +At compact widths, the shared 40% server-identity anchor stays in place; the Used +column receives 25% so the warning remains readable without horizontal overflow. +The redundant connection dot yields to its textual status. Datastore hover text +does not change disclosure or row identity. The mounted capacity, drawer and +full-page Backups tests plus `browser-tests/pbs-capacity.cjs` cover replacement, +independent inventory, keyboard/touch and 320px/390px/desktop rendering. Synthetic +presentation proof is not native datastore, backup, guest-thaw or release proof. + ### Backup age requires a usable completed timestamp Existing workload indicators, age badges and drawer protection context distinguish diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 0ebf9d1c10..4da8d3d071 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -21,6 +21,27 @@ ## Purpose +### PBS datastore capacity is not inferred from failed-read zeros + +The existing Backups server table distinguishes a named datastore whose status +read failed from an empty datastore and from a server without datastore data. +Provider unavailable/error evidence or an unavailable server connection +withdraws current capacity and deduplication figures, without dropping the row, +changing its drawer identity or invalidating independently readable backup +inventory. Unknown status, missing/nonfinite/negative capacity and invalid +authoritative percentages cannot receive a healthy usage indication. Capacity +requires a positive finite total and a finite nonnegative used observation; +measured zero remains zero, and the existing 75/90-percent warnings and +over-capacity danger remain intact. A subsequent valid same-identity snapshot +restores presentation without a remount, timer or new read. + +Fixed notices do not expose provider error text. Mounted +`ProxmoxBackupServersTable.capacity.test.tsx` covers these distinctions and +direct/canonical replacement; `ProxmoxBackupServersTable.test.ts` preserves +source evidence and independent datastore identity. The production-component +browser fixture checks desktop and narrow phone layouts. These synthetic +checks establish no native datastore repair, backup/thaw or release acceptance. + ### TrueNAS connection probes do not settle recovery inventory A successful settings probe reads system information only, on a separate client. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index ce79bd7a83..b6ad600f88 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -23,6 +23,19 @@ and sort the complete canonical change table while startup and ingestion wait. ## Purpose +### PBS capacity consumers preserve explicit read failure + +`ResourcePBSDatastore.error` describes existing provider read-failure evidence, +not a new collection or command. PBS resource facets retain that evidence +through canonical presentation. The Backups capacity consumer must not treat +the provider's zero-valued unavailable datastore as a successful empty-store +observation, nor show retained numbers as current when the connection is +unavailable. It keeps row/source identity and independent inventory counts; +fixed unavailable/unknown notices never disclose the error body. Valid capacity +including measured zero restores the same mounted row. Verification uses +`ProxmoxBackupServersTable.test.ts` plus the direct/canonical mounted capacity +controls; native read, backup and published acceptance remain separate. + ### History consumers do not accept a mismatched or malformed success body The common metrics History client validates the issued selection and response diff --git a/frontend-modern/browser-tests/pbs-capacity.cjs b/frontend-modern/browser-tests/pbs-capacity.cjs new file mode 100644 index 0000000000..5ba31895d6 --- /dev/null +++ b/frontend-modern/browser-tests/pbs-capacity.cjs @@ -0,0 +1,228 @@ +process.env.RAYON_NUM_THREADS = '2'; +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); +const root = '/workspace/frontend-modern'; +const output = '/workspace/tmp/pbs-capacity/browser'; +const origin = 'http://127.0.0.1:5321'; +const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const runtime = [ + 'src/features/proxmox/ProxmoxBackupServersTable.tsx', + 'src/features/proxmox/proxmoxBackupsTablePresentation.ts', + 'src/types/resource.ts', +]; + +(async () => { + fs.mkdirSync(output, { recursive: true }); + process.chdir(root); + const clientVersion = require('playwright/package.json').version; + assert.equal( + clientVersion, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + cacheDir: path.join(output, 'vite-cache'), + optimizeDeps: { entries: ['browser-tests/pbs-capacity.html'] }, + server: { host: '127.0.0.1', port: 5321, strictPort: true }, + }); + const results = []; + let browser; + try { + await server.listen(); + for (const [name, engine, width] of [ + ['chromium-desktop', chromium, 1365], + ['webkit-phone', webkit, 390], + ['webkit-narrow', webkit, 320], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + for (const transport of ['direct', 'canonical']) { + const phone = engine === webkit; + const page = await browser.newPage({ + viewport: { width, height: phone ? 844 : 900 }, + isMobile: phone, + hasTouch: phone, + colorScheme: phone ? 'dark' : 'light', + }); + if (phone) await page.addInitScript(() => document.documentElement.classList.add('dark')); + page.setDefaultTimeout(20000); + await page.clock.setFixedTime(new Date('2026-10-04T12:00:00Z')); + const errors = [], + offOrigin = [], + requests = [], + checks = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.route('**/*', async (route) => { + const req = route.request(), + url = new URL(req.url()); + if (url.origin !== origin) { + offOrigin.push(url.origin); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + requests.push({ path: url.pathname, method: req.method() }); + const data = + url.pathname === '/api/backups/pve' + ? { backupTasks: [], storageBackups: [], guestSnapshots: [] } + : url.pathname === '/api/backups/pbs' + ? { + backups: [ + { + id: 'pbs-point', + vmid: '100', + backupType: 'vm', + backupTime: '2026-10-04T11:00:00Z', + files: ['index.json.blob'], + instance: 'pbs-main', + datastore: 'main', + protected: false, + verified: true, + }, + ], + } + : { postures: [] }; + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ data }), + }); + }); + await page.goto(`${origin}/browser-tests/pbs-capacity.html?transport=${transport}`); + // Imported production theme stores can initialise after addInitScript. + // Apply the requested CSS context after the fixture is loaded too. + if (phone) await page.evaluate(() => document.documentElement.classList.add('dark')); + const table = page.locator('[data-proxmox-backups-table="servers"]'); + await table.waitFor(); + const head = await table.locator('th').allTextContents(); + const usedIndex = head.indexOf('Used'); + assert(usedIndex >= 0); + const cell = table.locator('tbody tr').first().locator('td').nth(usedIndex); + await cell.locator('text=40.0%').waitFor(); + await cell.evaluate((node) => { + node.dataset.identityWitness = 'original'; + }); + const activate = async (state) => { + const control = page.getByRole('button', { name: state, exact: true }); + if (phone) await control.tap(); + else { + await control.focus(); + await control.press('Enter'); + } + }; + const checkNotice = async (state, label) => { + await activate(state); + await cell.getByText(label, { exact: true }).waitFor(); + const evidence = await cell.evaluate((node) => ({ + text: node.textContent, + witness: node.dataset.identityWitness, + success: !!node.querySelector('.bg-emerald-500'), + title: node.querySelector('[title]')?.title, + })); + assert.equal(evidence.text, label); + assert.equal(evidence.witness, 'original'); + assert.equal(evidence.success, false); + assert(evidence.title.includes('does not mean the datastore is empty')); + assert(!(await table.textContent()).includes('2.0×')); + assert( + !(await page.locator('body').innerHTML()).includes('PRIVATE_PROVIDER_ERROR_SENTINEL'), + ); + checks.push({ state, evidence }); + }; + await checkNotice('Failure', 'Unavailable'); + await page.screenshot({ + path: path.join(output, `${name}-${transport}-unavailable.png`), + fullPage: true, + }); + await checkNotice('Unknown', 'Unknown'); + await checkNotice('Offline', 'Unavailable'); + await checkNotice('Error', 'Unavailable'); + await activate('Zero'); + await cell.getByText('0.0%', { exact: true }).waitFor(); + await activate('Full'); + await cell.getByText('95.0%', { exact: true }).waitFor(); + assert((await cell.locator('.text-red-600').count()) > 0); + checks.push({ + state: 'valid zero / full', + mounted: await cell.getAttribute('data-identity-witness'), + }); + await activate('No stores'); + await cell.getByText('No datastore data').waitFor(); + await activate('Healthy'); + await cell.getByText('40.0%', { exact: true }).waitFor(); + await activate('Failure'); + await cell.getByText('Unavailable', { exact: true }).waitFor(); + const geometry = await cell.evaluate((node) => { + const label = node.querySelector('span'), + range = document.createRange(); + range.selectNodeContents(label); + const text = range.getBoundingClientRect(), + box = node.getBoundingClientRect(); + return { + text: { x: text.x, right: text.right, width: text.width }, + cell: { x: box.x, right: box.right, width: box.width }, + pageWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + }; + }); + assert( + geometry.text.x >= geometry.cell.x && geometry.text.right <= geometry.cell.right, + JSON.stringify({ name, geometry }), + ); + assert(geometry.scrollWidth <= geometry.pageWidth + 1, JSON.stringify(geometry)); + checks.push({ state: 'warning fits', geometry }); + assert.equal(errors.length, 0, errors.join('\n')); + assert.equal(offOrigin.length, 0); + assert(requests.every((req) => req.method === 'GET')); + results.push({ + name, + transport, + width, + browserVersion: browser.version(), + checks, + requests, + errors, + offOrigin, + }); + await page.close(); + } + await browser.close(); + browser = undefined; + } + fs.writeFileSync( + path.join(output, 'result.json'), + JSON.stringify( + { + result: 'passed', + clientVersion, + results, + content_sha256: Object.fromEntries( + runtime.map((file) => [`frontend-modern/${file}`, sha256(path.join(root, file))]), + ), + }, + null, + 2, + ), + ); + console.log(JSON.stringify({ result: 'passed', contexts: results.length, output })); + } catch (error) { + fs.writeFileSync( + path.join(output, 'failure.json'), + JSON.stringify({ message: error.message, completedContexts: results }, null, 2), + ); + throw error; + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { + console.error(error.stack); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/pbs-capacity.html b/frontend-modern/browser-tests/pbs-capacity.html new file mode 100644 index 0000000000..8cdc08fe2a --- /dev/null +++ b/frontend-modern/browser-tests/pbs-capacity.html @@ -0,0 +1,12 @@ + + + + + + PBS capacity evidence + + +
+ + + diff --git a/frontend-modern/browser-tests/pbs-capacity.tsx b/frontend-modern/browser-tests/pbs-capacity.tsx new file mode 100644 index 0000000000..7dab6d9930 --- /dev/null +++ b/frontend-modern/browser-tests/pbs-capacity.tsx @@ -0,0 +1,80 @@ +// Production table/router/CSS and canonical resource adapter. Synthetic read +// states only: no provider, native datastore, backup or thaw operation. +import { createMemo, createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Route, Router } from '@solidjs/router'; +import type { Resource, ResourcePBSDatastore } from '../src/types/resource'; +import { canonicalizeRealtimeResource } from '../src/utils/resourceStateAdapters'; +import { ProxmoxBackupsTable } from '../src/features/proxmox/ProxmoxBackupsTable'; +import '../src/index.css'; + +const states = ['Healthy', 'Failure', 'Unknown', 'Offline', 'Zero', 'Full', 'Error', 'No stores']; +const normal = { + name: 'main', + total: 10000, + used: 4000, + available: 6000, + usagePercent: 40, + status: 'available', + deduplicationFactor: 2, +}; + +function Fixture() { + const [state, setState] = createSignal('Healthy'); + const servers = createMemo((): Resource[] => { + let store: ResourcePBSDatastore = { ...normal }; + if (state() === 'Failure') + store = { ...store, total: 0, used: 0, available: 0, usagePercent: 0, status: 'unavailable' }; + if (state() === 'Unknown') + store = { ...store, total: 0, used: 0, available: 0, usagePercent: 0 }; + if (state() === 'Zero') store = { ...store, used: 0, usagePercent: 0, available: 10000 }; + if (state() === 'Full') store = { ...store, used: 9500, usagePercent: 95, available: 500 }; + if (state() === 'Error') store = { ...store, error: 'PRIVATE_PROVIDER_ERROR_SENTINEL' }; + const pbs = { + instanceId: 'pbs-main', + connectionHealth: state() === 'Offline' ? 'offline' : 'healthy', + version: '3.2.1', + datastores: state() === 'No stores' ? [] : [store], + }; + const resource: Resource = { + id: 'pbs-main', + type: 'pbs', + name: 'pbs-main', + platformId: 'pbs-main', + platformType: 'proxmox-pbs', + sourceType: 'api', + status: 'online', + lastSeen: Date.parse('2026-10-04T12:00:00Z'), + cpu: { current: 12 }, + memory: { current: 40, total: 10000, used: 4000 }, + pbs, + }; + if (new URLSearchParams(location.search).get('transport') === 'canonical') { + resource.pbs = undefined; + resource.platformData = { sources: ['pbs'], pbs }; + return [canonicalizeRealtimeResource(resource)]; + } + return [resource]; + }); + return ( +
+
+ {states.map((value) => ( + + ))} +
+ } workloads={[]} servers={servers()} /> +
+ ); +} + +render( + () => ( + + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx index 44997db7e3..8cb2aa0be7 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx @@ -72,7 +72,7 @@ function buildBackupCounts(backups: readonly PBSBackup[]): Map { function serverIsOnline(resource: Resource): boolean { const status = (resource.status ?? '').toLowerCase(); - const conn = (resource.pbs?.connectionHealth ?? '').toLowerCase(); + const conn = (resource.pbs?.connectionHealth ?? '').trim().toLowerCase(); if (conn) return conn === 'healthy' || conn === 'ok'; return status === 'online' || status === 'running'; } @@ -83,10 +83,39 @@ function connectionLabel(resource: Resource): string { return serverIsOnline(resource) ? 'Online' : 'Offline'; } -function usagePercent(datastore: ResourcePBSDatastore): number | undefined { - if (typeof datastore.usagePercent === 'number') return datastore.usagePercent; - if (datastore.total > 0) return (datastore.used / datastore.total) * 100; - return undefined; +// PBS retains a named datastore when its status read fails, with zero-valued +// capacity and explicit unavailable/error evidence. Those zeros are not an +// empty datastore. Reachability alone cannot establish a successful store read. +function datastoreCapacity(datastore: ResourcePBSDatastore | undefined, online: boolean) { + if (!datastore) return { state: 'absent' as const, percent: undefined }; + const status = + typeof datastore.status === 'string' ? datastore.status.trim().toLowerCase() : datastore.status; + if ( + !online || + (typeof datastore.error === 'string' && Boolean(datastore.error.trim())) || + ['unavailable', 'offline', 'error', 'failed'].includes(status ?? '') + ) { + return { state: 'unavailable' as const, percent: undefined }; + } + // Empty status is supported by older payloads; an unrecognised explicit + // status must not be interpreted as current healthy capacity. + if ( + (status && !['available', 'online', 'healthy', 'ok'].includes(status)) || + !Number.isFinite(datastore.total) || + datastore.total <= 0 || + !Number.isFinite(datastore.used) || + datastore.used < 0 + ) { + return { state: 'unknown' as const, percent: undefined }; + } + const percent = + datastore.usagePercent !== undefined + ? datastore.usagePercent + : (datastore.used / datastore.total) * 100; + if (!Number.isFinite(percent) || percent < 0) { + return { state: 'unknown' as const, percent: undefined }; + } + return { state: 'available' as const, percent }; } // >=90% is the silent-backup-failure danger zone; >=75% is the early warning. @@ -480,7 +509,12 @@ export function ProxmoxBackupServersTable(props: { estimatedRowHeight={32} > {(row) => { - const pct = () => (row.datastore ? usagePercent(row.datastore) : undefined); + const capacity = () => datastoreCapacity(row.datastore, row.online); + const pct = () => capacity().percent; + const capacityNotice = () => + capacity().state === 'unavailable' + ? 'Datastore capacity is unavailable. This does not mean the datastore is empty.' + : 'Datastore capacity is unknown. This does not mean the datastore is empty.'; const rowIdentity = { id: row.key }; const isExpanded = () => detail.isExpanded(rowIdentity); const detailRowId = () => detail.detailRowId(rowIdentity); @@ -510,13 +544,18 @@ export function ProxmoxBackupServersTable(props: {
- + + + - + > {row.connectionLabel}
@@ -579,6 +618,7 @@ export function ProxmoxBackupServersTable(props: { {row.datastore?.name ?? '—'} @@ -589,25 +629,39 @@ export function ProxmoxBackupServersTable(props: { fallback={No datastore data} > {(datastore) => ( -
- - - - - - - - - {formatPlatformTableBytesValue(datastore().used, '0 B')} /{' '} - {formatPlatformTableBytesValue(datastore().total)} + + {capacity().state === 'unavailable' ? 'Unavailable' : 'Unknown'} - -
+ } + > +
+ + + + + + + + + {formatPlatformTableBytesValue(datastore().used, '0 B')} /{' '} + {formatPlatformTableBytesValue(datastore().total)} + + +
+
)}
@@ -645,10 +699,15 @@ export function ProxmoxBackupServersTable(props: { class={`${getPlatformTableCellClassForKind('numeric-value')} text-muted tabular-nums text-[11px]`} > 0 + } fallback={—} > - {(factor) => <>{factor().toFixed(1)}×} + {row.datastore?.deduplicationFactor?.toFixed(1)}× diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.capacity.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.capacity.test.tsx new file mode 100644 index 0000000000..a3861347a7 --- /dev/null +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.capacity.test.tsx @@ -0,0 +1,146 @@ +import { cleanup, render, waitFor, within } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Resource, ResourcePBSDatastore } from '@/types/resource'; +import { canonicalizeRealtimeResource } from '@/utils/resourceStateAdapters'; +import { ProxmoxBackupServersTable } from '../ProxmoxBackupServersTable'; + +vi.mock('@/components/Infrastructure/ResourceDetailDrawer', () => ({ + ResourceDetailDrawer: () =>
Details
, +})); + +afterEach(cleanup); + +const datastore = (patch: Record = {}): ResourcePBSDatastore => + ({ + name: 'tank', + total: 1000, + used: 400, + available: 600, + status: 'available', + deduplicationFactor: 2, + ...patch, + }) as ResourcePBSDatastore; + +const server = (store?: ResourcePBSDatastore, health = 'healthy'): Resource => ({ + id: 'pbs-capacity', + type: 'pbs', + name: 'pbs-capacity', + status: 'online', + platformId: 'pbs-capacity', + platformType: 'proxmox-pbs', + sourceType: 'api', + lastSeen: 1_790_000_000_000, + pbs: { instanceId: 'pbs-capacity', connectionHealth: health, datastores: store ? [store] : [] }, +}); + +function usedCell(container: HTMLElement) { + const table = container.querySelector('table')!; + const index = Array.from(table.querySelectorAll('th')).findIndex( + (head) => head.textContent === 'Used', + ); + return table.querySelector('tbody tr')!.querySelectorAll('td')[index] as HTMLElement; +} + +describe('PBS datastore capacity evidence', () => { + it.each([ + [ + 'provider failure zeros', + { total: 0, used: 0, available: 0, usagePercent: 0, status: 'unavailable' }, + 'Unavailable', + ], + ['retained failure values', { status: 'unavailable', usagePercent: 40 }, 'Unavailable'], + [ + 'explicit error with healthy status', + { error: 'PRIVATE_PROVIDER_ERROR_SENTINEL' }, + 'Unavailable', + ], + ['unknown status', { status: 'unknown', usagePercent: 0 }, 'Unknown'], + ['future status', { status: 'new-provider-state' }, 'Unknown'], + ['zero total', { total: 0, used: 0, usagePercent: 0 }, 'Unknown'], + ['missing used', { used: undefined }, 'Unknown'], + ['negative used', { used: -10 }, 'Unknown'], + ['nonfinite total', { total: Infinity }, 'Unknown'], + ['invalid authoritative percent', { usagePercent: NaN }, 'Unknown'], + ['negative authoritative percent', { usagePercent: -1 }, 'Unknown'], + ['null authoritative percent', { usagePercent: null }, 'Unknown'], + ['string authoritative percent', { usagePercent: '0' }, 'Unknown'], + ['overflowed ratio', { used: Number.MAX_VALUE, total: Number.MIN_VALUE }, 'Unknown'], + ])('shows %s as unknown evidence rather than healthy usage', async (_name, patch, label) => { + const { container } = render(() => ( + 1200} /> + )); + const cell = usedCell(container); + await waitFor(() => expect(within(cell).getByText(label)).toBeInTheDocument()); + expect(cell.textContent).not.toMatch(/%|0 B/); + expect(cell.querySelector('.bg-emerald-500')).toBeNull(); + expect(cell.querySelector('[title]')?.getAttribute('title')).toContain( + 'does not mean the datastore is empty', + ); + expect(container.textContent).not.toContain('2.0×'); + expect(container.innerHTML).not.toContain('PRIVATE_PROVIDER_ERROR_SENTINEL'); + expect(within(container).getByText('Healthy')).toBeInTheDocument(); + }); + + it('withdraws retained capacity and dedup when the PBS connection is offline', () => { + const { container } = render(() => ( + 1200} + /> + )); + expect(usedCell(container).textContent).toBe('Unavailable'); + expect(container.textContent).not.toContain('2.0×'); + }); + + it.each([ + [0, '0.0%', 'bg-emerald-500'], + [750, '75.0%', 'bg-amber-500'], + [900, '90.0%', 'bg-red-500'], + [1100, '110.0%', 'bg-red-500'], + ])('keeps measured usage %s and the existing warning thresholds', async (used, label, tone) => { + const { container } = render(() => ( + 1200} /> + )); + const cell = usedCell(container); + await waitFor(() => expect(within(cell).getByText(label)).toBeInTheDocument()); + expect(cell.querySelector(`.${tone}`)).not.toBeNull(); + expect(container.textContent).toContain('2.0×'); + }); + + it('keeps a server without datastore inventory distinct from an unknown named store', () => { + const { container } = render(() => ( + 1200} /> + )); + expect(usedCell(container).textContent).toBe('No datastore data'); + }); + + it.each(['direct', 'canonical'] as const)( + 'reconciles same-identity %s failure and recovery without remounting', + async (transport) => { + const present = (store: ResourcePBSDatastore): Resource => { + const resource = server(store); + if (transport === 'direct') return resource; + const { pbs, ...base } = resource; + return canonicalizeRealtimeResource({ ...base, platformData: { sources: ['pbs'], pbs } }); + }; + const [servers, setServers] = createSignal([present(datastore())]); + const { container } = render(() => ( + 1200} /> + )); + const cell = usedCell(container); + await waitFor(() => expect(cell.textContent).toContain('40.0%')); + setServers([ + present(datastore({ total: 0, used: 0, usagePercent: 0, status: 'unavailable' })), + ]); + await waitFor(() => expect(cell.textContent).toBe('Unavailable')); + expect(usedCell(container)).toBe(cell); + setServers([present(datastore({ used: 0, usagePercent: 0 }))]); + await waitFor(() => expect(cell.textContent).toContain('0.0%')); + expect(usedCell(container)).toBe(cell); + setServers([present(datastore({ used: 950, usagePercent: 95 }))]); + await waitFor(() => expect(cell.textContent).toContain('95.0%')); + expect(cell.querySelector('.bg-red-500')).not.toBeNull(); + }, + ); +}); diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.drawer.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.drawer.test.tsx index 05854d4787..4dd8562b69 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.drawer.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.drawer.test.tsx @@ -100,7 +100,9 @@ describe('ProxmoxBackupServersTable details', () => { const [servers, setServers] = createSignal(makeServers()); const { container } = render(() => ); const datastoreNames = () => - Array.from(container.querySelectorAll('td[title]')).map((cell) => cell.getAttribute('title')); + Array.from(container.querySelectorAll('tbody tr td:first-child[title]')).map((cell) => + cell.getAttribute('title'), + ); fireEvent.click(screen.getAllByRole('button', { name: 'Expand details for pbs-main' })[1]); const detail = screen.getByTestId('pbs-resource-detail'); expect(datastoreNames()).toEqual(['pbs-main · archive', 'pbs-main · tank']); diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.test.ts b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.test.ts index 6dfa3b16c3..222caf0d18 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.test.ts @@ -30,6 +30,25 @@ const makePbsResource = (overrides: Partial = {}): Resource => }) as Resource; describe('buildBackupServerRows', () => { + it('retains failed datastore-read evidence without changing server or row identity', () => { + const server = makePbsResource(); + server.pbs!.datastores![0] = { + name: 'tank', + total: 0, + used: 0, + available: 0, + usagePercent: 0, + status: 'unavailable', + error: 'synthetic status failure', + }; + const rows = buildBackupServerRows([server]); + const tank = rows.find((row) => row.datastore?.name === 'tank')!; + expect(tank.key).toBe('pbs-1:tank'); + expect(tank.online).toBe(true); + expect(tank.datastore).toEqual(server.pbs!.datastores![0]); + expect(rows.find((row) => row.datastore?.name === 'offsite')?.datastore?.usagePercent).toBe(95); + }); + it('carries host CPU, memory, and uptime onto every datastore row of the server', () => { const rows = buildBackupServerRows([makePbsResource()]); diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx index 2b6d786cc0..d047855666 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx @@ -214,6 +214,52 @@ beforeEach(() => { }); describe('ProxmoxBackupsTable', () => { + it('keeps successful backup inventory independent of unavailable datastore capacity', async () => { + mockBackupAPIs(); + const failed = { + ...pbsServerResource, + pbs: { + ...pbsServerResource.pbs!, + datastores: [ + { + name: 'main', + total: 0, + used: 0, + available: 0, + usagePercent: 0, + status: 'unavailable', + error: 'PRIVATE_PROVIDER_ERROR_SENTINEL', + }, + ], + }, + } as Resource; + const [servers, setServers] = createSignal([failed]); + const { container } = renderInRouter(() => ( + } + workloads={[workloadResource]} + servers={servers()} + /> + )); + const table = container.querySelector('[data-proxmox-backups-table="servers"]') as HTMLElement; + const backupCount = () => { + const index = Array.from(table.querySelectorAll('th')).findIndex( + (head) => head.getAttribute('aria-label') === 'Backups', + ); + return table.querySelector('tbody tr')!.querySelectorAll('td')[index].textContent; + }; + await within(table).findByText('Unavailable'); + await waitFor(() => expect(backupCount()).toBe('1')); + await screen.findAllByText('Protected'); + expect(container.innerHTML).not.toContain('PRIVATE_PROVIDER_ERROR_SENTINEL'); + expect(apiFetchMock.mock.calls.filter(([url]) => url === '/api/backups/pbs')).toHaveLength(1); + + setServers([pbsServerResource]); + await waitFor(() => expect(within(table).queryByText('Unavailable')).not.toBeInTheDocument()); + expect(backupCount()).toBe('1'); + expect(apiFetchMock.mock.calls.filter(([url]) => url === '/api/backups/pbs')).toHaveLength(1); + }); + it('uses a corroborated PBS host link for Backups History despite a PVE-only name collision', () => { const pbs = { ...pbsServerResource, diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts index ac7c723426..065410f49a 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts @@ -46,8 +46,9 @@ describe('Proxmox backups responsive table presentation', () => { 'used', ]); expect(getBackupServerColumnWidthStyle('server', 'compact')).toEqual({ width: '40%' }); - expect(getBackupServerColumnWidthStyle('status', 'compact')).toEqual({ width: '23%' }); - expect(getBackupServerColumnWidthStyle('used', 'compact')).toEqual({ width: '15%' }); + expect(getBackupServerColumnWidthStyle('status', 'compact')).toEqual({ width: '18%' }); + expect(getBackupServerColumnWidthStyle('used', 'compact')).toEqual({ width: '25%' }); + expect(getBackupServerColumnWidthStyle('datastore', 'compact')).toEqual({ width: '17%' }); expect(ids(getBackupServerColumns('basic'))).toEqual([ 'server', 'status', diff --git a/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts b/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts index e2299253bc..f62754f695 100644 --- a/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts +++ b/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts @@ -107,7 +107,9 @@ const BACKUP_SERVER_WEIGHTS: Record< ProxmoxBackupsTableLayoutMode, Partial> > = { - compact: { server: 40, status: 23, datastore: 22, used: 15 }, + // Capacity must fit "Unavailable", not just a short percentage. Keep the + // shared 40% identity anchor; the compact status omits its redundant dot. + compact: { server: 40, status: 18, datastore: 17, used: 25 }, // 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 }, diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index c79d1900d0..36899574e6 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -466,6 +466,7 @@ export interface ResourcePBSDatastore { available?: number; usagePercent?: number; status?: string; + error?: string; deduplicationFactor?: number; }