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; }