mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
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:
@@ -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>
|
||||
|
||||
+147
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
+3
-1
@@ -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,
|
||||
|
||||
+3
-2
@@ -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 },
|
||||
|
||||
@@ -466,6 +466,7 @@ export interface ResourcePBSDatastore {
|
||||
available?: number;
|
||||
usagePercent?: number;
|
||||
status?: string;
|
||||
error?: string;
|
||||
deduplicationFactor?: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user