Integrate reviewed PBS capacity evidence and phone warnings

Preserve the exact Web candidate and final-content browser receipts. Retain both Diagnostics and PBS contract sections in the documentation-only conflict resolution.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-04 13:26:01 +01:00
16 changed files with 804 additions and 36 deletions
@@ -50,6 +50,23 @@ API reader and local downloads with synthetic responses on Chromium and touch
WebKit, including loading/failure, keyboard, narrow layouts and the Docs link.
These proofs do not run native guest checks or establish release availability.
### 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
@@ -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.
@@ -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
@@ -0,0 +1,233 @@
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 }),
});
});
// Verify the actual table and its font metrics, not the dev server's
// generic load event. Keep the same bounded navigation/locator deadline.
await page.goto(`${origin}/browser-tests/pbs-capacity.html?transport=${transport}`, {
waitUntil: 'domcontentloaded',
});
// 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();
await page.waitForFunction(() => document.fonts.status === 'loaded');
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;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PBS capacity evidence</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/pbs-capacity.tsx"></script>
</body>
</html>
@@ -0,0 +1,81 @@
// 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',
displayName: '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 (
<main class="p-3 sm:p-6">
<div class="mb-4 flex flex-wrap gap-2" aria-label="Synthetic observation controls">
{states.map((value) => (
<button class="rounded border px-2 py-1" type="button" onClick={() => setState(value)}>
{value}
</button>
))}
</div>
<ProxmoxBackupsTable emptyIcon={<span />} workloads={[]} servers={servers()} />
</main>
);
}
render(
() => (
<Router>
<Route path="/*" component={Fixture} />
</Router>
),
document.getElementById('root')!,
);
@@ -0,0 +1,54 @@
{
"version": 1,
"base_sha": "ccb296676c167d2b948d96a54319bc0d7b52549a",
"verified_at": "2026-10-04T11:21:10.896992Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx",
"frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts",
"frontend-modern/src/types/resource.ts"
],
"content_sha256": {
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "5d51f3fe36638bde58f0d31b550947a78adc664dc0898ca323aac4fb8db62903",
"frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts": "00bdd77055a4c1c76bd532361f5bf0905aa253a6a72adf630445eb319f4843d0",
"frontend-modern/src/types/resource.ts": "007885931ae929a05ce382fa70d9afd3ed784d5ca32fc4ccf403260675a5a6de"
},
"routes": [
"Local /browser-tests/pbs-capacity.html: production ProxmoxBackupsTable/router/CSS, direct and canonical synthetic resources"
],
"viewports": [
{
"width": 1365,
"height": 900,
"browser": "Chromium",
"input": "keyboard",
"colour": "light"
},
{
"width": 390,
"height": 844,
"browser": "WebKit",
"input": "touch",
"colour": "dark"
},
{
"width": 320,
"height": 844,
"browser": "WebKit",
"input": "touch",
"colour": "dark"
}
],
"states": [
"Healthy capacity \u2192 failed zero capacity \u2192 unknown \u2192 offline retained values \u2192 safe fixed error notice",
"Measured zero remains zero; 95% remains danger; missing datastore stays separate; ordinary readings resume",
"All six content-identical product contexts complete; earlier overall init/load failures remain adverse"
],
"interactions": [
"Desktop keyboard Enter and phone taps switch synthetic observations in the existing page",
"Same-identity notice updates preserve the mounted capacity cell; no false success dot, raw error or unreliable deduplication",
"Font-aware warning bounds and page overflow checked; all six screenshots inspected",
"No external-origin request or API mutation"
],
"evidence": "Six exact-content context controls across two retained attempts, not a claim that every browser attempt passed. See browser-context-matrix.json in the lane dossier. Earlier generic-load timeout is not cleared or re-described as a timing pass."
}
@@ -0,0 +1,54 @@
{
"version": 1,
"base_sha": "795f0b4863e46b81584c82152cc7e66974e5c9a1",
"verified_at": "2026-10-04T11:36:56.829475Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx",
"frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts",
"frontend-modern/src/types/resource.ts"
],
"content_sha256": {
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "d2063f2d535c08980f57708a9cd4871f5cd1b98de1bb007c3eb78940463a2ded",
"frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts": "00bdd77055a4c1c76bd532361f5bf0905aa253a6a72adf630445eb319f4843d0",
"frontend-modern/src/types/resource.ts": "007885931ae929a05ce382fa70d9afd3ed784d5ca32fc4ccf403260675a5a6de"
},
"routes": [
"Local /browser-tests/pbs-capacity.html: production ProxmoxBackupsTable/router/CSS, direct and canonical synthetic resources"
],
"viewports": [
{
"width": 1365,
"height": 900,
"browser": "Chromium",
"input": "keyboard",
"colour": "light"
},
{
"width": 390,
"height": 844,
"browser": "WebKit",
"input": "touch",
"colour": "dark"
},
{
"width": 320,
"height": 844,
"browser": "WebKit",
"input": "touch",
"colour": "dark"
}
],
"states": [
"Healthy capacity \u2192 failed zero capacity \u2192 unknown \u2192 offline retained values \u2192 safe fixed error notice",
"Measured zero remains zero; 95% remains danger; missing datastore stays separate; ordinary readings resume",
"All six desktop/touch product contexts complete in one final execution; earlier init/load/source failures retained separately"
],
"interactions": [
"Desktop keyboard Enter and phone taps switch synthetic observations in the existing page",
"Same-identity notice updates preserve the mounted capacity cell; no false success dot, raw error or unreliable deduplication",
"Font-aware warning bounds and page overflow checked; all six screenshots inspected",
"No external-origin request or API mutation"
],
"evidence": "Final complete six-context execution, source-bound by browser-qualified-binding/result in the lane dossier. Earlier generic-load timeout and all source failures are retained, not cleared or claimed as timing passes."
}
@@ -72,7 +72,7 @@ function buildBackupCounts(backups: readonly PBSBackup[]): Map<string, number> {
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,13 @@ export function ProxmoxBackupServersTable(props: {
estimatedRowHeight={32}
>
{(row) => {
const pct = () => (row.datastore ? usagePercent(row.datastore) : undefined);
// Read admission is not a generic storage-adapter capacity snapshot.
const capacityEvidence = () => datastoreCapacity(row.datastore, row.online);
const pct = () => capacityEvidence().percent;
const capacityNotice = () =>
capacityEvidence().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 +545,18 @@ export function ProxmoxBackupServersTable(props: {
</TableCell>
<TableCell class={getPlatformTableCellClassForKind('text')}>
<div class="flex items-center gap-2">
<StatusDot
size="sm"
variant={row.online ? 'success' : 'danger'}
<Show when={layoutMode() !== 'compact'}>
<StatusDot
size="sm"
variant={row.online ? 'success' : 'danger'}
title={row.connectionLabel}
ariaHidden
/>
</Show>
<span
class="truncate text-[11px] text-base-content"
title={row.connectionLabel}
ariaHidden
/>
<span class="truncate text-[11px] text-base-content">
>
{row.connectionLabel}
</span>
</div>
@@ -579,6 +619,7 @@ export function ProxmoxBackupServersTable(props: {
<Show when={columnVisible('datastore')}>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content truncate font-mono text-[11px]`}
title={row.datastore?.name}
>
{row.datastore?.name ?? '—'}
</TableCell>
@@ -589,25 +630,41 @@ export function ProxmoxBackupServersTable(props: {
fallback={<span class="text-muted">No datastore data</span>}
>
{(datastore) => (
<div class="flex items-center justify-end gap-2">
<Show when={layoutMode() !== 'compact' && layoutMode() !== 'basic'}>
<StatusDot
size="sm"
variant={usageVariant(pct())}
title={`Datastore ${formatPlatformTablePercentValue(pct())} used`}
ariaHidden
/>
</Show>
<span class={`tabular-nums font-medium ${usageToneClass(pct())}`}>
<PlatformTablePercentValue value={pct()} />
</span>
<Show when={layoutMode() !== 'compact'}>
<span class="truncate text-[10px] text-muted tabular-nums">
{formatPlatformTableBytesValue(datastore().used, '0 B')} /{' '}
{formatPlatformTableBytesValue(datastore().total)}
<Show
when={capacityEvidence().state === 'available'}
fallback={
<span
class="text-[11px] text-amber-600 dark:text-amber-300"
title={capacityNotice()}
>
{capacityEvidence().state === 'unavailable'
? 'Unavailable'
: 'Unknown'}
</span>
</Show>
</div>
}
>
<div class="flex items-center justify-end gap-2">
<Show
when={layoutMode() !== 'compact' && layoutMode() !== 'basic'}
>
<StatusDot
size="sm"
variant={usageVariant(pct())}
title={`Datastore ${formatPlatformTablePercentValue(pct())} used`}
ariaHidden
/>
</Show>
<span class={`tabular-nums font-medium ${usageToneClass(pct())}`}>
<PlatformTablePercentValue value={pct()} />
</span>
<Show when={layoutMode() !== 'compact'}>
<span class="truncate text-[10px] text-muted tabular-nums">
{formatPlatformTableBytesValue(datastore().used, '0 B')} /{' '}
{formatPlatformTableBytesValue(datastore().total)}
</span>
</Show>
</div>
</Show>
)}
</Show>
</TableCell>
@@ -645,10 +702,15 @@ export function ProxmoxBackupServersTable(props: {
class={`${getPlatformTableCellClassForKind('numeric-value')} text-muted tabular-nums text-[11px]`}
>
<Show
when={row.datastore?.deduplicationFactor}
when={
capacityEvidence().state === 'available' &&
typeof row.datastore?.deduplicationFactor === 'number' &&
Number.isFinite(row.datastore.deduplicationFactor) &&
row.datastore.deduplicationFactor > 0
}
fallback={<span class="text-muted">—</span>}
>
{(factor) => <>{factor().toFixed(1)}×</>}
{row.datastore?.deduplicationFactor?.toFixed(1)}×
</Show>
</TableCell>
</Show>
@@ -0,0 +1,147 @@
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: () => <div>Details</div>,
}));
afterEach(cleanup);
const datastore = (patch: Record<string, unknown> = {}): 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',
displayName: '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(() => (
<ProxmoxBackupServersTable servers={[server(datastore(patch))]} layoutWidth={() => 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(() => (
<ProxmoxBackupServersTable
servers={[server(datastore(), 'offline')]}
layoutWidth={() => 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(() => (
<ProxmoxBackupServersTable servers={[server(datastore({ used }))]} layoutWidth={() => 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(() => (
<ProxmoxBackupServersTable servers={[server()]} layoutWidth={() => 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(() => (
<ProxmoxBackupServersTable servers={servers()} layoutWidth={() => 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();
},
);
});
@@ -100,7 +100,9 @@ describe('ProxmoxBackupServersTable details', () => {
const [servers, setServers] = createSignal(makeServers());
const { container } = render(() => <ProxmoxBackupServersTable servers={servers()} />);
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']);
@@ -30,6 +30,25 @@ const makePbsResource = (overrides: Partial<Resource> = {}): 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()]);
@@ -214,6 +214,55 @@ 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(() => (
<ProxmoxBackupsTable
emptyIcon={<span />}
workloads={[workloadResource]}
servers={servers()}
/>
));
// The router mounts its page asynchronously. Wait for the capacity notice
// before binding page-level queries to the server table.
await screen.findByText('Unavailable');
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,
@@ -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',
@@ -107,7 +107,9 @@ const BACKUP_SERVER_WEIGHTS: Record<
ProxmoxBackupsTableLayoutMode,
Partial<Record<BackupServerColumnId, number>>
> = {
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 },
+1
View File
@@ -466,6 +466,7 @@ export interface ResourcePBSDatastore {
available?: number;
usagePercent?: number;
status?: string;
error?: string;
deduplicationFactor?: number;
}