mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
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:
@@ -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"
|
||||
]
|
||||
}
|
||||
+37
-9
@@ -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);
|
||||
|
||||
+68
-5
@@ -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 && ).
|
||||
|
||||
+40
@@ -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,
|
||||
|
||||
+11
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user