Integrate reviewed backup response validation and composed disk-label proof

Preserve exact Web candidate 0f480d23af and its own-parent browser receipts; retained source and browser evidence covers the final reader and deferred-read label content.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-03 23:59:47 +01:00
37 changed files with 2019 additions and 94 deletions
@@ -88,6 +88,17 @@ API context; late responses cannot repopulate old evidence. Disposal aborts
reads and removes subscriptions. This uses the existing query lifecycle, without
new inventory polling or a remount cache.
Successful HTTP responses still need a readable backup envelope, collections
and consumed row fields. Invalid JSON, missing data and malformed rows produce
a source-local unavailable state with fixed format guidance, never raw response
content, a fabricated empty inventory or a page-level rendering failure that
hides another source. No invalid rows are silently dropped, no completion flags
are coerced, and only a validated response clears the warning. Explicitly nil
PVE collections remain compatible with the existing Go encoding.
`proxmoxBackupInventory.test.ts`, mounted backup-table controls and
`browser-tests/backup-inventory-format.cjs` verify these boundaries and isolated
keyboard/touch recovery, not native backup or released availability.
Verification: mounted `ProxmoxBackupsTable.test.tsx` covers source pending,
partial/both-source failure, isolated retry, fulfilled empty counts, 401/403,
organisation replacement, late response and disposal. Production component,
@@ -1607,6 +1607,13 @@ service-history reads plus denial/recovery without fabricated samples.
failing job's last good sync falls behind by its failure count, and a
long-running demo never reports healthy jobs as overdue. A mock job never
targets its own source node, and an estate without a second node has none.
The mock estate carries shared storage in the shape the poller produces,
not the per-node shape the PVE API returns: a shared PBS storage is one
cluster-scoped row listing its reporting nodes (issue 1049), never one row
per node with a different usage figure on each.
Demo Docker container names are unique per host, as Docker itself enforces:
a container profile reused for a second round on one host takes a numbered
suffix instead of producing two rows with one name.
Mock metrics history must also stay bounded independently of estate size:
eager multi-day PVE guest history is limited to a deterministic sample spread
across the estate, while every omitted guest continues to receive the same
@@ -402,6 +402,12 @@ every tick.
Mock replication timing is advanced in that same full-rotation refresh, and
only a job whose schedule phase has passed is rewritten, so keeping demo
replication on schedule adds no per-tick change for jobs that did not run.
Shared storage in the mock estate stays one row per cluster per store, so the
Storage surface grows with the number of storages rather than with the node
count times the number of shared storages.
Demo Docker container names stay unique per host (numbered suffixes on reused
profiles), so image "used by" lists and container rows never collapse two
distinct containers into what reads as a duplicated row.
The browser applies resource deltas to its connection-scoped raw baseline, but
canonicalizes and reconciles only changed resources plus the host-merge groups
@@ -2563,6 +2569,13 @@ resize-observer plus tooltip lifecycle live in
`frontend-modern/src/components/Workloads/useStackedDiskBarState.ts`.
Future disk-bar runtime changes must extend through those owners instead of
reintroducing mixed resize state and presentation branching into the shell.
The multi-disk `vertical-bars` mode (Proxmox nodes, Machines, and Docker
hosts with more than one agent-reported disk) draws one equal-weight micro-bar
per disk and labels the cell with the fullest disk's usage percentage, derived
in the model from the existing max-disk summary, so a multi-disk host answers
"how close is this host to running out of space?" at a glance in Bars mode
instead of only on hover or in Trends mode. The label names no disk in the
row; the mount and the per-disk breakdown stay in the title and tooltip.
The dashboard stacked memory bar now follows that same pattern: the shell
stays in `frontend-modern/src/components/Workloads/StackedMemoryBar.tsx`,
while memory-capacity math, balloon/swap tooltip derivation, anomaly label
@@ -2589,6 +2602,22 @@ resize-observer lifecycle lives in
`frontend-modern/src/components/Workloads/useMetricBarState.ts`. Future
metric-bar runtime changes must extend through those owners instead of
reintroducing mixed resize state and label-fit logic into the shell.
Label fit in the metric, stacked memory, and stacked disk bar models goes
through `estimateTextWidth` in `frontend-modern/src/utils/format.ts`, which
sums per-glyph advances rather than multiplying a character count. The
advances come from one hidden probe of about 200 nodes carrying the label's
type classes, read once per page load and again only if the device pixel
ratio changes; that is the estimator's only layout read. It is never per bar,
the bars still take their width from the resize observer alone, and each
estimate after the probe is arithmetic over the label's characters. Each model
adds the padding its own label sits in, the stacked bars count the anomaly
marker that shares the label's line, and inline disk slots are sized after the
gap between them. Measured in Chrome on the 429-bar Proxmox overview on
2026-10-03: 1.7 ms for the probe and about 110 ns per estimate. Where layout
is unavailable the estimator uses the advances measured for the macOS system
font. `formatExtra.test.ts` verifies the single probe read and the fallback;
`useMetricBarState.test.tsx` and `useStackedMemoryBarState.test.tsx` verify
that the long label appears only at an observed width that fits it.
The dashboard enhanced CPU bar now follows that same pattern: the shell stays
in `frontend-modern/src/components/Workloads/EnhancedCPUBar.tsx`, while usage
formatting, anomaly presentation, tooltip load-average formatting, and
@@ -41,6 +41,17 @@ disposal. `browser-tests/backup-inventory.cjs` uses production components,
API adapters, router and CSS in Chromium/WebKit with synthetic HTTP responses;
these controls are not native backup, permission, thaw or release acceptance.
An HTTP success alone does not establish readable recovery evidence. The client
validates the data envelope, source collections and consumed row fields before
publishing that inventory. Malformed or undecodable responses invalidate that
source atomically, not a silently truncated list or confirmed empty inventory.
PVE's explicitly nil Go collections remain valid empty observations; absent
fields are not nil collections. Optional unknown facts and additional fields
retain their existing meaning, and completion flags are never coerced.
`proxmoxBackupInventory.test.ts`, the mounted backup-table tests and
`browser-tests/backup-inventory-format.cjs` cover this response boundary, source
isolation and recovery without extending native protection or thaw claims.
### Backup identity, phone storage and History consent composition
The Proxmox Coverage last-backup pointer selects completed PBS snapshots or PVE
@@ -2750,6 +2761,14 @@ capability over storage or recovery data.
discriminator` and `falls back to the topology discriminator when no
vdev layout is reported` in
`frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts`.
When neither is reported, a Proxmox storage plugin type resolves to the
name the Proxmox UI gives it (`lvmthin` is LVM-Thin, `nfs` is NFS,
`cifs` is SMB/CIFS, `iscsi` is iSCSI, `zfs`, `btrfs`, `glusterfs`,
`esxi` likewise); title-casing the raw plugin name is not a label.
A storage the poller merged across the cluster carries the synthetic
`cluster` node (the mock fixture's `shared`), and its host label reads
"Shared" with the reporting node count when the storage lists its
nodes, never the raw marker.
27. A rejected legacy RBAC import must not destroy the store. The import is
transactional and leaves the legacy files in place, so a failure leaves the
@@ -35,6 +35,13 @@ count lifecycle; `browser-tests/backup-inventory.cjs` verifies production rows
and source notices on desktop/phone. This changes no resource identity, native
provider assessment or permission policy.
The backup inventory count also remains unavailable when an HTTP200 response
fails the client backup-envelope/row validation; transport success is not an
observed zero. A malformed source cannot remove independently readable canonical
server health or the other source's artifacts. Fixed diagnostics expose no
response body. The inventory parser, mounted table and
`browser-tests/backup-inventory-format.cjs` exercise that combined boundary.
### Guest disk freshness presentation
Existing workload disk evidence retains its source reason through the row,
@@ -910,6 +917,19 @@ last success and last schedule) are relative-age values under that rule:
`formatPlatformTableDateTimeValue` time as hover title, never as the raw RFC
3339 string the agent reports, and the Detail column carries enough weight
for those labelled ages to fit whole on a full-width desktop table.
The controller row expansion carries those timestamps in full: the
always-visible summary tables of the resource drawer render a Kubernetes
workload-controller section (`buildKubernetesControllerSection`, in the same
slot as the Docker container section) with the Job start and completion time,
the CronJob schedule and last run and last success time, each as the absolute
`formatPlatformTableDateTimeValue` time followed by its age in parentheses and
allowed to wrap in a phone-width detail cell, plus the target,
count, service, and scope fields the narrower layouts drop, so a phone or a
half-width pane reaches them in one tap rather than through a collapsed
platform-details disclosure. The `KubernetesControllersTable` phone projection
keeps controller, kind, ready, and issues and demotes Target with
`platform-table-phone-hidden`, so kind labels such as `DaemonSet` and
`StatefulSet` fit whole instead of clipping in a 15 percent track.
Duration and interval cells keep the same split: unified-resource or
source-specific consumers own which elapsed duration, human fallback, or poll
interval field is meaningful, while dense platform table rendering must use
@@ -2143,6 +2163,14 @@ application resource-provider or WebSocket lifecycle.
populates only the canonical field on the REST contract, so without
landing on `v2.uptime` ESXi hosts and VMware-backed VMs lose uptime on
the unified-resources side even though the API payload carries it.
The same mapping must carry every facet the frontend reads at the top
level of `Resource`: the provider facets (`pmg` included) and the
per-source `sourceStatus`, `actionReadiness`, and `technology` fields the
API returns. The REST and websocket transports race on a fresh route
load, and a facet present only under `platformData` on the REST row left
the Mail Gateway table rendering every PMG column as a dash, and Docker
lifecycle refusals and storage freshness reading undefined, whenever the
REST snapshot landed last.
That same shared cache boundary must normalize route/query type filters
through the canonical frontend-to-`ResourceType` resolver before slicing
the snapshot, so compatibility values such as `disk` / `physical_disk`
@@ -2666,6 +2694,11 @@ facet. Its value is owned by the backup recovery model, not by canonical
resource identity: PBS instance and datastore form the repository scope, and
the same value must survive the date/coverage route handoff so both views
apply one repository selection without inventing a top-level recovery route.
A PBS datastore resource takes its status from the storage vocabulary that PVE
storage already uses (`available`/`unavailable` alongside online, degraded, and
offline), so a healthy datastore is online and one PBS reports an error for is
offline; the generic string mapper that left every datastore unknown is not a
valid datastore status source.
Unified-resource drawers and Kubernetes drill-down controls may own resource
timeline filter semantics, namespace choices, and destination routes, but their
native select chrome must compose the frontend-primitives-owned `FormSelect`.
@@ -5521,6 +5554,16 @@ read-only status and governed UpdateButton content. The boundary must preserve
full action names, review semantics and native agent/container identity; it must
not hide update labels to satisfy width constraints. DockerNativeTables.test.ts
checks that the governed update action remains inside that boundary.
A phone row (under 440px) keeps container, state, CPU, memory and the update
control and defers the restart count to the row expansion, so the state word
and the Update control fit whole instead of clipping to "runni…" beside an
unreadable Restarts header. At compact width (a 1280-1536px laptop window) the
Host, Engine and Updates columns take enough share that "docker 26.1.3", a
host name and the Update control render whole; the percentage-only CPU and
memory bars yield the difference. Swarm services show the rollout state as
words ("Rollback started") with the raw token, message and completion time in
the title, and Swarm tasks show Started as an age with the RFC3339 stamp in
the title, matching every other platform table's time cells.
ResourceDetailDrawer headings wrap long display names rather than ellipsising
them. Overview rows may still truncate identity; keyboard expansion exposes the
@@ -0,0 +1,278 @@
// Production backup readers/table/router/CSS, synthetic HTTP only.
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 phase = 'final';
const root = '/workspace/frontend-modern';
const output = `/workspace/tmp/backup-format-${phase}`;
const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const emptyPVE = { data: { backupTasks: [], storageBackups: [], guestSnapshots: [] } };
const emptyPBS = { data: { backups: [] } };
const archive = {
id: 'archive-112',
instance: 'pve-a',
node: 'pve-a',
storage: 'local',
type: 'ct',
vmid: 112,
time: '2026-10-03T01:00:00Z',
format: 'zst',
volid: 'local:backup/vzdump-lxc-112.tar.zst',
};
const snapshot = {
id: 'pbs-main/main/ct/112/2026-10-03T02:00:00Z',
instance: 'pbs-main',
datastore: 'main',
backupType: 'ct',
vmid: '112',
backupTime: '2026-10-03T02:00:00Z',
files: ['index.json.blob'],
};
const pvePayload = { data: { ...emptyPVE.data, storageBackups: [archive] } };
const pbsPayload = { data: { backups: [snapshot] } };
(async () => {
fs.mkdirSync(output, { recursive: true });
process.chdir(root);
const playwrightVersion = require('playwright/package.json').version;
assert.equal(
playwrightVersion,
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'),
server: { host: '127.0.0.1', port: 5298, strictPort: true },
});
const observations = [];
let browser;
try {
await server.listen();
for (const [name, engine, phone] of [
['chromium-desktop', chromium, false],
['webkit-phone', webkit, true],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const viewport = phone ? { width: 390, height: 844 } : { width: 1365, height: 900 };
const page = await browser.newPage({
viewport,
isMobile: phone,
hasTouch: phone,
locale: 'en-GB',
timezoneId: 'UTC',
});
page.setDefaultTimeout(20000);
await page.clock.setFixedTime(new Date('2026-10-03T12:00:00Z'));
const errors = [],
requests = [],
offOrigin = [],
screenshots = [],
checks = [];
page.on('pageerror', (error) => errors.push(error.message));
const sources = { pve: emptyPVE, pbs: {} };
let retryGate;
await page.route('**/*', async (route) => {
const request = route.request();
const url = new URL(request.url());
if (url.origin !== 'http://127.0.0.1:5298') {
offOrigin.push(url.origin);
return route.abort();
}
if (!url.pathname.startsWith('/api/')) return route.continue();
requests.push({ path: url.pathname, method: request.method() });
const key =
url.pathname === '/api/backups/pbs'
? 'pbs'
: url.pathname === '/api/backups/pve'
? 'pve'
: null;
const payload = key ? sources[key] : { data: [], policy: {}, meta: {} };
if (key === 'pbs' && retryGate) await retryGate;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(payload),
});
});
const rows = () => page.locator('[data-proxmox-backup-row="recoverable"]');
const notice = (source) =>
page.getByRole('status').filter({
hasText: `${source} backup inventory is unavailable`,
});
const load = async () => {
await page.goto('http://127.0.0.1:5298/browser-tests/backup-inventory.html?view=date', {
waitUntil: 'domcontentloaded',
});
if (phone) await page.evaluate(() => document.documentElement.classList.add('dark'));
};
const capture = async (suffix) => {
await page.evaluate(() =>
Promise.all(
document
.getAnimations()
.filter((animation) => animation.effect?.getTiming().iterations !== Infinity)
.map((animation) => animation.finished.catch(() => undefined)),
),
);
const file = path.join(output, `${name}-${suffix}.png`);
await page.screenshot({ path: file, fullPage: true });
screenshots.push({ path: path.relative('/workspace', file), sha256: sha256(file) });
};
await load();
if (phase === 'baseline') {
await page.getByText('No backups yet', { exact: true }).waitFor();
assert.equal(await notice('PBS').count(), 0);
checks.push('HTTP200 missing PBS data is incorrectly shown as No backups yet');
} else {
await notice('PBS').waitFor();
await page.getByText('Backup inventory is incomplete', { exact: true }).waitFor();
assert.equal(await page.getByText('No backups yet', { exact: true }).count(), 0);
await page.getByText(/The response format is invalid/).waitFor();
if (!phone) await page.locator('[title="PBS backup inventory is unavailable"]').waitFor();
checks.push(
'HTTP200 missing data remains incomplete with a fixed format explanation, not empty/zero',
);
}
await capture('missing-data');
sources.pve = pvePayload;
sources.pbs = { data: { backups: {} }, message: 'SYNTHETIC_PRIVATE_BODY_DO_NOT_DISPLAY' };
await load();
if (phase === 'baseline') {
await page.getByRole('alert').filter({ hasText: 'Backup view failed' }).waitFor();
assert.equal(await rows().count(), 0);
checks.push(
'HTTP200 non-array PBS inventory removes independently readable PVE evidence at ErrorBoundary',
);
} else {
await notice('PBS').waitFor();
await rows().first().waitFor();
assert.equal(await rows().count(), 1);
assert.match(await rows().first().innerText(), /PVE file/);
assert.equal(await page.getByRole('alert').count(), 0);
assert.equal(await page.getByText('SYNTHETIC_PRIVATE_BODY_DO_NOT_DISPLAY').count(), 0);
checks.push(
'Malformed PBS is source-local; readable PVE remains and raw response content is not displayed',
);
}
await capture('pbs-invalid');
sources.pbs = pbsPayload;
sources.pve = { data: { ...emptyPVE.data, backupTasks: {} } };
await load();
if (phase === 'baseline') {
await page.getByRole('alert').filter({ hasText: 'Backup view failed' }).waitFor();
assert.equal(await rows().count(), 0);
checks.push(
'HTTP200 non-array PVE tasks removes independently readable PBS evidence at ErrorBoundary',
);
} else {
await notice('PVE').waitFor();
await rows().first().waitFor();
assert.equal(await rows().count(), 1);
await rows().first().getByText('PBS', { exact: true }).waitFor();
await rows().first().getByText('Snapshot', { exact: true }).waitFor();
assert.equal(await page.getByRole('alert').count(), 0);
checks.push('Malformed PVE is source-local and independent PBS evidence remains usable');
}
await capture('pve-invalid');
if (phase === 'final') {
sources.pve = pvePayload;
sources.pbs = { data: { backups: [{ ...snapshot, inProgress: 'true' }] } };
await load();
await notice('PBS').waitFor();
await rows().first().waitFor();
assert.equal(
await rows().count(),
1,
'a mistyped completion flag must not become recovery evidence',
);
const before = requests.filter((r) => r.path === '/api/backups/pve').length;
sources.pbs = pbsPayload;
let release;
retryGate = new Promise((resolve) => (release = resolve));
const retry = page.getByRole('button', { name: 'Retry PBS inventory', exact: true });
if (phone) await retry.tap();
else {
await retry.focus();
await page.keyboard.press('Enter');
}
assert.equal(await retry.isDisabled(), true);
await notice('PBS').waitFor();
assert.equal(await rows().count(), 1);
release();
await page.waitForFunction(
() => document.querySelectorAll('[data-proxmox-backup-row="recoverable"]').length === 2,
);
retryGate = null;
assert.equal(await notice('PBS').count(), 0);
assert.equal(requests.filter((r) => r.path === '/api/backups/pve').length, before);
await capture('recovered');
checks.push(
'Mistyped incomplete flag is rejected; warning persists through keyboard/touch isolated retry until valid recovery',
);
sources.pve = { data: { backupTasks: null, storageBackups: null, guestSnapshots: null } };
sources.pbs = emptyPBS;
await load();
await page.getByText('No backups yet', { exact: true }).waitFor();
assert.equal(await page.getByRole('status').filter({ hasText: 'inventory' }).count(), 0);
checks.push('Explicit Go nil PVE collections remain a valid empty observation');
}
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth > window.innerWidth + 1,
);
assert.equal(overflow, false);
assert.deepEqual(errors, []);
assert.deepEqual(offOrigin, []);
assert(requests.every((r) => r.method === 'GET'));
observations.push({
name,
browserVersion: browser.version(),
viewport,
checks,
requests,
errors,
offOrigin,
overflow,
screenshots,
});
await browser.close();
browser = null;
}
const runtime = [
'src/features/proxmox/ProxmoxBackupsTable.tsx',
'src/features/proxmox/proxmoxBackupInventory.ts',
].filter((p) => fs.existsSync(path.join(root, p)));
fs.writeFileSync(
path.join(output, 'result.json'),
JSON.stringify(
{
phase,
playwrightVersion,
runtimeSha256: Object.fromEntries(runtime.map((p) => [p, sha256(path.join(root, p))])),
observations,
},
null,
2,
) + '\n',
);
console.log(`${phase}: ${observations.length} browser contexts passed; receipts at ${output}`);
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,313 @@
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/composed-disk-labels-settled';
const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
const runtime = [
'frontend-modern/src/components/Workloads/stackedDiskBarModel.ts',
'frontend-modern/src/components/Workloads/StackedDiskBar.tsx',
'frontend-modern/src/components/Workloads/useStackedDiskBarState.ts',
'frontend-modern/src/components/Workloads/GuestRow.tsx',
'frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx',
'frontend-modern/src/components/Workloads/DiskList.tsx',
'frontend-modern/src/utils/workloadGuestPresentation.ts',
'frontend-modern/src/utils/format.ts',
'frontend-modern/src/index.css',
];
(async () => {
fs.mkdirSync(output, { recursive: true });
const playwrightVersion = require('playwright/package.json').version;
assert.equal(
playwrightVersion,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
const hashes = Object.fromEntries(
runtime.map((file) => [file, sha256(fs.readFileSync(path.join('/workspace', file)))]),
);
assert.equal(
hashes[runtime[0]],
'9f50f630df8379673032052970e7cc6d287c02ecd212b3df5d16f1032be42413',
);
process.chdir(root);
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
cacheDir: path.join(output, 'vite-cache'),
server: { host: '127.0.0.1', port: 5301, strictPort: true },
});
const results = [],
screenshots = [];
let browser;
try {
await server.listen();
for (const [name, engine, width, dark] of [
['chromium-desktop', chromium, 1365, false],
['webkit-phone', webkit, 390, true],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const context = await browser.newContext({
viewport: { width, height: width === 390 ? 844 : 900 },
isMobile: width === 390,
hasTouch: width === 390,
locale: 'en-GB',
timezoneId: 'UTC',
});
const page = await context.newPage();
page.setDefaultTimeout(15000);
page.setDefaultNavigationTimeout(60000);
const errors = [],
requests = [],
checks = [],
fits = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('request', (request) =>
requests.push({
method: request.method(),
pathname: new URL(request.url()).pathname,
origin: new URL(request.url()).origin,
}),
);
await page.route('http://127.0.0.1:5301/api/**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: [],
alerts: [],
anomalies: [],
config: null,
}),
}),
);
await page.addInitScript((value) => {
document.addEventListener('DOMContentLoaded', () => {
if (value) document.documentElement.classList.add('dark');
});
}, dark);
const capture = async (state) => {
const file = path.join(output, `${name}-${state}.png`);
await page.screenshot({ path: file, fullPage: true });
screenshots.push({
path: path.relative('/workspace', file),
sha256: sha256(fs.readFileSync(file)),
});
};
const settle = () =>
page.evaluate(
() =>
new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
);
await page.goto('http://127.0.0.1:5301/browser-tests/composed-disk-labels.html');
await page.waitForFunction(() => window.__composedDiskLabels);
await settle();
const checkFit = async (expectedWidth, roomy) => {
await page.waitForFunction(
(value) =>
Math.abs(
document.querySelector('[data-fit="single"]').getBoundingClientRect().width - value,
) < 0.5,
expectedWidth,
);
await settle();
const state = await page.evaluate(() => {
const boxes = [...document.querySelectorAll('[data-fit]')].map((box) => {
const id = box.getAttribute('data-fit');
const labels = [...box.querySelectorAll('span.whitespace-nowrap')];
return {
id,
width: box.getBoundingClientRect().width,
text: box.innerText,
labels: labels.map((label) => ({
text: label.textContent,
scroll: label.scrollWidth,
client: label.clientWidth,
})),
sublabels: box.querySelectorAll('.metric-sublabel').length,
};
});
return { boxes, presentation: window.__composedDiskLabels.presentation() };
});
for (const box of state.boxes) {
for (const label of box.labels)
assert(
label.scroll <= label.client + 1,
`${name} ${box.id} ${expectedWidth}: rendered label must fit, not be ellipsised`,
);
}
for (const id of ['single', 'anomaly', 'aggregate']) {
const box = state.boxes.find((item) => item.id === id);
assert.equal(box.sublabels, roomy ? 1 : 0, `${id}: optional detail matches actual width`);
}
assert.equal(state.presentation.single.displayLabel, '60%');
assert.equal(state.presentation.anomaly.anomalyRatio, '2.5x');
assert.equal(state.presentation.aggregate.showDiskCount, true);
assert.equal(
await page.locator('[data-fit="vertical"] [data-stacked-disk-max-label]').innerText(),
'67%',
);
assert.equal(
await page.locator('[data-fit="vertical"] [data-stacked-disk-fill="vertical"]').count(),
2,
);
fits.push({ width: expectedWidth, boxes: state.boxes });
};
await checkFit(100, false);
const compactInline = await page.locator('[data-fit="inline"]').innerText();
assert(!compactInline.includes('MMMM'));
assert(compactInline.includes('iiii 67%'));
await capture('compact');
const roomyButton = page.getByRole('button', { name: 'Use roomy bars', exact: true });
if (engine === chromium) {
await roomyButton.focus();
await page.keyboard.press('Enter');
} else await roomyButton.tap();
await checkFit(320, true);
assert((await page.locator('[data-fit="inline"]').innerText()).includes('MMMM 50%'));
assert((await page.locator('[data-fit="inline"]').innerText()).includes('iiii 67%'));
await capture('roomy');
checks.push(
'ResizeObserver drops optional detail at 100px and shows complete un-clipped single/anomaly/max/count and inline labels at 320px; vertical max remains truthful',
);
if (engine === chromium) {
await page.locator('[data-fit="single"] [data-stacked-disk-trigger]').hover();
const tip = page.locator('[data-tooltip-portal="true"]');
await tip.waitFor({ state: 'visible' });
assert((await tip.innerText()).includes('Guest reads paused while a backup is running.'));
assert((await tip.innerText()).includes('/data'));
await page.locator('h1').hover();
await page.getByRole('button', { name: 'Mark observation fresh' }).click();
await page.locator('[data-fit="single"] [data-stacked-disk-trigger]').hover();
await tip.waitFor({ state: 'visible' });
assert(!(await tip.innerText()).includes('Guest reads paused'));
assert((await tip.innerText()).includes('/data'));
await page.locator('h1').hover();
checks.push(
'Caller-owned paused-read tooltip survives measured model; fresh same-disk update removes only status',
);
}
// Exercise the existing production guest callers, but only the combined
// stale-to-fresh boundary, not an unchanged eight-reason acceptance run.
await page.goto('http://127.0.0.1:5301/browser-tests/guest-disk-deferral.html');
await page.waitForFunction(() => window.__guestDiskEvidence);
const row = page.locator('[data-guest-id="fixture-pve:pve-a:101"]');
const cell = row.locator('[data-workload-col="disk"]');
if (engine === chromium) await row.click();
else
// WebKit's trusted tap emitted no click on this production row in the
// separately retained input diagnostic. Check rendering via a mouse
// click, without asserting touch acceptance or masking that failure.
await row
.locator('[data-workload-col="name"]')
.getByText('backup-guest', { exact: true })
.click();
const drawer = page.getByRole('region', { name: 'Guest Overview' });
await drawer.waitFor({ state: 'visible' });
const paused = await cell.getAttribute('title');
assert(paused.startsWith('Using last known disk stats. '));
assert(paused.includes('VM operation lock, such as a backup.'));
assert((await drawer.innerText()).includes(paused));
assert(
(await page.getByRole('region', { name: 'Guest disk list' }).innerText()).includes(paused),
);
assert((await drawer.innerText()).includes('/data'));
if (engine === chromium) {
await page.locator('h1').hover();
await cell.locator('[data-stacked-disk-trigger]').hover();
await page.locator('[data-tooltip-portal="true"]').waitFor({ state: 'visible' });
assert((await page.locator('[data-tooltip-portal="true"]').innerText()).includes(paused));
await page.locator('h1').hover();
}
await capture('guest-paused');
await page.evaluate(() => {
window.__composedOriginalRow = document.querySelector(
'[data-guest-id="fixture-pve:pve-a:101"]',
);
window.__guestDiskEvidence.update({ reason: '', data: true, usage: 75 });
});
await page.waitForFunction(
() => !document.querySelector('[data-workload-col="disk"]').hasAttribute('title'),
);
assert(!(await drawer.innerText()).includes('Using last known disk stats.'));
assert.equal(await drawer.getByRole('progressbar').getAttribute('aria-valuenow'), '75');
assert.equal(
await page.evaluate(
() =>
window.__composedOriginalRow ===
document.querySelector('[data-guest-id="fixture-pve:pve-a:101"]'),
),
true,
);
// AnimatedNumber must finish; do not capture an intermediate old percent
// beside the new capacity and claim that it is a settled observation.
await page.waitForFunction(() =>
document.querySelector('[data-workload-col="disk"]').textContent.includes('75%'),
);
await capture('guest-fresh');
checks.push(
'Production GuestRow, Overview and disk list retain paused provenance; same-VM resumption clears notice and updates to 75% without remount',
);
assert.equal(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
true,
);
assert.deepEqual(errors, []);
assert(
requests.every(
(request) => request.origin === 'http://127.0.0.1:5301' && request.method === 'GET',
),
);
results.push({
name,
browserVersion: browser.version(),
viewport: { width, height: width === 390 ? 844 : 900 },
dark,
checks,
fits,
errors,
requestCount: requests.length,
});
await context.close();
await browser.close();
browser = null;
}
fs.writeFileSync(
path.join(output, 'result.json'),
JSON.stringify(
{
result: 'passed',
verifiedAt: new Date().toISOString(),
playwrightVersion,
runtimeHashes: hashes,
results,
screenshots,
limitations:
'Synthetic component/CSS/browser composition only; not native QGA, thaw, appliance acceptance, installation, release qualification or availability.',
},
null,
2,
),
);
process.stdout.write(
`COMPOSED_DISK_LABELS_PASSED ${results.length} browsers / ${screenshots.length} screenshots\n`,
);
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
process.stderr.write(`${error.stack}\n`);
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>Composed disk label verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./composed-disk-labels.tsx"></script>
</body>
</html>
@@ -0,0 +1,93 @@
// Production disk model, shell, ResizeObserver and CSS; synthetic observations.
// This fixture verifies composition, not collection, thaw or native recovery.
import { createSignal, For } from 'solid-js';
import { render } from 'solid-js/web';
import { StackedDiskBar } from '../src/components/Workloads/StackedDiskBar';
import {
buildStackedDiskBarPresentation,
type StackedDiskBarProps,
} from '../src/components/Workloads/stackedDiskBarModel';
import type { Disk } from '../src/types/api';
import type { AnomalyReport } from '../src/types/aiIntelligence';
import '../src/index.css';
const GiB = 1024 ** 3;
const disk = (mountpoint: string, total: number, used: number): Disk => ({
mountpoint,
total: total * GiB,
used: used * GiB,
free: (total - used) * GiB,
usage: (used / total) * 100,
type: 'ext4',
});
const single = [disk('/data', 440, 265)];
const multiple = [disk('/MMMM', 180, 90), disk('/iiii', 260, 175)];
const anomaly: AnomalyReport = {
resource_id: 'fixture-pve:pve-a:101',
resource_name: 'backup-guest',
resource_type: 'vm',
metric: 'disk',
current_value: 60,
baseline_mean: 24,
baseline_std_dev: 4,
z_score: 9,
severity: 'high',
description: 'Synthetic disk anomaly',
};
const cases: { id: string; label: string; props: StackedDiskBarProps }[] = [
{ id: 'single', label: 'Single filesystem', props: { disks: single } },
{ id: 'anomaly', label: 'Filesystem with anomaly', props: { disks: single, anomaly } },
{
id: 'aggregate',
label: 'Aggregate with fullest disk and count',
props: { disks: multiple, mode: 'aggregate', showDiskCount: true },
},
{ id: 'inline', label: 'Equal slots, different glyph widths', props: { disks: multiple } },
{
id: 'vertical',
label: 'Host micro-bars and fullest disk',
props: { disks: multiple, mode: 'vertical-bars' },
},
];
function Fixture() {
const [width, setWidth] = createSignal(100);
const [status, setStatus] = createSignal(
'Using last known disk stats. Guest reads paused while a backup is running.',
);
(window as any).__composedDiskLabels = {
width: setWidth,
presentation: () =>
Object.fromEntries(
cases.map((item) => [item.id, buildStackedDiskBarPresentation(item.props, width())]),
),
};
return (
<main class="min-h-screen space-y-5 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">Composed disk evidence</h1>
<div class="flex flex-wrap gap-2">
<button class="rounded-sm border border-border p-2" onClick={() => setWidth(100)}>
Use compact bars
</button>
<button class="rounded-sm border border-border p-2" onClick={() => setWidth(320)}>
Use roomy bars
</button>
<button class="rounded-sm border border-border p-2" onClick={() => setStatus('')}>
Mark observation fresh
</button>
</div>
<For each={cases}>
{(item) => (
<section aria-label={item.label} class="space-y-2">
<h2 class="text-sm font-medium">{item.label}</h2>
<div data-fit={item.id} style={{ width: `${width()}px` }}>
<StackedDiskBar {...item.props} statusMessage={status()} />
</div>
</section>
)}
</For>
</main>
);
}
render(() => <Fixture />, document.getElementById('root')!);
@@ -0,0 +1,53 @@
{
"version": 1,
"base_sha": "f5925942f49b088689695678b98e703af9ead9ef",
"verified_at": "2026-10-03T21:03:10.375831Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx",
"frontend-modern/src/features/proxmox/proxmoxBackupInventory.ts"
],
"content_sha256": {
"frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "8e1113c7c9e263d284c86e4c3373381b3d71f6a0161897d9dcc782ce225478d5",
"frontend-modern/src/features/proxmox/proxmoxBackupInventory.ts": "deccbf62dcdbafd447cca0f33b516bf98fddb8c726515a30b85b614d26a37095"
},
"routes": [
"/browser-tests/backup-inventory.html?view=date"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"HTTP200 missing PBS envelope stays incomplete, never no-backups/zero",
"Malformed PBS/PVE collections preserve independent source and server health without a rendering failure",
"Mistyped incomplete flags are unavailable, not completed restore points",
"Explicit Go nil PVE collections remain valid empty observations"
],
"interactions": [
"Desktop Enter and phone touch isolated retry keep warnings and independent rows while pending, clear only after validated success",
"Eight settled final screenshots and two baseline screenshots opened and inspected; light desktop/dark phone warnings and rows readable, no page overflow"
],
"playwright_version": "1.56.1",
"browser_versions": [
{
"engine": "chromium-desktop",
"version": "141.0.7390.37"
},
{
"engine": "webkit-phone",
"version": "26.0"
}
],
"dependency_snapshot": "8aaf02a3db8e1c245174d04d132398d1716a69525064975470ff738d168abb82",
"limitations": [
"Synthetic source responses through production table/router/API/CSS, not native provider collection, permission, restore, QGA/thaw or installed/released acceptance.",
"Production/embed build is separately source-proved; no new route, polling, source cache or authority."
]
}
@@ -0,0 +1,67 @@
{
"version": 1,
"base_sha": "bb4a1372583e28204f9ac825d54855e5e00557ca",
"verified_at": "2026-10-03T20:35:27Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/StackedDiskBar.tsx",
"frontend-modern/src/components/Workloads/stackedDiskBarModel.ts",
"frontend-modern/src/features/docker/DockerImagesTable.tsx",
"frontend-modern/src/features/docker/DockerServicesTable.tsx",
"frontend-modern/src/features/docker/DockerTasksTable.tsx",
"frontend-modern/src/features/docker/dockerContainerTableModel.ts",
"frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts",
"frontend-modern/src/features/storageBackups/storageAdapters.ts",
"frontend-modern/src/hooks/useUnifiedResources.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/StackedDiskBar.tsx": "100cc005912a2bef11c55fa8b6961d473d245e8a3fb71ab8a640186c294b26eb",
"frontend-modern/src/components/Workloads/stackedDiskBarModel.ts": "9fde1740e300724ef9cab4a3b036c942bdab6e5d2060f262606b27f4b1a40ea6",
"frontend-modern/src/features/docker/DockerImagesTable.tsx": "29284b34d9631f118a449da1c274b1028e3cea57aed37a3ac056e48e05b2b613",
"frontend-modern/src/features/docker/DockerServicesTable.tsx": "0a84ec6834e2aa8f6603c57a3bad168911b9f06bef80c9c7ccbad07d1f04075c",
"frontend-modern/src/features/docker/DockerTasksTable.tsx": "6cc99405c7bdb2b2edfd992f6b17b2636bb9285a53e949e41609dfa8b339cacf",
"frontend-modern/src/features/docker/dockerContainerTableModel.ts": "b821b9caf65d5c247e543a6d6dbf0925f735b617c6393066d0f8c15a10e05e72",
"frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts": "933723fb9afeeb53afa46946a346daca19d50c0d61a81fc0730498ebd8c09bb2",
"frontend-modern/src/features/storageBackups/storageAdapters.ts": "daa85c5d99903d5a5fb10070837106b91f06ad130cae320472b2d980b979fb48",
"frontend-modern/src/hooks/useUnifiedResources.ts": "166e45835c56d00f1a3ddbc37f7127348a0e36dc7c42c921f972db7750c603c6"
},
"routes": [
"/proxmox/overview",
"/proxmox/storage",
"/proxmox/mail",
"/docker/overview",
"/docker/images",
"/docker/storage",
"/docker/networks",
"/docker/swarm",
"/machines"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 1280,
"height": 900
},
{
"width": 390,
"height": 900
}
],
"states": [
"PR #2432 rebuilt as one commit on origin/main 1f140a5d38 (after Tailwind 4, #2443/#2444), on a private mock stack whose backend is built from this tree (the mock generator and REST mapper changes are live), Vite serving this tree, light and dark",
"Proxmox Storage at 1440 and 390: PBS datastores read Online instead of Unknown, types read NFS, Pool, Filesystem, Datastore and Backup Target, cluster-merged storage reads Shared \u00b7 6 nodes, shared PBS storage is one row; the 390 storage table still clips host and usage values exactly as main does, which open PR #2433 addresses",
"Proxmox Overview nodes table at 1440 light and dark in Bars mode: seven multi-disk hosts show their fullest disk percentage beside equal-weight micro-bars with a Highest usage title, none clipped; bar and fill corners compute 2px, matching main's rounded-xs after the Tailwind 4 rename",
"Mail Gateway on six fresh loads: both instances fill Version, Nodes, Uptime and mail counters every time, with no dash-only rows",
"Docker at 1440, 1280 and 390: Engine reads docker 26.1.3 or podman 4.9.3 whole with the Update control in view; phone containers keep Container, State, CPU, Mem and Update; phone images keep Image, Size and Update; Swarm services read Rollback started and tasks show Started as ages with no raw RFC3339 stamps; storage, networks and swarm phone clipping is limited to identity strings",
"The mock estate has no multi-disk Docker host or machine, so Bars mode was seen on Proxmox nodes only; DockerHostsTable and AgentsMachinesTable share the same StackedDiskBar path and its unit tests"
],
"interactions": [
"scrollWidth-over-clientWidth sweep of every visible table cell (sr-only excluded) on each route and width; read column value distributions for Storage State, Type and Host and Docker Engine",
"Opened Mail Gateway from the Proxmox tab strip on six independent page loads and counted dash-only cells per row",
"Read computed border radius of the vertical disk bars and the max-label text and title on every multi-disk node row",
"go test ./internal/mock ./internal/unifiedresources passed; vitest for src/components/Workloads, src/features/docker, src/features/storageBackups, src/features/platformPage and useUnifiedResources: 174 files, 3384 tests passed"
]
}
@@ -0,0 +1,37 @@
{
"version": 1,
"base_sha": "ffac59b6dcccaf03f52fb4ba747b6f99a79ba9b0",
"verified_at": "2026-10-03T22:38:46.141Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/stackedDiskBarModel.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/stackedDiskBarModel.ts": "9f50f630df8379673032052970e7cc6d287c02ecd212b3df5d16f1032be42413"
},
"routes": [
"/browser-tests/composed-disk-labels.html",
"/browser-tests/guest-disk-deferral.html"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Exact combined deferred-read statusMessage and measured-glyph disk model; production StackedDiskBar, ResizeObserver, GuestRow, Overview, disk list and stylesheet, synthetic observations only. Playwright 1.56.1 matches the integration lock; Chromium 141.0.7390.37 light desktop and WebKit 26.0 dark 390px phone.",
"100px bars retain percentages and omit optional capacity detail; equal inline slots retain iiii 67% but drop the wider MMMM label. 320px bars show whole capacity, anomaly, fullest-disk/count and inline labels without ellipsis. The micro-bars keep a truthful 67% highest-disk label.",
"Paused guest evidence retains last-known disk values and caller-owned VM-operation-lock context in row, Overview, disk list and desktop tooltip. Same-VM fresh observations clear stale context without remount; row, Overview and disk list settle at 75%. All eight final screenshots inspected.",
"Phone drawer rendering is verified using an emulated mouse click on the guest name. Separate trusted WebKit row/name taps emitted no click and did not open the drawer; this pass does not establish touch acceptance or clear that adverse observation. No native collection, QGA/thaw, installation or release claim."
],
"interactions": [
"Chromium keyboard focus and Enter / WebKit tap on the native Roomy button drive ResizeObserver from 100px to 320px. Independent DOM scrollWidth/clientWidth checks verify rendered label fit.",
"Desktop hover reads paused provenance beside unchanged /data evidence, then confirms fresh same-disk update removes only that status. Production GuestRow/Overview/list callers are exercised at both viewports; the phone guest is opened by mouse click, not a claimed tap success.",
"Fresh source update preserves the same row node, clears stale status, updates the Overview progressbar and waits for AnimatedNumber to settle at 75% before capture. No page error, off-origin request, non-GET request or horizontal page overflow."
]
}
@@ -0,0 +1,45 @@
{
"version": 1,
"base_sha": "ebda81d2be79d2ee7e7ff4f665b41e1d330d87a6",
"verified_at": "2026-10-03T20:21:04Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx",
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "ddad24a010a2a194f37977f3c3aa3fa647b431bd65d1d75e9f5aea6d42f72692",
"frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "5d7362905632cf4e69c4a878f972ac40544bda2959db2822a431bcc74ba35089"
},
"routes": [
"/kubernetes"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 1280,
"height": 900
},
{
"width": 900,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Merge of origin/main 1f140a5d38 into #2434: both files carry the PR's controller-expansion changes plus main's Tailwind 4 renames (max-w-48/max-w-52 truncation spans, rounded-sm badges, dark:bg-blue-900/25 identity IP badge), checked on a private mock stack built from the merged tree",
"Workload Controllers table with DaemonSet, CronJob, Job and StatefulSet rows across three mock clusters: 1440 shows Scope and Detail, 1280 and 900 hide them, 390 keeps Controller, Kind, Ready and Issues; table width equals its container at every width, 1440 clips only one long StatefulSet Service detail, 1280 and 900 clip nothing, 390 clips only long controller names",
"CronJob, Job and StatefulSet rows expanded at every width in light (and dark at 1440 and 390): the CronJob section shows Schedule, Last run and Last success as absolute time with age, Active, Namespace and Cluster; the Job section shows Started, Completed, Duration 5m, Target 1 completion and Succeeded; the StatefulSet shows Service, Target, Current, Ready, Available and Updated; no value in those sections clips, and only the pre-existing Identity IDs clip at 390"
],
"interactions": [
"Loaded /kubernetes per viewport and colour scheme, clicked the cron-nightly-backfill, nightly-backfill-28918234 and platform-observability rows to open and close their inline drawers",
"Ran a scrollWidth-over-clientWidth sweep over every controllers th/td leaf and every leaf inside each open drawer",
"Ran vitest for src/features/kubernetes, src/components/Infrastructure and src/features/platformPage on the merged tree: 90 files, 1504 tests passed"
]
}
@@ -2,16 +2,20 @@ import { describe, expect, it } from 'vitest';
import { estimateTextWidth } from '@/utils/format';
import metricBarSource from '../MetricBar.tsx?raw';
import type { MetricBarProps } from '../metricBarModel';
import { buildMetricBarPresentation } from '../metricBarModel';
// estimateTextWidth(text) = text.length * 5.5 + 8 (mirrored from @/utils/format).
// For label='CPU' + sublabel='8c' the composed threshold text is 'CPU (8c)'
// (length 8) -> estimateTextWidth = 8 * 5.5 + 8 = 52. So a containerWidth of 52
// trips the >= true arm of the showSublabel check, and 51 trips the false arm.
// The long form needs its text plus the 4px of padding the label span
// carries. For label='CPU' + sublabel='8c' the text is 'CPU' semibold (21.83)
// and ' (8c)' at normal weight (23.04): 44.87, rounded up to 45, plus 4 = 49.
// So a containerWidth of 49 trips the >= true arm of the showSublabel check,
// and 48 trips the false arm.
const LABEL = 'CPU';
const SUBLABEL = '8c';
const SUBLABEL_THRESHOLD = estimateTextWidth(`${LABEL} (${SUBLABEL})`); // 52
const LABEL_PADDING_PX = 4;
const SUBLABEL_THRESHOLD =
estimateTextWidth(LABEL, { detail: ` (${SUBLABEL})` }) + LABEL_PADDING_PX; // 49
// Tailwind background class mirrored from metricThresholds.BG_CLASSES.normal.
// Default cpu thresholds are warning 80 / critical 90 (METRIC_THRESHOLDS.cpu),
@@ -65,23 +69,47 @@ describe('buildMetricBarPresentation (branch coverage 0720pm)', () => {
});
});
describe('showSublabel threshold branch (containerWidth >= estimateTextWidth(...))', () => {
describe('showSublabel threshold branch (containerWidth >= estimateTextWidth(...) + padding)', () => {
it('returns showSublabel=true when containerWidth meets the threshold exactly (>= true arm)', () => {
// estimateTextWidth('CPU (8c)') === 52; containerWidth 52 -> 52 >= 52 true.
expect(SUBLABEL_THRESHOLD).toBe(52);
// The threshold is 49; containerWidth 49 -> 49 >= 49 true.
expect(SUBLABEL_THRESHOLD).toBe(49);
const p = buildMetricBarPresentation(makeProps(), SUBLABEL_THRESHOLD);
expect(p.showLabel).toBe(true);
expect(p.showSublabel).toBe(true);
});
it('returns showSublabel=false when containerWidth is one below the threshold (false arm)', () => {
// containerWidth 51 < 52 -> the >= arm is false -> showSublabel false.
// containerWidth 48 < 49 -> the >= arm is false -> showSublabel false.
// showLabel itself is unaffected by the width check (still true here).
const p = buildMetricBarPresentation(makeProps(), SUBLABEL_THRESHOLD - 1);
expect(p.showLabel).toBe(true);
expect(p.showSublabel).toBe(false);
});
it('keeps a 113px bar on the short label for "60% (265 GB/440 GB)"', () => {
// Measured in the browser on 2026-10-03: that label is 110.1px of text
// at text-[10px], so it needs 114.1px here and 118.1px in the memory
// bar's wider chip. The old 5.5px-per-character estimate put it at
// 112.5px, showed the long form in a 113px bar and clipped it.
const props = makeProps({ value: 60, label: '60%', sublabel: '265 GB/440 GB' });
expect(buildMetricBarPresentation(props, 109).showSublabel).toBe(false);
expect(buildMetricBarPresentation(props, 113).showSublabel).toBe(false);
expect(buildMetricBarPresentation(props, 114).showSublabel).toBe(false);
expect(buildMetricBarPresentation(props, 115).showSublabel).toBe(true);
});
it('fits a dotted label that the per-character estimate turned away', () => {
// '71% (5.65/8.00TB)' renders 93.5px wide: full stops and the slash are
// half a digit. It fits a 100px bar; 17 characters at 5.5px did not.
const props = makeProps({ value: 71, label: '71%', sublabel: '5.65/8.00TB' });
expect(buildMetricBarPresentation(props, 100).showSublabel).toBe(true);
expect(buildMetricBarPresentation(props, 97).showSublabel).toBe(false);
});
it('counts the padding the label span carries in MetricBar', () => {
expect(metricBarSource).toContain('text-ellipsis px-0.5 text-center');
});
it('returns showSublabel=false when sublabel is empty (Boolean(props.sublabel) false arm)', () => {
// Even with a very wide container, Boolean('') is false -> showSublabel false.
const p = buildMetricBarPresentation(makeProps({ sublabel: '' }), 1000);
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import type { AnomalyReport } from '@/types/aiIntelligence';
import type { Disk } from '@/types/api';
import stackedDiskBarSource from '../StackedDiskBar.tsx?raw';
import { buildStackedDiskBarPresentation } from '../stackedDiskBarModel';
// The four small helpers under test (getDiskUsagePercent, getShortDiskLabel,
@@ -117,7 +118,8 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
});
describe('getInlineDiskText (via miniDisks[].inlineText)', () => {
// estimateInlineTextWidth(text) = text.length * 5.4 + 4 (module-internal).
// estimateInlineTextWidth(text) is the label's width at text-[9px]
// semibold, rounded up, plus the slot's 2px of padding (module-internal).
it('returns the full text when slotWidth <= 0 (short-circuit)', () => {
// slotWidth = containerWidth(0) / 1 = 0 -> first operand of the || .
const p = buildStackedDiskBarPresentation(
@@ -130,7 +132,7 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
});
it('returns the full text when it fits within the slot', () => {
// 'sda1 50%' width = 8 * 5.4 + 4 = 47.2 <= slotWidth(60).
// 'sda1 50%' width = 46 + 2 = 48 <= slotWidth(60).
const p = buildStackedDiskBarPresentation(
{
disks: [makeDisk({ mountpoint: '/dev/sda1', total: 100, used: 50, free: 50, usage: 50 })],
@@ -141,8 +143,8 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
});
it('falls back to the percent label when only it fits', () => {
// shortLabel 'a', percentLabel '100%'. full 'a 100%' width 36.4 > 30;
// '100%' width 25.6 <= 30 -> returns '100%'.
// shortLabel 'a', percentLabel '100%'. full 'a 100%' width 38 > 30;
// '100%' width 30 <= 30 -> returns '100%'.
const p = buildStackedDiskBarPresentation(
{ disks: [makeDisk({ mountpoint: '/dev/a', total: 100, used: 100, free: 0, usage: 100 })] },
30,
@@ -151,7 +153,7 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
});
it('falls back to the short label when the percent label does not fit but the short label does', () => {
// slotWidth 20: full 36.4 > 20; percent '100%' 25.6 > 20; short 'a' 9.4 <= 20 -> 'a'.
// slotWidth 20: full 38 > 20; percent '100%' 30 > 20; short 'a' 8 <= 20 -> 'a'.
const p = buildStackedDiskBarPresentation(
{ disks: [makeDisk({ mountpoint: '/dev/a', total: 100, used: 100, free: 0, usage: 100 })] },
20,
@@ -167,6 +169,26 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
);
expect(p.miniDisks[0].inlineText).toBe('');
});
it('sizes each slot after the gap between slots', () => {
// Two disks in a 117.58px bar (Proxmox nodes at a 900px viewport) get
// 57.79px each once the 2px gap is out. 'backup 80%' needs 58 + 2, so
// the slot keeps the percentage; splitting the bar in half had let the
// full text in and clipped it.
const disks = [
makeDisk({ mountpoint: '/backup', total: 100, used: 80, free: 20, usage: 80 }),
makeDisk({ mountpoint: '/', total: 100, used: 50, free: 50, usage: 50 }),
];
const narrow = buildStackedDiskBarPresentation({ disks }, 117.58);
expect(narrow.miniDisks.map((disk) => disk.inlineText)).toEqual(['80%', '/ 50%']);
const wide = buildStackedDiskBarPresentation({ disks }, 122);
expect(wide.miniDisks[0].inlineText).toBe('backup 80%');
});
it('counts the padding and gap the inline slots carry in StackedDiskBar', () => {
expect(stackedDiskBarSource).toContain('flex h-full items-stretch gap-0.5');
expect(stackedDiskBarSource).toContain('overflow-hidden px-px text-center text-[9px]');
});
});
describe('getStackedDiskColor (via segments[].color, mode=stacked)', () => {
@@ -467,6 +489,47 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
expect(unknown.anomalyRatio).toBe('');
});
it('shows the sublabel only where the whole label fits', () => {
// '60% (265 GB/440 GB)' is 110.1px of text in the browser and sits in
// 4px of padding: a 113px bar keeps the percentage, a 115px bar fits it.
const GiB = 1024 ** 3;
const disks = [
makeDisk({
mountpoint: '/',
total: 440 * GiB,
used: 265 * GiB,
free: 175 * GiB,
usage: 60,
}),
];
const narrow = buildStackedDiskBarPresentation({ disks }, 113);
expect(narrow.displayLabel).toBe('60%');
expect(narrow.displaySublabel).toBe('265 GB/440 GB');
expect(narrow.showSublabel).toBe(false);
expect(buildStackedDiskBarPresentation({ disks }, 114).showSublabel).toBe(false);
expect(buildStackedDiskBarPresentation({ disks }, 115).showSublabel).toBe(true);
expect(stackedDiskBarSource).toContain('text-ellipsis px-0.5 text-center');
});
it('leaves room for the anomaly marker that shares the line', () => {
// ' 2.5x' adds 25.35px, taking the threshold from 115 to 140.
const GiB = 1024 ** 3;
const props = {
disks: [
makeDisk({
mountpoint: '/',
total: 440 * GiB,
used: 265 * GiB,
free: 175 * GiB,
usage: 60,
}),
],
anomaly: makeAnomaly({ baseline_mean: 10, current_value: 25 }),
};
expect(buildStackedDiskBarPresentation(props, 139).showSublabel).toBe(false);
expect(buildStackedDiskBarPresentation(props, 140).showSublabel).toBe(true);
});
it('suppresses the sublabel when containerWidth is too narrow to fit it', () => {
// displaySublabel non-empty but containerWidth < estimateTextWidth(...) ->
// showSublabel false (the width-failing arm of the && ).
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { AnomalyReport } from '@/types/aiIntelligence';
import stackedMemoryBarSource from '../StackedMemoryBar.tsx?raw';
import type { StackedMemoryBarProps } from '../stackedMemoryBarModel';
import { buildStackedMemoryBarPresentation } from '../stackedMemoryBarModel';
@@ -194,4 +195,43 @@ describe('stackedMemoryBarModel (branch coverage 0724pm)', () => {
expect(p.anomalyDescription).toBe('Memory spike');
});
});
describe('showSublabel fit (label text plus the chip padding)', () => {
// 265 of 440 GiB reads '60% (265 GB/440 GB)': 110.1px of text in the
// browser, 118.1px with the chip's 8px of padding. This is the label the
// Kubernetes clusters table clipped in a 113px bar on 2026-10-03.
const clusterMemory = makeProps({ used: 265 * GiB, total: 440 * GiB });
it('keeps a 113px bar on the short label for "60% (265 GB/440 GB)"', () => {
const p = buildStackedMemoryBarPresentation(clusterMemory, 113);
expect(p.displayLabel).toBe('60%');
expect(p.displaySublabel).toBe('265 GB/440 GB');
expect(p.showSublabel).toBe(false);
expect(buildStackedMemoryBarPresentation(clusterMemory, 118).showSublabel).toBe(false);
});
it('shows the long label once the bar is wide enough for it', () => {
expect(buildStackedMemoryBarPresentation(clusterMemory, 119).showSublabel).toBe(true);
});
it('leaves room for the anomaly marker that shares the line', () => {
// ' 3.0x' adds 25.44px, taking the threshold from 119 to 145.
const withMarker = makeProps({ ...clusterMemory, anomaly: makeAnomaly() });
expect(buildStackedMemoryBarPresentation(withMarker, 144).showSublabel).toBe(false);
expect(buildStackedMemoryBarPresentation(withMarker, 145).showSublabel).toBe(true);
});
it('ignores an anomaly that renders no marker', () => {
// The marker needs both a description and a ratio.
const noDescription = makeProps({
...clusterMemory,
anomaly: makeAnomaly({ description: '' }),
});
expect(buildStackedMemoryBarPresentation(noDescription, 119).showSublabel).toBe(true);
});
it('counts the padding the label chip carries in StackedMemoryBar', () => {
expect(stackedMemoryBarSource).toContain('px-1 text-center');
});
});
});
@@ -65,4 +65,38 @@ describe('useMetricBarState', () => {
unmount();
expect(observers[0].disconnect).toHaveBeenCalled();
});
it('shows the long label only at an observed width that fits it', () => {
const observers: MockResizeObserver[] = [];
globalThis.ResizeObserver = class extends MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
super(callback);
observers.push(this);
}
} as unknown as typeof ResizeObserver;
let captured: ReturnType<typeof useMetricBarState> | undefined;
const Harness = () => {
captured = useMetricBarState({
value: 60,
label: '60%',
sublabel: '265 GB/440 GB',
type: 'memory',
});
return <div ref={captured.setContainerRef} />;
};
render(() => <Harness />);
// '60% (265 GB/440 GB)' renders 110.1px of text plus 4px of padding. The
// width arrives from the resize observer alone; the fit never reads the
// bar's own layout.
observers[0].trigger(113);
expect(captured!.presentation().showSublabel).toBe(false);
observers[0].trigger(115);
expect(captured!.presentation().showSublabel).toBe(true);
observers[0].trigger(109);
expect(captured!.presentation().showSublabel).toBe(false);
});
});
@@ -67,4 +67,34 @@ describe('useStackedMemoryBarState', () => {
unmount();
expect(observers[0].disconnect).toHaveBeenCalled();
});
it('shows the long label only at an observed width that fits it', () => {
const observers: MockResizeObserver[] = [];
globalThis.ResizeObserver = class extends MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
super(callback);
observers.push(this);
}
} as unknown as typeof ResizeObserver;
let captured: ReturnType<typeof useStackedMemoryBarState> | undefined;
const Harness = () => {
captured = useStackedMemoryBarState({
used: 265 * 1024 ** 3,
total: 440 * 1024 ** 3,
});
return <div ref={captured.setContainerRef} />;
};
render(() => <Harness />);
// '60% (265 GB/440 GB)' renders 110.1px of text plus the chip's 8px of
// padding: the 113px Kubernetes clusters bar keeps the percentage.
observers[0].trigger(113);
expect(captured!.presentation().displaySublabel).toBe('265 GB/440 GB');
expect(captured!.presentation().showSublabel).toBe(false);
observers[0].trigger(119);
expect(captured!.presentation().showSublabel).toBe(true);
});
});
@@ -22,6 +22,9 @@ export interface MetricBarPresentation {
width: number;
}
// Horizontal padding around the label inside the bar (px-0.5 in MetricBar.tsx).
const LABEL_PADDING_PX = 4;
export function buildMetricBarPresentation(
props: MetricBarProps,
containerWidth: number,
@@ -31,7 +34,8 @@ export function buildMetricBarPresentation(
const showSublabel =
showLabel &&
Boolean(props.sublabel) &&
containerWidth >= estimateTextWidth(`${props.label} (${props.sublabel})`);
containerWidth >=
estimateTextWidth(props.label, { detail: ` (${props.sublabel})` }) + LABEL_PADDING_PX;
const metric = props.type || 'cpu';
const metricType: MetricType = metric === 'generic' ? 'cpu' : metric;
@@ -149,8 +149,17 @@ function getShortDiskLabel(label: string): string {
return trimmed;
}
// Horizontal padding around the aggregate label inside the bar (px-0.5 in
// StackedDiskBar.tsx).
const LABEL_PADDING_PX = 4;
// Inline per-disk labels are text-[9px] font-semibold, in px-px slots that a
// gap-0.5 separates.
const INLINE_DISK_FONT_PX = 9;
const INLINE_DISK_SLOT_PADDING_PX = 2;
const INLINE_DISK_SLOT_GAP_PX = 2;
function estimateInlineTextWidth(text: string): number {
return text.length * 5.4 + 4;
return estimateTextWidth(text, { fontPx: INLINE_DISK_FONT_PX }) + INLINE_DISK_SLOT_PADDING_PX;
}
function getInlineDiskText(shortLabel: string, percentLabel: string, slotWidth: number): string {
@@ -274,7 +283,10 @@ export function buildStackedDiskBarPresentation(
const inlineDiskMode =
(miniMode || hasMultipleDisks) && !aggregateMode && !explicitStackedMode && !verticalBarsMode;
const useStackedSegments = hasMultipleDisks && explicitStackedMode;
const inlineDiskSlotWidth = disks.length > 0 ? containerWidth / disks.length : 0;
const inlineDiskSlotWidth =
disks.length > 0
? (containerWidth - INLINE_DISK_SLOT_GAP_PX * (disks.length - 1)) / disks.length
: 0;
const totalCapacity = hasDisks
? disks.reduce((sum, disk) => sum + (disk.total || 0), 0)
: (props.aggregateDisk?.total ?? 0);
@@ -312,15 +324,19 @@ export function buildStackedDiskBarPresentation(
(aggregateMode &&
hasMultipleDisks &&
maxLabelShort.length > 0 &&
containerWidth >= estimateTextWidth(`${displayLabel} ${maxLabelShort}`));
containerWidth >=
estimateTextWidth(displayLabel, { detail: ` ${maxLabelShort}` }) + LABEL_PADDING_PX);
// The anomaly marker shares the label's line whenever it renders.
const anomalyMarker = props.anomaly?.description && anomalyRatio ? ` ${anomalyRatio}` : '';
const showSublabel =
displaySublabel.length > 0 &&
containerWidth >=
estimateTextWidth(
`${displayLabel}${showMaxLabel ? ` ${maxLabelShort}` : ''} (${displaySublabel})${
estimateTextWidth(`${displayLabel}${anomalyMarker}`, {
detail: `${showMaxLabel ? ` ${maxLabelShort}` : ''} (${displaySublabel})${
showDiskCount ? ` ${diskCountLabel}` : ''
}`,
);
}) +
LABEL_PADDING_PX;
const barColor =
aggregateMode && hasMultipleDisks && maxInfo
? getMetricColorRgba(maxInfo.percent, 'disk', props.thresholds)
@@ -271,6 +271,9 @@ function getTooltipRows(
return rows;
}
// Horizontal padding of the label chip inside the bar (px-1 in StackedMemoryBar.tsx).
const LABEL_PADDING_PX = 8;
export function buildStackedMemoryBarPresentation(
props: StackedMemoryBarProps,
containerWidth: number,
@@ -281,16 +284,21 @@ export function buildStackedMemoryBarPresentation(
!props.unavailable && props.total > 0
? `${formatBytes(props.used)}/${formatBytes(props.total)}`
: '';
const anomalyRatio = formatAnomalyRatio(props.anomaly) ?? '';
// The anomaly marker shares the label's line whenever it renders.
const anomalyMarker = props.anomaly?.description && anomalyRatio ? ` ${anomalyRatio}` : '';
const showSublabel =
displaySublabel.length > 0 &&
containerWidth >= estimateTextWidth(`${displayLabel} (${displaySublabel})`);
containerWidth >=
estimateTextWidth(`${displayLabel}${anomalyMarker}`, { detail: ` (${displaySublabel})` }) +
LABEL_PADDING_PX;
return {
anomalyClass: props.anomaly
? (ANOMALY_SEVERITY_CLASS[props.anomaly.severity] ?? 'text-yellow-400')
: 'text-yellow-400',
anomalyDescription: props.anomaly?.description,
anomalyRatio: formatAnomalyRatio(props.anomaly) ?? '',
anomalyRatio,
displayLabel,
displayPercentValue: utilizationPercent,
displaySublabel,
@@ -321,9 +321,20 @@ describe('platform overview layout guardrails', () => {
]) {
expect(source).toContain('platform-table-mobile-w-30');
}
// The images phone row keeps image, size and the update verdict; host and
// consumers move to the row expansion so the verdict is not clipped.
expect(dockerImagesTableSource).toMatch(
/sortKey="size"[\s\S]{0,120}?class="platform-table-mobile-w-20 md:w-\[12%\]"/,
);
expect(dockerImagesTableSource).toMatch(
/sortKey="host"[\s\S]{0,120}?class="platform-table-phone-hidden md:w-\[18%\]"/,
);
expect(dockerImagesTableSource).toMatch(
/sortKey="usedBy"[\s\S]{0,120}?class="platform-table-phone-hidden md:w-\[24%\]"/,
);
expect(dockerImagesTableSource).toMatch(
/sortKey="update"[\s\S]{0,120}?class="platform-table-mobile-w-25 md:w-\[16%\]"/,
);
expect(dockerServicesTableSource).toMatch(
/sortKey="mode"[\s\S]{0,120}?class="platform-table-phone-hidden md:w-\[8%\]"/,
);
@@ -29,9 +29,7 @@ import type {
GuestSnapshot,
PBSBackup,
PBSBackupsPayload,
PBSBackupsResponse,
PVEBackupsPayload,
PVEBackupsResponse,
StorageBackup,
} from '@/types/api';
import type { Resource } from '@/types/resource';
@@ -70,6 +68,12 @@ import { ProxmoxBackupsCoverageStrip } from './ProxmoxBackupsCoverageStrip';
import { ProxmoxBackupServersTable } from './ProxmoxBackupServersTable';
import { ProxmoxCoverageTable } from './ProxmoxCoverageTable';
import { ProxmoxRecoverableTable } from './ProxmoxRecoverableTable';
import {
BackupInventoryFormatError,
parsePBSBackupInventory,
parsePVEBackupInventory,
readBackupInventoryJSON,
} from './proxmoxBackupInventory';
// One backups surface, two operator views: a chronological recoverable-artifact
// feed for "what ran when", and a guest coverage table for "what is protected".
@@ -97,8 +101,7 @@ async function fetchPVEBackups(signal: AbortSignal): Promise<PVEBackupsPayload>
status: response.status,
});
}
const payload = (await response.json()) as PVEBackupsResponse;
return payload?.data ?? EMPTY_PVE_BACKUPS;
return parsePVEBackupInventory(await readBackupInventoryJSON(response));
}
async function fetchPBSBackups(signal: AbortSignal): Promise<PBSBackupsPayload> {
@@ -108,8 +111,7 @@ async function fetchPBSBackups(signal: AbortSignal): Promise<PBSBackupsPayload>
status: response.status,
});
}
const payload = (await response.json()) as PBSBackupsResponse;
return payload?.data ?? EMPTY_PBS_BACKUPS;
return parsePBSBackupInventory(await readBackupInventoryJSON(response));
}
export const ProxmoxBackupsTable: Component<{
@@ -702,6 +704,11 @@ export const ProxmoxBackupsTable: Component<{
<span>
{label} backup inventory is {query.error() ? 'unavailable' : 'loading'}. Counts
are incomplete. {getAPIReadAccessErrorMessage(query.error())}
<Show when={query.error() instanceof BackupInventoryFormatError}>
{' '}
The response format is invalid. Missing restore points do not mean no backups
exist.
</Show>
</span>
<Show when={query.error()}>
<button
@@ -735,6 +735,79 @@ describe('independent Proxmox backup inventory reads', () => {
window.history.replaceState({}, '', '/proxmox/backups/date');
apiFetchJSONMock.mockResolvedValue({ data: [], policy: {}, meta: {} });
});
it.each(['pve', 'pbs'] as const)(
'keeps malformed HTTP200 %s responses source-local and recovers with an isolated retry',
async (source) => {
const bad = source === 'pbs' ? { data: { backups: [null] } } : { data: {} };
let response = Promise.resolve(jsonResponse(bad));
apiFetchMock.mockImplementation((url) =>
url.endsWith(`/${source}`)
? response
: Promise.resolve(jsonResponse(source === 'pve' ? pbsPayload : readPvePayload)),
);
mountBackupReadState();
await screen.findByText(/The response format is invalid/);
await waitFor(() => expect(recoverableRows()).toHaveLength(1));
expect(screen.queryByText('No backups yet')).not.toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
if (source === 'pbs') expect(serverTable()).toHaveTextContent('Unavailable');
const pending = deferred<Response>();
response = pending.promise;
const retry = screen.getByRole('button', { name: `Retry ${source.toUpperCase()} inventory` });
fireEvent.click(retry);
expect(retry).toBeDisabled();
expect(screen.getByText(/The response format is invalid/)).toBeInTheDocument();
expect(recoverableRows()).toHaveLength(1);
pending.resolve(jsonResponse(source === 'pbs' ? pbsPayload : readPvePayload));
await waitFor(() => expect(recoverableRows()).toHaveLength(2));
expect(screen.queryByText(/The response format is invalid/)).not.toBeInTheDocument();
expect(sourceCalls(source === 'pbs' ? 'pve' : 'pbs')).toHaveLength(1);
},
);
it('does not infer empty PBS inventory from a missing data envelope', async () => {
apiFetchMock.mockImplementation((url) =>
Promise.resolve(jsonResponse(url.endsWith('/pbs') ? {} : emptyPVE)),
);
mountBackupReadState();
await screen.findByText('Backup inventory is incomplete');
expect(screen.queryByText('No backups yet')).not.toBeInTheDocument();
expect(serverTable()).toHaveTextContent('Unavailable');
});
it('keeps undecodable successful bodies out of user-facing diagnostics', async () => {
apiFetchMock.mockImplementation((url) =>
Promise.resolve(
url.endsWith('/pbs')
? new Response('SYNTHETIC_PRIVATE_BODY_DO_NOT_DISPLAY', { status: 200 })
: jsonResponse(readPvePayload),
),
);
mountBackupReadState();
await screen.findByText(/The response format is invalid/);
await waitFor(() => expect(recoverableRows()).toHaveLength(1));
expect(document.body).not.toHaveTextContent('SYNTHETIC_PRIVATE_BODY_DO_NOT_DISPLAY');
});
it('accepts Go nil PVE collections as explicitly observed empty inventory', async () => {
apiFetchMock.mockImplementation((url) =>
Promise.resolve(
jsonResponse(
url.endsWith('/pbs')
? emptyPBS
: { data: { backupTasks: null, storageBackups: null, guestSnapshots: null } },
),
),
);
mountBackupReadState();
await screen.findByText('No backups yet');
expect(screen.queryByText(/inventory is unavailable/)).not.toBeInTheDocument();
expect(
within(serverTable() as HTMLElement).getByText('0', { exact: true }),
).toBeInTheDocument();
});
it('does not present an unread PBS inventory as empty or a measured zero', async () => {
const pending = deferred<Response>();
apiFetchMock.mockImplementation((url) =>
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest';
import {
BackupInventoryFormatError,
parsePBSBackupInventory,
parsePVEBackupInventory,
readBackupInventoryJSON,
} from '../proxmoxBackupInventory';
const task = { id: 'task-112', vmid: 112, type: 'ct', status: 'OK', startTime: '' };
const archive = { id: 'archive-112', vmid: 112, type: 'ct', time: '' };
const snapshot = { id: 'snapshot-112', vmid: 112, type: 'ct', time: '' };
const pbs = { id: 'pbs-112', vmid: '112', backupType: 'ct', backupTime: '', files: [] };
const pveData = { backupTasks: [task], storageBackups: [archive], guestSnapshots: [snapshot] };
const pbsData = { backups: [pbs] };
describe('backup inventory response boundary', () => {
it('keeps observed rows, missing optional facts and future fields without coercion', () => {
const data = {
...pveData,
storageBackups: [{ ...archive, inProgress: true, futureField: { enabled: true } }],
};
const result = parsePVEBackupInventory({ data });
expect(result).toEqual(data);
expect(result.storageBackups).toBe(data.storageBackups);
expect(result.storageBackups[0].size).toBeUndefined();
expect(parsePBSBackupInventory({ data: pbsData })).toEqual(pbsData);
});
it('accepts empty arrays and explicitly nil PVE collections, not missing collections', () => {
const empty = { backupTasks: [], storageBackups: [], guestSnapshots: [] };
expect(parsePVEBackupInventory({ data: empty })).toEqual(empty);
expect(
parsePVEBackupInventory({
data: { backupTasks: null, storageBackups: null, guestSnapshots: null },
}),
).toEqual(empty);
expect(parsePBSBackupInventory({ data: { backups: [] } })).toEqual({ backups: [] });
expect(() => parsePBSBackupInventory({ data: { backups: null } })).toThrow(
BackupInventoryFormatError,
);
});
it.each([undefined, null, false, 'response', [], {}, { data: null }, { data: [] }, { data: {} }])(
'rejects a missing or invalid envelope: %j',
(payload) => {
expect(() => parsePVEBackupInventory(payload)).toThrow(BackupInventoryFormatError);
expect(() => parsePBSBackupInventory(payload)).toThrow(BackupInventoryFormatError);
},
);
it.each(['backupTasks', 'storageBackups', 'guestSnapshots'] as const)(
'rejects missing, scalar, object and invalid %s rows without silently dropping them',
(key) => {
for (const value of [undefined, false, '', {}, [null], ['row'], [pveData[key][0], {}]]) {
expect(() => parsePVEBackupInventory({ data: { ...pveData, [key]: value } })).toThrow(
BackupInventoryFormatError,
);
}
},
);
it.each([undefined, false, '', {}, [null], ['row'], [pbs, {}]])(
'rejects a malformed PBS collection or row: %j',
(backups) => {
expect(() => parsePBSBackupInventory({ data: { backups } })).toThrow(
BackupInventoryFormatError,
);
},
);
it.each([
['id', ''],
['vmid', 112],
['backupType', {}],
['backupTime', null],
['files', undefined],
['files', null],
['files', {}],
['files', [42]],
['instance', {}],
['datastore', []],
['namespace', 1],
['size', '1000'],
['size', -1],
['size', Number.NaN],
['protected', 'false'],
['verified', 1],
['inProgress', 'true'],
['writeActivityObserved', {}],
['writeActive', null],
])(
'rejects mistyped PBS %s without inventing an identity or completion state',
(field, value) => {
expect(() =>
parsePBSBackupInventory({ data: { backups: [{ ...pbs, [field as string]: value }] } }),
).toThrow(BackupInventoryFormatError);
},
);
it.each([
['backupTasks', task, 'status', {}],
['backupTasks', task, 'startTime', null],
['backupTasks', task, 'endTime', 1],
['backupTasks', task, 'error', []],
['storageBackups', archive, 'id', ' '],
['storageBackups', archive, 'vmid', '112'],
['storageBackups', archive, 'vmid', 1.5],
['storageBackups', archive, 'vmid', -1],
['storageBackups', archive, 'time', {}],
['storageBackups', archive, 'type', null],
['storageBackups', archive, 'node', {}],
['storageBackups', archive, 'instance', 1],
['storageBackups', archive, 'storage', []],
['storageBackups', archive, 'format', {}],
['storageBackups', archive, 'volid', []],
['storageBackups', archive, 'size', Number.POSITIVE_INFINITY],
['storageBackups', archive, 'protected', 'false'],
['storageBackups', archive, 'verified', 1],
['storageBackups', archive, 'isPBS', 'false'],
['storageBackups', archive, 'inProgress', 'true'],
['guestSnapshots', snapshot, 'name', {}],
['guestSnapshots', snapshot, 'description', []],
['guestSnapshots', snapshot, 'sizeBytes', -1],
] as const)('rejects mistyped PVE %s.%s', (key, row, field, value) => {
expect(() =>
parsePVEBackupInventory({ data: { ...pveData, [key]: [{ ...row, [field]: value }] } }),
).toThrow(BackupInventoryFormatError);
});
it('preserves valid zero values, host identifiers and boolean completion facts', () => {
const row = {
...pbs,
vmid: '',
backupType: 'host',
namespace: '',
size: 0,
protected: false,
verified: false,
inProgress: true,
writeActivityObserved: true,
writeActive: false,
};
expect(parsePBSBackupInventory({ data: { backups: [row] } }).backups[0]).toEqual(row);
});
it('replaces JSON decoding errors with fixed copy that contains no response content', async () => {
const response = new Response('SYNTHETIC_PRIVATE_BODY_DO_NOT_DISPLAY');
await expect(readBackupInventoryJSON(response)).rejects.toEqual(
new BackupInventoryFormatError(),
);
await expect(readBackupInventoryJSON(new Response('null'))).resolves.toBeNull();
});
});
@@ -0,0 +1,131 @@
import type {
BackupTask,
GuestSnapshot,
PBSBackup,
PBSBackupsPayload,
PVEBackupsPayload,
StorageBackup,
} from '@/types/api';
export class BackupInventoryFormatError extends Error {
constructor() {
// Never include response bodies, provider identifiers or JSON parser text.
super('The response format is invalid. Missing restore points do not mean no backups exist.');
this.name = 'BackupInventoryFormatError';
}
}
type FieldCheck = (value: unknown) => boolean;
type RowShape = Readonly<Record<string, FieldCheck>>;
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const isString: FieldCheck = (value) => typeof value === 'string';
const isID: FieldCheck = (value) => typeof value === 'string' && value.trim().length > 0;
const isBoolean: FieldCheck = (value) => typeof value === 'boolean';
const isNonnegativeNumber: FieldCheck = (value) =>
typeof value === 'number' && Number.isFinite(value) && value >= 0;
const isGuestID: FieldCheck = (value) => isNonnegativeNumber(value) && Number.isSafeInteger(value);
const optional =
(check: FieldCheck): FieldCheck =>
(value) =>
value === undefined || check(value);
const isFiles: FieldCheck = (value) => Array.isArray(value) && value.every(isString);
// Validate what the recovery model consumes, without guessing missing identity,
// coercing completion flags, silently dropping bad rows or rejecting extra fields.
// Optional display fields retain the model's existing unknown/fallback behaviour.
const pveGuestShape: RowShape = {
id: isID,
vmid: isGuestID,
type: isString,
node: optional(isString),
instance: optional(isString),
};
const taskShape: RowShape = {
...pveGuestShape,
status: isString,
startTime: isString,
endTime: optional(isString),
error: optional(isString),
};
const archiveShape: RowShape = {
...pveGuestShape,
time: isString,
storage: optional(isString),
format: optional(isString),
volid: optional(isString),
size: optional(isNonnegativeNumber),
protected: optional(isBoolean),
isPBS: optional(isBoolean),
verified: optional(isBoolean),
inProgress: optional(isBoolean),
};
const snapshotShape: RowShape = {
...pveGuestShape,
time: isString,
name: optional(isString),
description: optional(isString),
sizeBytes: optional(isNonnegativeNumber),
};
const pbsShape: RowShape = {
id: isID,
vmid: isString,
backupType: isString,
backupTime: isString,
files: isFiles,
instance: optional(isString),
datastore: optional(isString),
namespace: optional(isString),
size: optional(isNonnegativeNumber),
protected: optional(isBoolean),
verified: optional(isBoolean),
inProgress: optional(isBoolean),
writeActivityObserved: optional(isBoolean),
writeActive: optional(isBoolean),
};
function inventoryData(payload: unknown): Record<string, unknown> {
if (!isRecord(payload) || !isRecord(payload.data)) throw new BackupInventoryFormatError();
return payload.data;
}
function collection<T>(
data: Record<string, unknown>,
key: string,
shape: RowShape,
allowNil = false,
): T[] {
const value = data[key];
// The PVE handler can encode its empty Go slices as null. A missing field
// is not that observation. PBS normalizes both its list and files to arrays.
if (allowNil && value === null) return [];
const fields = Object.entries(shape);
if (
!Array.isArray(value) ||
!value.every((row) => isRecord(row) && fields.every(([field, check]) => check(row[field])))
) {
throw new BackupInventoryFormatError();
}
return value as T[];
}
export function parsePVEBackupInventory(payload: unknown): PVEBackupsPayload {
const data = inventoryData(payload);
return {
backupTasks: collection<BackupTask>(data, 'backupTasks', taskShape, true),
storageBackups: collection<StorageBackup>(data, 'storageBackups', archiveShape, true),
guestSnapshots: collection<GuestSnapshot>(data, 'guestSnapshots', snapshotShape, true),
};
}
export function parsePBSBackupInventory(payload: unknown): PBSBackupsPayload {
return { backups: collection<PBSBackup>(inventoryData(payload), 'backups', pbsShape) };
}
export async function readBackupInventoryJSON(response: Response): Promise<unknown> {
try {
return await response.json();
} catch {
throw new BackupInventoryFormatError();
}
}
@@ -58,24 +58,26 @@ describe('formatPowerOnHours - boundaries', () => {
});
describe('estimateTextWidth - edge inputs', () => {
it('counts whitespace characters as full-width', () => {
expect(estimateTextWidth(' ')).toBe(13.5);
expect(estimateTextWidth(' ')).toBe(19);
it('gives a space its own narrow advance', () => {
// A semibold space is 2.78px in the fallback advances.
expect(estimateTextWidth(' ')).toBe(3);
expect(estimateTextWidth(' ')).toBe(6);
});
it('counts control characters (tab, newline) as length 1', () => {
expect(estimateTextWidth('\t')).toBe(13.5);
expect(estimateTextWidth('\n')).toBe(13.5);
it('counts a glyph outside the measured ASCII range as a full em', () => {
expect(estimateTextWidth('\t')).toBe(10);
expect(estimateTextWidth('\n')).toBe(10);
expect(estimateTextWidth('—')).toBe(10);
});
it('counts surrogate pairs as length 2 (UTF-16 code units)', () => {
it('counts surrogate pairs as two UTF-16 code units', () => {
// "😀" is one code point but two UTF-16 code units -> length === 2
expect('😀'.length).toBe(2);
expect(estimateTextWidth('😀')).toBe(19);
expect(estimateTextWidth('😀')).toBe(20);
});
it('scales linearly for very long strings', () => {
expect(estimateTextWidth('a'.repeat(100))).toBe(558);
expect(estimateTextWidth('a'.repeat(100))).toBe(589); // 100 x 5.89
});
it('is strictly monotonic with respect to length', () => {
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
formatPowerOnHours,
estimateTextWidth,
@@ -36,12 +36,75 @@ describe('formatPowerOnHours', () => {
});
describe('estimateTextWidth', () => {
it('estimates width based on character count', () => {
expect(estimateTextWidth('')).toBe(8);
expect(estimateTextWidth('a')).toBe(13.5);
expect(estimateTextWidth('abc')).toBe(24.5);
expect(estimateTextWidth('hello')).toBe(35.5); // 5 chars = 27.5 + 8
expect(estimateTextWidth('hello world')).toBe(68.5); // 11 chars = 60.5 + 8
// jsdom has no layout, so these read the measured fallback advances
// (macOS system font at 10px) rather than a live probe.
it('returns zero for empty text', () => {
expect(estimateTextWidth('')).toBe(0);
});
it('sums per-glyph advances and rounds up', () => {
expect(estimateTextWidth('a')).toBe(6); // 5.89
expect(estimateTextWidth('abc')).toBe(19); // 5.89 + 6.51 + 5.9 = 18.3
});
it('tells wide glyphs from narrow ones at the same length', () => {
expect(estimateTextWidth('888')).toBe(21); // 3 x 6.88
expect(estimateTextWidth('...')).toBe(11); // 3 x 3.43
expect(estimateTextWidth('MMM')).toBe(28); // 3 x 9.08
expect(estimateTextWidth('iii')).toBe(9); // 3 x 2.86
});
it('measures detail at normal weight after the semibold value', () => {
// 60% is 23.7 semibold, " (265 GB/440 GB)" 86.89 at normal weight. The
// browser renders that label 110.1px wide.
expect(estimateTextWidth('60%', { detail: ' (265 GB/440 GB)' })).toBe(111);
expect(estimateTextWidth('265 GB')).toBe(38);
expect(estimateTextWidth('', { detail: '265 GB' })).toBe(37);
});
it('scales with the label font size', () => {
expect(estimateTextWidth('sda1 50%')).toBe(52); // 51.03
expect(estimateTextWidth('sda1 50%', { fontPx: 9 })).toBe(46); // 45.93
});
});
describe('estimateTextWidth with layout', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
});
it('reads glyph advances from one hidden probe and reuses them', async () => {
// A stand-in font: digits 8px, capitals 9px, everything else 4px, each
// probe cell holding its glyph sixteen times.
const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (
this: Element,
) {
const text = this.textContent ?? '';
const advance = /\d/.test(text[0]) ? 8 : /[A-Z]/.test(text[0]) ? 9 : 4;
return { width: text.length * advance } as DOMRect;
});
vi.resetModules();
const { estimateTextWidth: measured } = await import('@/utils/format');
// 8 digits, 4 capitals, 7 others.
expect(measured('60%', { detail: ' (265 GB/440 GB)' })).toBe(8 * 8 + 4 * 9 + 7 * 4);
// U+0020..U+007E at both weights, plus the ten tabular digits.
expect(rect).toHaveBeenCalledTimes(95 + 10 + 95);
expect(document.body.childElementCount).toBe(0);
measured('73%', { detail: ' (475 GB/648 GB)' });
expect(rect).toHaveBeenCalledTimes(95 + 10 + 95);
});
it('keeps the measured table when a stubbed layout reports every box alike', async () => {
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
width: 100,
} as DOMRect);
vi.resetModules();
const { estimateTextWidth: measured } = await import('@/utils/format');
expect(measured('60%', { detail: ' (265 GB/440 GB)' })).toBe(111);
});
});
+121 -3
View File
@@ -283,13 +283,131 @@ export function formatPowerOnHours(hours: number, condensed = false): string {
return condensed ? `${hours}h` : `${hours} hours`;
}
// Metric bar labels are set at text-[10px]: the value semibold (with tabular
// digits while it animates), any detail after it at normal weight. Glyph
// widths in that type vary too much for a per-character constant (a digit is
// more than twice as wide as a full stop), so label widths are summed from
// per-glyph advances of the font the browser actually renders. A hidden probe
// carrying the label's own type classes measures them once.
const LABEL_FONT_PX = 10;
const LABEL_PROBE_FIRST_CODE = 0x20;
const LABEL_PROBE_LAST_CODE = 0x7e;
const LABEL_PROBE_REPEAT = 16;
interface LabelGlyphAdvances {
// Semibold; a digit takes the wider of its proportional and tabular advance.
value: readonly number[];
// Normal weight.
detail: readonly number[];
}
// Advances in px for U+0020..U+007E as the probe reads them in Chrome with the
// macOS system font (2026-10-03). They stand in wherever layout is
// unavailable, such as unit tests.
const FALLBACK_LABEL_GLYPH_ADVANCES: LabelGlyphAdvances = {
value: [
2.78, 3.54, 5.51, 6.7, 6.7, 10.11, 7.46, 3.43, 4.25, 4.25, 4.92, 6.7, 3.43, 4.92, 3.43, 3.37,
6.79, 6.7, 6.7, 6.7, 6.85, 6.7, 6.8, 6.7, 6.88, 6.8, 3.43, 3.43, 6.7, 6.7, 6.7, 5.55, 9.36,
7.25, 6.93, 7.45, 7.52, 6.25, 6.01, 7.68, 7.82, 3.13, 5.92, 7.05, 5.98, 9.08, 7.71, 7.93, 6.73,
7.93, 6.93, 6.75, 6.64, 7.65, 7.18, 10.08, 7.25, 7.03, 6.83, 4.25, 3.37, 4.25, 6.7, 5.88, 5.12,
5.89, 6.51, 5.9, 6.51, 6.03, 4.08, 6.46, 6.3, 2.86, 2.86, 5.94, 2.93, 9.24, 6.25, 6.22, 6.47,
6.47, 4.35, 5.55, 4.11, 6.25, 5.82, 8.35, 5.74, 5.91, 5.69, 4.25, 2.98, 4.25, 6.7,
],
detail: [
2.93, 3.23, 4.9, 6.42, 6.42, 9.38, 7.24, 3.09, 3.94, 3.94, 4.84, 6.42, 3.09, 4.84, 3.09, 3.17,
6.42, 4.76, 6.16, 6.39, 6.56, 6.3, 6.49, 6, 6.51, 6.49, 3.09, 3.09, 6.42, 6.42, 6.42, 5.25, 9.3,
6.86, 6.69, 7.28, 7.39, 6.08, 5.84, 7.59, 7.54, 2.8, 5.5, 6.71, 5.8, 8.86, 7.54, 7.84, 6.47,
7.84, 6.66, 6.49, 6.46, 7.5, 6.86, 9.8, 6.91, 6.67, 6.74, 3.94, 3.17, 3.94, 6.42, 5.59, 5.12,
5.64, 6.26, 5.72, 6.26, 5.84, 3.74, 6.22, 6.01, 2.59, 2.59, 5.55, 2.65, 8.82, 5.96, 6.03, 6.23,
6.22, 4.02, 5.27, 3.75, 5.96, 5.54, 7.87, 5.37, 5.55, 5.51, 3.94, 2.71, 3.94, 6.42,
],
};
let labelGlyphAdvances: LabelGlyphAdvances | undefined;
let labelGlyphAdvancesPixelRatio = 0;
function measureLabelGlyphAdvances(): LabelGlyphAdvances | null {
if (typeof document === 'undefined' || !document.body) return null;
const probe = document.createElement('div');
probe.setAttribute('aria-hidden', 'true');
probe.className =
'pointer-events-none invisible fixed left-0 top-0 whitespace-pre font-sans text-[10px] leading-none';
const addGroup = (className: string, firstCode: number, lastCode: number) => {
const group = document.createElement('div');
group.className = className;
for (let code = firstCode; code <= lastCode; code++) {
const cell = document.createElement('div');
cell.className = 'w-max';
cell.textContent = String.fromCharCode(code).repeat(LABEL_PROBE_REPEAT);
group.appendChild(cell);
}
probe.appendChild(group);
return group;
};
const semiboldGroup = addGroup('font-semibold', LABEL_PROBE_FIRST_CODE, LABEL_PROBE_LAST_CODE);
const tabularGroup = addGroup('font-semibold tabular-nums', 0x30, 0x39);
const normalGroup = addGroup('font-normal', LABEL_PROBE_FIRST_CODE, LABEL_PROBE_LAST_CODE);
document.body.appendChild(probe);
const read = (group: HTMLElement) =>
Array.from(group.children, (cell) => cell.getBoundingClientRect().width / LABEL_PROBE_REPEAT);
const semibold = read(semiboldGroup);
const tabular = read(tabularGroup);
const normal = read(normalGroup);
probe.remove();
// No layout (jsdom), or a stubbed one that reports every box alike.
const at = (advances: number[], glyph: string) =>
advances[glyph.charCodeAt(0) - LABEL_PROBE_FIRST_CODE];
if (!(at(normal, '.') > 0 && at(normal, 'M') > at(normal, '.'))) return null;
const firstDigit = 0x30 - LABEL_PROBE_FIRST_CODE;
tabular.forEach((advance, digit) => {
semibold[firstDigit + digit] = Math.max(semibold[firstDigit + digit], advance);
});
return { value: semibold, detail: normal };
}
function getLabelGlyphAdvances(): LabelGlyphAdvances {
// Zoom changes how glyph advances round, so the table is per pixel ratio.
const pixelRatio = typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1;
if (!labelGlyphAdvances || pixelRatio !== labelGlyphAdvancesPixelRatio) {
labelGlyphAdvances = measureLabelGlyphAdvances() ?? FALLBACK_LABEL_GLYPH_ADVANCES;
labelGlyphAdvancesPixelRatio = pixelRatio;
}
return labelGlyphAdvances;
}
function sumLabelGlyphAdvances(text: string, advances: readonly number[]): number {
let width = 0;
for (let index = 0; index < text.length; index++) {
// A glyph outside the measured range counts as a full em.
width += advances[text.charCodeAt(index) - LABEL_PROBE_FIRST_CODE] ?? LABEL_FONT_PX;
}
return width;
}
export interface EstimateTextWidthOptions {
/** Text that follows at normal weight, such as ` (265 GB/440 GB)`. */
detail?: string;
/** Label font size in px when it is not the bar label's 10px. */
fontPx?: number;
}
/**
* Estimate rendered text width based on character count.
* Estimate the rendered width in px of a metric bar label: `text` semibold,
* `options.detail` after it at normal weight. The result is the text alone,
* rounded up; callers add the padding their label sits in.
* Used for determining if labels fit inside metric bars.
* ALL text-width estimation MUST use this function.
*/
export function estimateTextWidth(text: string): number {
return text.length * 5.5 + 8;
export function estimateTextWidth(text: string, options: EstimateTextWidthOptions = {}): number {
const advances = getLabelGlyphAdvances();
const width =
sumLabelGlyphAdvances(text, advances.value) +
sumLabelGlyphAdvances(options.detail ?? '', advances.detail);
return Math.ceil((width * (options.fontPx ?? LABEL_FONT_PX)) / LABEL_FONT_PX);
}
/**
+12 -2
View File
@@ -649,7 +649,7 @@ func applyDemoOfflineDockerContainers(containers []models.DockerContainer, now t
finished = finished.Add(-18 * time.Minute)
for i := range containers {
profile := offlineProfiles[i%len(offlineProfiles)]
containers[i].Name = profile.Name
containers[i].Name = demoContainerProfileName(profile.Name, i/len(offlineProfiles))
containers[i].Image = profile.Image
containers[i].Labels = mergeScenarioLabelSet(containers[i].Labels, profile.Tags)
containers[i].State = "exited"
@@ -662,6 +662,16 @@ func applyDemoOfflineDockerContainers(containers []models.DockerContainer, now t
return containers
}
// demoContainerProfileName keeps container names unique per host. Docker
// refuses two containers with one name on a host, so a profile reused for a
// second round takes a numbered suffix instead of producing duplicate rows.
func demoContainerProfileName(name string, round int) string {
if round <= 0 {
return name
}
return fmt.Sprintf("%s-%d", name, round+1)
}
func applyDemoDockerContainerProfiles(
containers []models.DockerContainer,
profiles []demoDockerContainerProfile,
@@ -672,7 +682,7 @@ func applyDemoDockerContainerProfiles(
})
for i := range containers {
profile := profiles[i%len(profiles)]
containers[i].Name = profile.Name
containers[i].Name = demoContainerProfileName(profile.Name, i/len(profiles))
containers[i].Image = profile.Image
containers[i].Labels = mergeScenarioLabelSet(containers[i].Labels, profile.Tags)
containers[i].State = "running"
+24
View File
@@ -987,3 +987,27 @@ func demoProxmoxNodeIsOffline(graph FixtureGraph, nodeName string) bool {
}
return false
}
func TestDemoDockerContainerNamesAreUniquePerHost(t *testing.T) {
cfg := DefaultConfig
cfg.DockerHostCount = 3
cfg.DockerContainersPerHost = 8
graph := buildFixtureGraph(cfg, time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC))
// Docker refuses two containers with one name on a host. The demo used to
// reuse a curated profile for a second round and show two "customer-portal"
// rows per host, which also doubled every image's "used by" list.
for _, host := range graph.State.DockerHosts {
seen := map[string]struct{}{}
for _, container := range host.Containers {
if _, dup := seen[container.Name]; dup {
t.Fatalf("host %s names two containers %q", host.Hostname, container.Name)
}
seen[container.Name] = struct{}{}
}
}
if !dockerContainerNameExists(graph, "customer-portal") {
t.Fatal("expected the first round of curated names to stay unchanged")
}
}
+50 -38
View File
@@ -5197,50 +5197,62 @@ func generateStorage(nodes []models.Node) []models.Storage {
}
sort.Strings(clusterInstances)
// Every node reports the cluster's two shared PBS storage definitions. This
// stays linear in fleet size: the former one-storage-per-target-node shape
// grew quadratically and made a large demo look busy for the wrong reason.
// Each cluster has two shared PBS storage definitions. Every node reports
// them to the API, but the poller merges a shared storage into one
// cluster-scoped entry (issue 1049), so the fixture carries that merged
// shape: one row per store with the reporting nodes listed, never one row
// per node with a different usage figure on each.
for _, clusterInstance := range clusterInstances {
clusterNodes := clusterNodesByInstance[clusterInstance]
sort.Slice(clusterNodes, func(i, j int) bool { return clusterNodes[i].Name < clusterNodes[j].Name })
nodeNames := make([]string, 0, len(clusterNodes))
nodeIDs := make([]string, 0, len(clusterNodes))
anyNodeOnline := false
for _, node := range clusterNodes {
for storeIndex, storeName := range []string{"pbs-primary", "pbs-offsite"} {
pbsTotal := int64(48+storeIndex*16) * 1024 * 1024 * 1024 * 1024
pbsID := fmt.Sprintf("%s-%s-%s", node.Instance, node.Name, storeName)
pbsUsage := SampleMetric("pbsDatastore", pbsID, "usage", now)
pbsUsed := int64(float64(pbsTotal) * (pbsUsage / 100.0))
isOffline := node.Status != "online" || node.ConnectionHealth == "offline" || node.Uptime <= 0
status := "available"
enabled := true
active := true
lastSeen := now
if isOffline {
status = "offline"
enabled = false
active = false
pbsUsed = 0
pbsUsage = 0
lastSeen = now.Add(-10 * time.Minute)
}
storage = append(storage, models.Storage{
ID: pbsID,
Name: storeName,
Node: node.Name,
Instance: node.Instance,
Type: "pbs",
Status: status,
Total: pbsTotal,
Used: pbsUsed,
Free: pbsTotal - pbsUsed,
Usage: pbsUsage,
Content: "backup",
Shared: true,
Enabled: enabled,
Active: active,
LastSeen: lastSeen,
})
nodeNames = append(nodeNames, node.Name)
nodeIDs = append(nodeIDs, fmt.Sprintf("%s-%s", node.Instance, node.Name))
if node.Status == "online" && node.ConnectionHealth != "offline" && node.Uptime > 0 {
anyNodeOnline = true
}
}
for storeIndex, storeName := range []string{"pbs-primary", "pbs-offsite"} {
pbsTotal := int64(48+storeIndex*16) * 1024 * 1024 * 1024 * 1024
pbsID := fmt.Sprintf("%s-cluster-%s", clusterInstance, storeName)
pbsUsage := SampleMetric("pbsDatastore", pbsID, "usage", now)
pbsUsed := int64(float64(pbsTotal) * (pbsUsage / 100.0))
status := "available"
enabled := true
active := true
lastSeen := now
if !anyNodeOnline {
status = "offline"
enabled = false
active = false
pbsUsed = 0
pbsUsage = 0
lastSeen = now.Add(-10 * time.Minute)
}
storage = append(storage, models.Storage{
ID: pbsID,
Name: storeName,
Node: "cluster",
Nodes: append([]string(nil), nodeNames...),
NodeIDs: append([]string(nil), nodeIDs...),
NodeCount: len(nodeNames),
Instance: clusterInstance,
Type: "pbs",
Status: status,
Total: pbsTotal,
Used: pbsUsed,
Free: pbsTotal - pbsUsed,
Usage: pbsUsage,
Content: "backup",
Shared: true,
Enabled: enabled,
Active: active,
LastSeen: lastSeen,
})
}
sharedTotal := int64(20 * 1024 * 1024 * 1024 * 1024)
sharedID := fmt.Sprintf("%s-shared-storage", clusterInstance)
+31
View File
@@ -1291,3 +1291,34 @@ func TestRebaseMockReplicationJobsLeavesUnchangedJobsAlone(t *testing.T) {
t.Fatalf("expected at most one job to roll over in one second, %d of %d stayed put", unchanged, len(jobs))
}
}
func TestBuildFixtureStateMergesSharedPBSStorageLikeThePoller(t *testing.T) {
cfg := DefaultConfig
cfg.NodeCount = 5
data := buildFixtureState(cfg)
// Issue 1049: the poller folds a shared storage reported by every node
// into one cluster-scoped row. The fixture must not fan it back out into
// one row per node, each with its own usage figure.
seen := map[string]models.Storage{}
for _, storage := range data.Storage {
if storage.Type != "pbs" {
continue
}
key := storage.Instance + "/" + storage.Name
if _, dup := seen[key]; dup {
t.Fatalf("shared PBS storage %s appears more than once", key)
}
seen[key] = storage
if storage.Node != "cluster" || storage.NodeCount < 2 || len(storage.Nodes) != storage.NodeCount {
t.Fatalf("shared PBS storage %s is not cluster-scoped: node=%q nodes=%v count=%d", key, storage.Node, storage.Nodes, storage.NodeCount)
}
if !strings.HasSuffix(storage.ID, "-cluster-"+storage.Name) {
t.Fatalf("shared PBS storage %s keeps a node-scoped id %q", key, storage.ID)
}
}
if len(seen) == 0 {
t.Fatal("expected shared PBS storage in the fixture")
}
}
+5 -1
View File
@@ -1563,7 +1563,11 @@ func resourceFromPBSDatastore(instance models.PBSInstance, datastore models.PBSD
name := strings.TrimSpace(datastore.Name)
assessment := storagehealth.AssessPBSDatastore(datastore)
risk := storageRiskFromAssessment(assessment)
status := storageStatus(statusFromString(datastore.Status), risk)
// The PBS poller reports datastores as "available"/"unavailable", the same
// storage vocabulary PVE uses; the generic string mapper does not know it
// and left every healthy datastore "unknown".
datastoreStatus, _ := statusFromStorageState(datastore.Status)
status := storageStatus(datastoreStatus, risk)
incidents := incidentsFromAssessment("pulse", string(SourcePBS), "pbs-datastore:"+name, assessment, instance.LastSeen)
status = incidentsStatus(status, incidents)
@@ -1242,6 +1242,28 @@ func TestResourceFromPBSDatastoreDerivesRiskAndIncidents(t *testing.T) {
}
}
func TestResourceFromPBSDatastoreReadsStorageStatusVocabulary(t *testing.T) {
instance := models.PBSInstance{ID: "pbs-1", Name: "pbs-main", LastSeen: time.Now().UTC()}
// The poller stamps "available" when PBS reports no error and
// "unavailable" when it does; neither is in the generic status mapper.
cases := []struct {
status string
want ResourceStatus
}{
{status: "available", want: StatusOnline},
{status: "unavailable", want: StatusOffline},
{status: "degraded", want: StatusWarning},
{status: "", want: StatusUnknown},
}
for _, tc := range cases {
datastore := models.PBSDatastore{Name: "backup-store", Status: tc.status, Total: 100, Used: 10}
resource, _ := resourceFromPBSDatastore(instance, datastore)
if resource.Status != tc.want {
t.Fatalf("status %q: Status = %q, want %q", tc.status, resource.Status, tc.want)
}
}
}
func TestResourceFromPBSInstanceRollsUpDatastoreRisk(t *testing.T) {
now := time.Now().UTC()
instance := models.PBSInstance{
@@ -843,3 +843,26 @@ func TestPBSHostHistoryThreeServerTopology(t *testing.T) {
t.Fatalf("three-server topology: services=%d linked hosts=%d linked disks=%d", services, linkedHosts, linkedDisks)
}
}
func TestStatusFromStorageStateCoversPBSAndPVEVocabulary(t *testing.T) {
// PVE storage and PBS datastores both report "available"/"unavailable";
// the mapping has to be shared so neither surface falls back to unknown.
cases := []struct {
state string
want ResourceStatus
known bool
}{
{state: "available", want: StatusOnline, known: true},
{state: "ACTIVE", want: StatusOnline, known: true},
{state: "degraded", want: StatusWarning, known: true},
{state: "unavailable", want: StatusOffline, known: true},
{state: "", want: StatusUnknown, known: false},
{state: "mystery", want: StatusUnknown, known: false},
}
for _, tc := range cases {
got, known := statusFromStorageState(tc.state)
if got != tc.want || known != tc.known {
t.Fatalf("statusFromStorageState(%q) = (%q, %v), want (%q, %v)", tc.state, got, known, tc.want, tc.known)
}
}
}
+16 -5
View File
@@ -32,14 +32,25 @@ func statusFromGuest(status string) ResourceStatus {
}
}
func statusFromStorage(storage models.Storage) ResourceStatus {
switch strings.ToLower(strings.TrimSpace(storage.Status)) {
// statusFromStorageState maps the storage-native status vocabulary, which
// includes the "available"/"unavailable" pair PVE storage and PBS datastores
// report, onto a resource status. The second result is false when the string
// is not a storage state the caller can act on.
func statusFromStorageState(status string) (ResourceStatus, bool) {
switch strings.ToLower(strings.TrimSpace(status)) {
case "online", "running", "available", "active", "ok":
return StatusOnline
return StatusOnline, true
case "warning", "degraded":
return StatusWarning
return StatusWarning, true
case "offline", "down", "unavailable", "error":
return StatusOffline
return StatusOffline, true
}
return StatusUnknown, false
}
func statusFromStorage(storage models.Storage) ResourceStatus {
if status, ok := statusFromStorageState(storage.Status); ok {
return status
}
if !storage.Active {
return StatusOffline