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