mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Integrate reviewed truthful guest History presentation
Preserve the exact reviewed Web commits and their own-parent browser receipts alongside the supplied main frontier. Change-source: pulse-maintainer
This commit is contained in:
@@ -20,6 +20,29 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
All drawer History fallbacks must distinguish current observations from retained, unavailable or freshness-unknown guest readings, using original memory evidence and filesystem read reasons.
|
||||
|
||||
### Canonical drawer History preserves guest read provenance
|
||||
|
||||
The shared resource drawer passes selected memory observation state/source/time
|
||||
and Proxmox VM disk-status reasons into the existing History deferred-reading
|
||||
path. Last-known values remain labelled evidence; unavailable numeric carriers
|
||||
never become current values. Unannotated Proxmox VM/LXC memory and unusable
|
||||
current timestamps are freshness-unknown. Non-Proxmox unannotated snapshots,
|
||||
independently current CPU/network/memory and stored observations stay unchanged.
|
||||
The memory presentation policy is shared with the dedicated guest drawer in
|
||||
`memoryObservation.ts`; it never borrows Last seen, a disk deferral or a snapshot
|
||||
refresh time. Unknown source/state text is not echoed.
|
||||
|
||||
`resourceDetailDrawerMetricsHistoryModel.branchcov0712.test.ts` pins both memory
|
||||
and filesystem paths, including invalid numbers/dates, zero, every deferral and
|
||||
unrelated platforms. `ResourceDetailDrawer.history.test.tsx` exercises the full
|
||||
reactive drawer with the real History renderer; `GuestDrawer.test.tsx` preserves
|
||||
the dedicated path. `browser-tests/resource-history-provenance.cjs` verifies
|
||||
these production components and clients with synthetic observations on desktop
|
||||
and touch WebKit. These are presentation checks, not independent thaw, native
|
||||
filesystem writes, safe service resumption or release/installed acceptance.
|
||||
|
||||
### Observed CPU labels preserve small positives
|
||||
|
||||
Shared responsive CPU cells distinguish actual zero from small positive readings:
|
||||
@@ -140,7 +163,6 @@ readable unmatched explanation and same-resource recovery. No native backup
|
||||
identity, restore, guest thaw or containing-release acceptance follows from
|
||||
these source/render observations.
|
||||
|
||||
|
||||
### Diagnostics are live checks; downloads require a privacy review
|
||||
|
||||
The existing Settings Diagnostics panel explains before either run action that
|
||||
@@ -373,7 +395,6 @@ or touch retry, source isolation and actual new organisation headers. Synthetic
|
||||
HTTP results establish frontend truthfulness/lifecycle, not native PBS/PVE
|
||||
permission, archive restorability, guest thaw or released availability.
|
||||
|
||||
|
||||
### Composed operator tables and conservative rollback consent
|
||||
|
||||
Phone storage tables use the canonical weighted-width helper and matching
|
||||
@@ -403,7 +424,6 @@ controller/storage/backup/workload tests and production-CSS browser fixture
|
||||
source at desktop, intermediate and phone widths. Synthetic HTTP/snapshots do
|
||||
not establish native collectors, tenancy, installation recovery or release delivery.
|
||||
|
||||
|
||||
### Organisation-scoped single-metric History
|
||||
|
||||
Fetched HistoryChart observations belong to the active organisation as well as
|
||||
@@ -996,7 +1016,6 @@ display label or permission to describe every preview as release-ready.
|
||||
Automatic stable updates remain unavailable while Preview is selected, and
|
||||
that manual-channel consequence must stay visible at desktop and narrow widths.
|
||||
|
||||
|
||||
### Backup date uncertainty uses existing age cells
|
||||
|
||||
Proxmox Coverage and By date age cells label unorderable restore-point dates
|
||||
@@ -1210,7 +1229,6 @@ Docker table tests and the desktop/touch-phone production-component browser
|
||||
fixture check these priorities, rather than treating an old percentage as an
|
||||
immutable primitive contract. No new public route or navigation entry is added.
|
||||
|
||||
|
||||
### Maintenance schedule copy
|
||||
|
||||
Operator maintenance banners show local absolute start and end timestamps,
|
||||
@@ -1241,7 +1259,6 @@ renderers with an edited input retained across overlapping windows. The browser
|
||||
journey is a narrow Proxmox node Manage form, scrolling to lifecycle Save while
|
||||
the synthetic estate continues updating, plus the alert timeline and note form.
|
||||
|
||||
|
||||
### PBS host history correlation
|
||||
|
||||
The Backups surface passes its complete deduplicated route inventory to the PBS table. Only PBS servers render as rows; other resources supply correlation inputs. Preserve PBS drawer identity and use the correlated canonical history target. Missing disk utilisation does not gate CPU/memory history.
|
||||
@@ -4070,7 +4087,6 @@ Cached update responses remain valid without a release date. Browser qualificati
|
||||
covers the current settings route, pending/retry states, keyboard checks and date
|
||||
presentation at desktop, intermediate and narrow widths.
|
||||
|
||||
|
||||
### Provider tabs use compact canonical evidence during route hydration
|
||||
|
||||
Evidence-gated provider tabs consume the unified-resource owner's
|
||||
|
||||
@@ -15,6 +15,18 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Drawer observation labels derive locally from the selected snapshot without renewing timestamps, adding reads, remounting charts or changing stored metric coordinates.
|
||||
|
||||
### Drawer provenance stays local to snapshot presentation
|
||||
|
||||
Guest and canonical resource drawer memory presentation share the same pure
|
||||
original-observation policy. Same-ID state replacement updates current versus
|
||||
retained/unknown fallback labels without remounting a chart, changing coordinates,
|
||||
adding reads or plotting live numbers as stored observations. The full
|
||||
`GuestDrawer.test.tsx` and `ResourceDetailDrawer.history.test.tsx` controls assert
|
||||
unchanged single-read budgets through retained-to-current replacement and zero
|
||||
preservation. This changes no estate collection, timing target or cache policy.
|
||||
|
||||
### Bulk resource reads do not repeatedly derive canonical metadata
|
||||
|
||||
Registry List and typed-view copies materialize identity/scopes/policy once
|
||||
@@ -33,7 +45,6 @@ moving that work into the registry is not proof that total ingest or broadcast
|
||||
CPU improved. Native #2199 CPU/RSS, trailing refresh/duplicate ingest identity,
|
||||
owner-set grouping, broadcast conversion and the sawtooth remain separate.
|
||||
|
||||
|
||||
### Agent identity lookup cost follows identity-relevant inventory
|
||||
|
||||
Report admission no longer clones/ingests twenty explicitly listed Kubernetes
|
||||
@@ -51,7 +62,6 @@ compared without selecting a favourable rerun. This is a bounded source cost
|
||||
improvement, not attribution of the reporter's native CPU, RSS, sawtooth or
|
||||
remaining refresh/grouping/broadcast mechanisms.
|
||||
|
||||
|
||||
### Memory presentation does not collect or renew observations
|
||||
|
||||
Guest-memory provenance is projected from the already-owned snapshot in the
|
||||
@@ -149,7 +159,6 @@ clearing, false/zero values, unchanged peer-site rows and full/fast equivalence.
|
||||
merges, row, Overview and History with synthetic inputs. This is no native
|
||||
QGA/thaw, installed recovery, fleet-performance or release-availability claim.
|
||||
|
||||
|
||||
### Unavailable filesystem values share the existing disk model
|
||||
|
||||
The filesystem list and guest Overview use the same nullable measured-byte ratio
|
||||
@@ -218,7 +227,6 @@ ownership remain unchanged. Guest column regressions verify the sums and the
|
||||
production composed-table browser fixture checks identity and all configured
|
||||
metric tracks at narrow width; this is readability, not a throughput claim.
|
||||
|
||||
|
||||
### Bounded confidence/priority work in broadcast grouping — issue #2199
|
||||
|
||||
The general identity matcher retains every confidence, reason, ordering and
|
||||
@@ -1754,7 +1762,6 @@ final measurements and host-load limitation remain recorded in
|
||||
`records/resource-payload-static-metadata-2026-08-24.md`. They do not establish
|
||||
a controlled performance improvement or satisfy the open SLO qualification.
|
||||
|
||||
|
||||
### Large API responses negotiate gzip without corrupting edge cases
|
||||
|
||||
The main listener compresses eligible JSON and frontend text assets for
|
||||
@@ -3551,7 +3558,6 @@ The signal survives transport loss and resets on organisation URL changes.
|
||||
On admission-request failure retain the existing facet without extra retries,
|
||||
polling or full-estate reads. A later successful refresh replaces that facet.
|
||||
|
||||
|
||||
### ASCII route-label classification
|
||||
|
||||
HTTP metric route normalization classifies numeric IDs and UUID-like labels
|
||||
|
||||
@@ -23,6 +23,26 @@ and sort the complete canonical change table while startup and ingestion wait.
|
||||
|
||||
## Purpose
|
||||
|
||||
Canonical resource History honours selected memory observation provenance and Proxmox VM filesystem deferrals instead of treating numeric carriers as fresh measurements.
|
||||
|
||||
### Canonical resource History cannot promote retained guest readings
|
||||
|
||||
`ResourceMetric.current` is a selected numeric carrier, not a freshness
|
||||
assertion. Canonical drawer History consumes its existing memory observation
|
||||
and Proxmox VM filesystem read reason as independent evidence, just as the
|
||||
dedicated guest drawer does. Current memory requires a usable original time
|
||||
when annotated; last-known/unknown/unavailable evidence has explicit labels.
|
||||
A VM read deferral withdraws current disk fallback, retaining a number only
|
||||
for the existing `prev-` reason vocabulary. LXC disk usage is not a QEMU
|
||||
filesystem read. Other platform/catalog metrics and capacity aliases stay
|
||||
unchanged. Same-ID replacement updates this presentation without remounting
|
||||
History or requesting another read. Clearing deferral is not native recovery.
|
||||
|
||||
The full drawer regression in `ResourceDetailDrawer.history.test.tsx` covers
|
||||
retention, unavailable carriers, original timestamps, measured zero and recovery
|
||||
without invented chart points; model and browser controls retain source/access
|
||||
isolation. No wire schema, collector, alert, request identity or authority changes.
|
||||
|
||||
### Bulk registry copies reuse mutation-bound canonical metadata
|
||||
|
||||
`ResourceRegistry.List` and typed-view rebuilding share canonical identity,
|
||||
@@ -49,7 +69,6 @@ lazy views, races and allocation cost. The external connected demo test includes
|
||||
all nine providers and its metadata-heavy estate. These are source invariants,
|
||||
not native fleet CPU/RSS relief, backup recovery or release availability.
|
||||
|
||||
|
||||
### Container CPU presentation explains its existing scale
|
||||
|
||||
The existing Docker/Podman container table and drawer visibly explain that CPU
|
||||
@@ -199,7 +218,6 @@ measured zero, partial omissions and full/delta/fast recovery. The connected
|
||||
and History presentation in desktop Chromium and phone WebKit. Synthetic
|
||||
source proof does not establish native backup safety or shipped availability.
|
||||
|
||||
|
||||
### Backup server counts retain their inventory availability
|
||||
|
||||
The existing PBS server/datastore row takes backup-inventory read availability
|
||||
@@ -231,7 +249,6 @@ The shared presentation and mounted regressions plus the production-component
|
||||
`browser-tests/guest-disk-deferral.cjs` fixture verify this client behaviour;
|
||||
they do not prove native QGA coordination, History freshness or delivery.
|
||||
|
||||
|
||||
### Narrow controller and backup summaries retain their source facts
|
||||
|
||||
Kubernetes controller row expansions expose supplied kind-specific targets,
|
||||
@@ -248,7 +265,6 @@ hover and search. The recovery-model, table and guest regressions plus desktop/
|
||||
phone browser rendering verify attribution, visible facts and source composition.
|
||||
These frontend improvements change no collector, API authority or native status.
|
||||
|
||||
|
||||
### Resource evidence fallback access boundaries
|
||||
|
||||
When the existing resource-facet reader receives a final HTTP 401/403, its
|
||||
@@ -437,7 +453,6 @@ read-only incident memory association. It does not rewrite event resource IDs,
|
||||
resource operator state, approvals, action request identity or execution
|
||||
capabilities. An unavailable canonical read remains an error.
|
||||
|
||||
|
||||
### Canonical Patrol and Assistant continuation, 2026-09-07
|
||||
|
||||
Persisted action request identity is scoped to the trusted actor and exact
|
||||
@@ -662,7 +677,6 @@ distinction afterwards. The unified risk projection gates wearout on
|
||||
`storagehealth.WearoutReported` so its verdict and the server-side alert verdict
|
||||
about the same disk cannot diverge.
|
||||
|
||||
|
||||
### Coverage rows preserve unknown backup chronology
|
||||
|
||||
The existing Proxmox backup Coverage projection carries unknown-date flags
|
||||
@@ -875,7 +889,6 @@ timestamp retained. Native table/model/guardrail tests and the production
|
||||
`browser-tests/proxmox-docker-rows.cjs` exercise these surfaces without executing
|
||||
a container action or claiming native appliance or release acceptance.
|
||||
|
||||
|
||||
### PBS host history correlation
|
||||
|
||||
PBS presentation correlation accepts standalone agents and VM/system-container resources with an agent facet. Preserve unique identity matching and canonical metricsTarget type and ID. Guests without telemetry and mixed ambiguous agent/guest matches must not replace the PBS target.
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
// Fresh combined-content proof, not native Podman or alert-delivery acceptance.
|
||||
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/docker-composed-20261005-controls';
|
||||
const origin = 'http://127.0.0.1:5341';
|
||||
const runtimePath = 'frontend-modern/src/features/docker/DockerContainersTable.tsx';
|
||||
const expectedHash = '85753788c2e982dcedbab9aed99ff74a1f1623ec35bc7886ab25661e86de2dd9';
|
||||
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
|
||||
(async () => {
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
assert.equal(hash('/workspace/' + runtimePath), expectedHash);
|
||||
const result = {
|
||||
result: 'incomplete',
|
||||
playwright: require('playwright/package.json').version,
|
||||
content_sha256: { [runtimePath]: expectedHash },
|
||||
cases: [],
|
||||
screenshots: [],
|
||||
limits:
|
||||
'Production table, drawer and CSS with selected synthetic resources/APIs. Emulated phone, not a native phone. No collector, engine, threshold duration, notification, installed or release acceptance.',
|
||||
};
|
||||
assert.equal(
|
||||
result.playwright,
|
||||
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
|
||||
'node_modules/@playwright/test'
|
||||
].version,
|
||||
);
|
||||
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: output + '/cache',
|
||||
optimizeDeps: { noDiscovery: true, include: [] },
|
||||
server: { host: '127.0.0.1', port: 5341, strictPort: true, watch: null },
|
||||
});
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [name, engine, width, dark] of [
|
||||
['desktop-light', chromium, 1440, false],
|
||||
['desktop-dark', chromium, 1440, true],
|
||||
['phone-light', webkit, 390, false],
|
||||
['phone-dark', webkit, 390, true],
|
||||
]) {
|
||||
browser = await engine.launch(
|
||||
engine === chromium
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true },
|
||||
);
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height: width < 768 ? 844 : 900 },
|
||||
isMobile: width < 768,
|
||||
hasTouch: width < 768,
|
||||
locale: 'en-GB',
|
||||
timezoneId: 'UTC',
|
||||
});
|
||||
page.setDefaultTimeout(12000);
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
const record = {
|
||||
name,
|
||||
width,
|
||||
dark,
|
||||
browser: browser.version(),
|
||||
errors: [],
|
||||
requests: [],
|
||||
checks: [],
|
||||
};
|
||||
result.cases.push(record);
|
||||
page.on('pageerror', (error) => record.errors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') record.errors.push(message.text());
|
||||
});
|
||||
await page.routeWebSocket('**/*', (socket) => {
|
||||
if (socket.url().startsWith('ws://127.0.0.1:5341/')) socket.connectToServer();
|
||||
else {
|
||||
record.requests.push({ blocked: true, socket: socket.url() });
|
||||
socket.close();
|
||||
}
|
||||
});
|
||||
await page.route('**/*', (route) => {
|
||||
const request = route.request(),
|
||||
url = new URL(request.url());
|
||||
if (url.origin !== origin || request.method() !== 'GET') {
|
||||
record.requests.push({ blocked: true, url: request.url(), method: request.method() });
|
||||
return route.abort();
|
||||
}
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
record.requests.push({ path: url.pathname, method: request.method() });
|
||||
return route.fulfill({
|
||||
json: {
|
||||
success: true,
|
||||
data: [],
|
||||
alerts: [],
|
||||
anomalies: [],
|
||||
config: null,
|
||||
capabilities: [],
|
||||
relationships: [],
|
||||
recentChanges: [],
|
||||
available: false,
|
||||
audits: [],
|
||||
count: 0,
|
||||
blocked_capabilities: [],
|
||||
limits: [],
|
||||
max_history_days: 90,
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.addInitScript((dark) => {
|
||||
const apply = () => document.documentElement.classList.toggle('dark', dark);
|
||||
if (document.documentElement) apply();
|
||||
else document.addEventListener('DOMContentLoaded', apply, { once: true });
|
||||
window.__inputs = [];
|
||||
for (const type of ['click', 'keydown'])
|
||||
document.addEventListener(
|
||||
type,
|
||||
(event) => window.__inputs.push({ type, trusted: event.isTrusted }),
|
||||
true,
|
||||
);
|
||||
}, dark);
|
||||
await page.goto(origin + '/browser-tests/docker-composed.html', { waitUntil: 'networkidle' });
|
||||
const activate = async (locator) => {
|
||||
if (width < 768) await locator.tap();
|
||||
else {
|
||||
await locator.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
};
|
||||
const table = page.getByRole('table');
|
||||
const cpuCell = async (id) => {
|
||||
const index = await table
|
||||
.locator('thead th')
|
||||
.evaluateAll((headers) =>
|
||||
headers.findIndex((header) => header.textContent.trim() === 'CPU'),
|
||||
);
|
||||
assert.ok(index >= 0, JSON.stringify(await table.locator('thead th').allTextContents()));
|
||||
return page.locator(`[data-docker-container-row="${id}"]`).getByRole('cell').nth(index);
|
||||
};
|
||||
const labels = async () => {
|
||||
for (const [id, text] of [
|
||||
['small-positive', '0.3%'],
|
||||
['measured-zero', '0%'],
|
||||
['tiny-positive', '<0.1%'],
|
||||
]) {
|
||||
const cell = await cpuCell(id);
|
||||
await cell.getByText(text, { exact: true }).waitFor();
|
||||
}
|
||||
};
|
||||
const screenshot = async (state) => {
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
await new Promise((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
||||
);
|
||||
});
|
||||
assert.ok(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1),
|
||||
'No document overflow',
|
||||
);
|
||||
const file = `${name}-${state}.png`;
|
||||
await page.screenshot({ path: path.join(output, file), fullPage: true });
|
||||
result.screenshots.push({ file, name, state, sha256: hash(path.join(output, file)) });
|
||||
};
|
||||
await page
|
||||
.locator('[data-docker-host-group="edge-a"] [data-docker-host-group-stale]')
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page.locator('[data-docker-host-group-stale]').innerText(),
|
||||
'No report for 9m',
|
||||
);
|
||||
assert.match(
|
||||
await page.locator('[data-docker-host-group-stale]').getAttribute('title'),
|
||||
/last values received/,
|
||||
);
|
||||
assert.equal(
|
||||
await table
|
||||
.locator('thead th')
|
||||
.filter({ hasText: /^Host$/ })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
await table
|
||||
.locator('thead th')
|
||||
.filter({ hasText: /^Engine$/ })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
assert.match(
|
||||
await page.locator('[data-docker-host-group="edge-a"]').innerText(),
|
||||
/docker fixture/,
|
||||
);
|
||||
assert.match(
|
||||
await page.locator('[data-docker-host-group="edge-b"]').innerText(),
|
||||
/podman fixture/,
|
||||
);
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('[data-docker-host-group="edge-b"] [data-docker-host-group-stale]')
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
await labels();
|
||||
const guidance = page.getByTestId('container-table-cpu-scale');
|
||||
assert.match(await guidance.innerText(), /100% means all host CPUs.*per-core scale/);
|
||||
await screenshot('grouped-with-hosts');
|
||||
record.checks.push(
|
||||
'Both fractional labels and shared host/engine headings survive composition; only the silent host is qualified.',
|
||||
);
|
||||
await activate(page.getByRole('button', { name: 'Remove host context', exact: true }));
|
||||
assert.equal(await page.locator('[data-docker-host-group-stale]').count(), 0);
|
||||
assert.match(
|
||||
await page.locator('[data-docker-host-group="edge-a"]').innerText(),
|
||||
/docker fixture/,
|
||||
);
|
||||
await labels();
|
||||
await screenshot('grouped-without-hosts');
|
||||
record.checks.push(
|
||||
'Missing host context removes stale/status claims, keeps container engine fallback, fractions and scale guidance.',
|
||||
);
|
||||
await activate(page.getByRole('button', { name: 'Restore host context', exact: true }));
|
||||
if (width < 768) await activate(page.getByRole('button', { name: 'Filters', exact: true }));
|
||||
await activate(page.getByRole('button', { name: 'View', exact: true }));
|
||||
await activate(page.getByRole('button', { name: 'List', exact: true }));
|
||||
await page.keyboard.press('Escape');
|
||||
assert.equal(await page.locator('[data-docker-host-group]').count(), 0);
|
||||
if (width >= 1024) {
|
||||
assert.equal(
|
||||
await table
|
||||
.locator('thead th')
|
||||
.filter({ hasText: /^Host$/ })
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
await table
|
||||
.locator('thead th')
|
||||
.filter({ hasText: /^Engine$/ })
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
}
|
||||
await labels();
|
||||
const containerRow = page.locator('[data-docker-container-row="small-positive"]');
|
||||
// On phones the production disclosure button is intentionally hidden;
|
||||
// use its existing whole-row pointer convenience, not a forced click.
|
||||
if (width < 768) await containerRow.tap();
|
||||
else await activate(containerRow.getByRole('button').first());
|
||||
const detail = page.locator('[data-inline-platform-resource-detail-for="small-positive"]');
|
||||
await detail.waitFor();
|
||||
assert.match(
|
||||
await detail.getByTestId('container-drawer-cpu-scale').innerText(),
|
||||
/total host capacity/,
|
||||
);
|
||||
await screenshot('flat-expanded');
|
||||
await activate(
|
||||
detail.getByRole('button', { name: 'Collapse small-positive details', exact: true }),
|
||||
);
|
||||
await detail.waitFor({ state: 'detached' });
|
||||
record.checks.push(
|
||||
'Flat layout retains CPU labels; trusted expansion/collapse shows the same capacity guidance in the real drawer.',
|
||||
);
|
||||
assert.deepEqual(record.errors, []);
|
||||
assert.ok(!record.requests.some((request) => request.blocked));
|
||||
record.trustedInputs = await page.evaluate(() => window.__inputs);
|
||||
assert.ok(record.trustedInputs.some((event) => event.trusted));
|
||||
await browser.close();
|
||||
browser = undefined;
|
||||
}
|
||||
result.result = 'passed';
|
||||
result.verified_at = new Date().toISOString();
|
||||
} catch (error) {
|
||||
result.error = String(error.stack || error);
|
||||
const failedPage = browser?.contexts()[0]?.pages()[0];
|
||||
if (failedPage) {
|
||||
result.failure_headers = await failedPage.locator('thead th').allTextContents();
|
||||
result.failure_body = (await failedPage.locator('body').innerText()).slice(0, 5000);
|
||||
await failedPage.screenshot({ path: output + '/failure.png', fullPage: true });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
result.browser_closed = true;
|
||||
result.server_closed = true;
|
||||
fs.writeFileSync(output + '/result.json', JSON.stringify(result, null, 2) + '\n');
|
||||
}
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
result: result.result,
|
||||
states: result.cases.length,
|
||||
screenshots: result.screenshots.length,
|
||||
resultPath: output + '/result.json',
|
||||
}) + '\n',
|
||||
);
|
||||
})().catch((error) => {
|
||||
process.stderr.write(String(error.stack || error) + '\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 container presentation proof</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/browser-tests/docker-composed.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,87 @@
|
||||
// Presentation only: selected synthetic resources, real table, drawer and CSS.
|
||||
import { createSignal } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { Router, Route } from '@solidjs/router';
|
||||
import { DockerContainersTable } from '../src/features/docker/DockerContainersTable';
|
||||
import { DarkModeContext } from '../src/contexts/appRuntime';
|
||||
import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
|
||||
import type { Resource } from '../src/types/resource';
|
||||
import '../src/index.css';
|
||||
|
||||
syncAIRuntimeSettings({ discovery_enabled: false });
|
||||
const container = (name: string, host: string, cpu: number, engine = 'docker'): Resource => ({
|
||||
id: name,
|
||||
type: 'app-container',
|
||||
name,
|
||||
platformId: name,
|
||||
platformType: 'docker',
|
||||
sourceType: 'agent',
|
||||
status: 'running',
|
||||
cpu: { current: cpu },
|
||||
memory: { current: 10 },
|
||||
docker: { hostname: host, runtime: engine, runtimeVersion: 'fixture', containerState: 'running' },
|
||||
});
|
||||
const containers = [
|
||||
container('small-positive', 'edge-a', 0.26),
|
||||
container('measured-zero', 'edge-a', 0),
|
||||
container('tiny-positive', 'edge-b', 0.04, 'podman'),
|
||||
];
|
||||
const hosts: Resource[] = [
|
||||
{
|
||||
id: 'host-a',
|
||||
type: 'docker-host',
|
||||
name: 'edge-a',
|
||||
platformId: 'host-a',
|
||||
platformType: 'docker',
|
||||
sourceType: 'agent',
|
||||
status: 'offline',
|
||||
docker: { hostname: 'edge-a', runtime: 'docker', runtimeVersion: 'fixture' },
|
||||
health: {
|
||||
verdict: 'stale',
|
||||
reasons: [{ code: 'telemetry_stale', detail: '9m' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'host-b',
|
||||
type: 'docker-host',
|
||||
name: 'edge-b',
|
||||
platformId: 'host-b',
|
||||
platformType: 'docker',
|
||||
sourceType: 'agent',
|
||||
status: 'online',
|
||||
docker: { hostname: 'edge-b', runtime: 'podman', runtimeVersion: 'fixture' },
|
||||
},
|
||||
];
|
||||
function Fixture() {
|
||||
const [withHosts, setWithHosts] = createSignal(true);
|
||||
return (
|
||||
<DarkModeContext.Provider value={() => document.documentElement.classList.contains('dark')}>
|
||||
<main class="min-h-screen space-y-4 bg-surface p-3 text-base-content">
|
||||
<h1 class="text-lg font-semibold">Composed container presentation</h1>
|
||||
<p class="text-xs text-muted">Synthetic metrics. No container or notification action.</p>
|
||||
<button
|
||||
type="button"
|
||||
class="min-h-11 rounded border border-border px-3 text-xs"
|
||||
onClick={() => setWithHosts(!withHosts())}
|
||||
>
|
||||
{withHosts() ? 'Remove host context' : 'Restore host context'}
|
||||
</button>
|
||||
<DockerContainersTable
|
||||
resources={containers}
|
||||
hosts={withHosts() ? hosts : undefined}
|
||||
emptyIcon={<span />}
|
||||
emptyTitle="No containers"
|
||||
emptyDescription="No containers"
|
||||
/>
|
||||
</main>
|
||||
</DarkModeContext.Provider>
|
||||
);
|
||||
}
|
||||
render(
|
||||
() => (
|
||||
<Router>
|
||||
<Route path="*" component={Fixture} />
|
||||
</Router>
|
||||
),
|
||||
document.getElementById('root')!,
|
||||
);
|
||||
@@ -0,0 +1,400 @@
|
||||
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/history-provenance-20261005-complete/browser';
|
||||
const origin = 'http://127.0.0.1:5317';
|
||||
const runtime = [
|
||||
'frontend-modern/src/utils/memoryObservation.ts',
|
||||
'frontend-modern/src/components/Workloads/guestDrawerModel.ts',
|
||||
'frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts',
|
||||
'frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts',
|
||||
'frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx',
|
||||
];
|
||||
const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
(async () => {
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const result = {
|
||||
result: 'incomplete',
|
||||
playwright: require('playwright/package.json').version,
|
||||
runtime_hashes: Object.fromEntries(runtime.map((p) => [p, hash('/workspace/' + p)])),
|
||||
cases: [],
|
||||
screenshots: [],
|
||||
limits:
|
||||
'Production ResourceDetailDrawer and GuestDrawer, History, client and CSS; synthetic local observations/APIs. Not native guest safety, provider/appliance, installed, reporter or release acceptance.',
|
||||
};
|
||||
assert.equal(
|
||||
result.playwright,
|
||||
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
|
||||
'node_modules/@playwright/test'
|
||||
].version,
|
||||
);
|
||||
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: output + '/cache',
|
||||
optimizeDeps: { noDiscovery: true, include: [] },
|
||||
server: { host: '127.0.0.1', port: 5317, strictPort: true, watch: null },
|
||||
});
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [name, engine, width, dark, guest] of [
|
||||
['resource-desktop-light', chromium, 1365, false, false],
|
||||
['resource-desktop-dark', chromium, 1365, true, false],
|
||||
['resource-phone-dark', webkit, 390, true, false],
|
||||
['resource-phone-light', webkit, 390, false, false],
|
||||
['guest-desktop-light', chromium, 1365, false, true],
|
||||
['guest-desktop-dark', chromium, 1365, true, true],
|
||||
['guest-phone-dark', webkit, 390, true, true],
|
||||
['guest-phone-light', webkit, 390, false, true],
|
||||
]) {
|
||||
browser = await engine.launch(
|
||||
engine === chromium
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true },
|
||||
);
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height: width < 768 ? 844 : 900 },
|
||||
isMobile: width < 768,
|
||||
hasTouch: width < 768,
|
||||
locale: 'en-GB',
|
||||
timezoneId: 'UTC',
|
||||
});
|
||||
page.setDefaultTimeout(12000);
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
const record = {
|
||||
name,
|
||||
width,
|
||||
dark,
|
||||
guest,
|
||||
browser: browser.version(),
|
||||
errors: [],
|
||||
consoleErrors: [],
|
||||
expectedHttpFailures: [],
|
||||
requests: [],
|
||||
checks: [],
|
||||
};
|
||||
result.cases.push(record);
|
||||
let mode = 'empty';
|
||||
const start = Date.parse('2026-10-04T12:00:00Z'),
|
||||
end = Date.parse('2026-10-05T00:00:00Z');
|
||||
const point = (timestamp, value) => ({ timestamp, value, min: value, max: value });
|
||||
page.on('pageerror', (error) => record.errors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error')
|
||||
record.consoleErrors.push({ text: message.text(), url: message.location().url });
|
||||
});
|
||||
await page.routeWebSocket('**/*', (socket) => {
|
||||
if (socket.url().startsWith('ws://127.0.0.1:5317/')) socket.connectToServer();
|
||||
else {
|
||||
record.requests.push({ blocked: true, socket: socket.url() });
|
||||
socket.close();
|
||||
}
|
||||
});
|
||||
await page.route('**/*', (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== origin) {
|
||||
record.requests.push({ blocked: true, origin: url.origin });
|
||||
return route.abort();
|
||||
}
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
assert.equal(route.request().method(), 'GET');
|
||||
record.requests.push({ path: url.pathname, method: route.request().method(), mode });
|
||||
if (url.pathname === '/api/metrics-store/history') {
|
||||
assert.equal(url.searchParams.get('resourceType'), 'vm');
|
||||
assert.equal(url.searchParams.get('resourceId'), 'fixture:pve:101');
|
||||
assert.equal(url.searchParams.get('maxPoints'), '240');
|
||||
if (mode === 'transient' || mode === 'denied') {
|
||||
record.expectedHttpFailures.push({
|
||||
url: url.href,
|
||||
status: mode === 'transient' ? 503 : 403,
|
||||
});
|
||||
return route.fulfill({
|
||||
status: mode === 'transient' ? 503 : 403,
|
||||
json: { error: 'private-error-sentinel' },
|
||||
});
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
resourceType: 'vm',
|
||||
resourceId: 'fixture:pve:101',
|
||||
range: url.searchParams.get('range'),
|
||||
start,
|
||||
end,
|
||||
source: 'store',
|
||||
metrics:
|
||||
mode === 'stored'
|
||||
? {
|
||||
memory: [point(start, 5), point(end, 0)],
|
||||
disk: [point(start, 40), point(end, 45)],
|
||||
}
|
||||
: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
return route.fulfill({
|
||||
json:
|
||||
url.pathname === '/api/license/runtime-capabilities'
|
||||
? {
|
||||
capabilities: [],
|
||||
limits: [],
|
||||
max_history_days: 90,
|
||||
hosted_mode: false,
|
||||
runtime: { build: 'community' },
|
||||
blocked_capabilities: [],
|
||||
}
|
||||
: {
|
||||
success: true,
|
||||
data: [],
|
||||
alerts: [],
|
||||
anomalies: [],
|
||||
config: null,
|
||||
capabilities: [],
|
||||
relationships: [],
|
||||
recentChanges: [],
|
||||
available: false,
|
||||
audits: [],
|
||||
count: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.addInitScript((isDark) => {
|
||||
document.addEventListener('DOMContentLoaded', () =>
|
||||
document.documentElement.classList.toggle('dark', isDark),
|
||||
);
|
||||
window.__inputs = [];
|
||||
for (const type of ['click', 'keydown', 'input'])
|
||||
document.addEventListener(
|
||||
type,
|
||||
(e) => window.__inputs.push({ type, trusted: e.isTrusted }),
|
||||
true,
|
||||
);
|
||||
}, dark);
|
||||
await page.goto(
|
||||
origin + '/browser-tests/resource-history-provenance.html' + (guest ? '?guest' : ''),
|
||||
);
|
||||
const activate = (locator) => (width < 768 ? locator.tap() : locator.click());
|
||||
await activate(page.getByRole('tab', { name: 'History', exact: true }));
|
||||
const chart = page.locator('[data-history-group="utilization"]');
|
||||
await chart.locator('[data-history-last-known="memory"]').waitFor();
|
||||
await page.evaluate(
|
||||
() => (window.__chart = document.querySelector('[data-history-group="utilization"]')),
|
||||
);
|
||||
const screenshot = async (state) => {
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready;
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
});
|
||||
const file = `${name}-${state}.png`;
|
||||
await page.screenshot({ path: path.join(output, file), fullPage: true });
|
||||
result.screenshots.push({ file, sha256: hash(path.join(output, file)), state, name });
|
||||
const geometry = await page.evaluate(() => ({
|
||||
width: document.documentElement.clientWidth,
|
||||
scroll: document.documentElement.scrollWidth,
|
||||
}));
|
||||
assert.ok(geometry.scroll <= geometry.width + 1, JSON.stringify(geometry));
|
||||
};
|
||||
assert.match(
|
||||
await chart.locator('[data-history-last-known="memory"]').innerText(),
|
||||
/25.0%\s*last known/,
|
||||
);
|
||||
assert.match(
|
||||
await chart.locator('[data-history-last-known="disk"]').innerText(),
|
||||
/50.0%\s*last known/,
|
||||
);
|
||||
assert.equal(
|
||||
await chart
|
||||
.locator('[data-history-current="memory"], [data-history-current="disk"]')
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
assert.match(
|
||||
await chart.locator('[data-history-deferred="memory"]').innerText(),
|
||||
/2026-10-04 12:00:00 UTC/,
|
||||
);
|
||||
assert.match(
|
||||
await chart.locator('[data-history-deferred="disk"]').innerText(),
|
||||
/operation lock/,
|
||||
);
|
||||
assert.equal(await chart.locator('path, [data-history-observation]').count(), 0);
|
||||
const description = await chart
|
||||
.locator('[data-history-last-known="memory"]')
|
||||
.evaluate((el) => document.getElementById(el.getAttribute('aria-describedby')).textContent);
|
||||
assert.match(description, /Not a current measurement/);
|
||||
await screenshot('retained');
|
||||
record.checks.push(
|
||||
'Retained memory/disk qualified visibly and accessibly; no synthetic stored points.',
|
||||
);
|
||||
const reads = () =>
|
||||
record.requests.filter((r) => r.path === '/api/metrics-store/history').length;
|
||||
const firstReads = reads();
|
||||
await activate(page.getByRole('button', { name: 'Advance snapshot', exact: true }));
|
||||
assert.match(
|
||||
await chart.locator('[data-history-deferred="memory"]').innerText(),
|
||||
/2026-10-04 12:00:00 UTC/,
|
||||
);
|
||||
assert.equal(reads(), firstReads);
|
||||
record.checks.push(
|
||||
'Snapshot Last seen refresh cannot renew observation time or add a History read.',
|
||||
);
|
||||
await activate(page.getByRole('button', { name: 'unavailable', exact: true }));
|
||||
assert.equal(
|
||||
await chart
|
||||
.locator(
|
||||
'[data-history-last-known], [data-history-current="memory"], [data-history-current="disk"]',
|
||||
)
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
assert.match(await chart.innerText(), /Memory\s*-/);
|
||||
assert.match(await chart.innerText(), /Disk\s*-/);
|
||||
assert.match(
|
||||
await chart.locator('[data-history-deferred="memory"]').innerText(),
|
||||
/Unavailable/,
|
||||
);
|
||||
await screenshot('unavailable');
|
||||
record.checks.push(
|
||||
'Unavailable memory and non-retained filesystem carriers are missing, never zero/current.',
|
||||
);
|
||||
await activate(page.getByRole('button', { name: 'unknown', exact: true }));
|
||||
assert.match(
|
||||
await chart.locator('[data-history-unknown="memory"]').innerText(),
|
||||
/25.0%\s*freshness unknown/,
|
||||
);
|
||||
assert.match(
|
||||
await chart.locator('[data-history-deferred="memory"]').innerText(),
|
||||
/Unknown source.*Observation time unknown/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
await page.locator('body').innerText(),
|
||||
/private-(state|source|error)-sentinel|2999/,
|
||||
);
|
||||
record.checks.push(
|
||||
'Unknown state/source/future timestamp fail closed without leaking raw text.',
|
||||
);
|
||||
await activate(page.getByRole('button', { name: 'current', exact: true }));
|
||||
assert.match(
|
||||
await chart.locator('[data-history-current="memory"]').innerText(),
|
||||
/0.0%\s*current/,
|
||||
);
|
||||
assert.match(
|
||||
await chart.locator('[data-history-current="disk"]').innerText(),
|
||||
/50.0%\s*current/,
|
||||
);
|
||||
assert.equal(await chart.locator('[data-history-deferred]').count(), 0);
|
||||
assert.equal(reads(), firstReads);
|
||||
assert.ok(
|
||||
await page.evaluate(
|
||||
() => window.__chart === document.querySelector('[data-history-group="utilization"]'),
|
||||
),
|
||||
);
|
||||
await screenshot('current');
|
||||
record.checks.push(
|
||||
'Fresh measured zero and independent disk recovery render in place without extra reads or inferred thaw.',
|
||||
);
|
||||
mode = 'stored';
|
||||
await activate(page.getByRole('button', { name: 'Refresh history', exact: true }));
|
||||
const slider = chart.getByRole('slider', { name: 'Inspect Utilization history' });
|
||||
await slider.waitFor();
|
||||
assert.equal(
|
||||
await chart
|
||||
.locator('[data-history-current="memory"], [data-history-current="disk"]')
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
await slider.focus();
|
||||
await page.keyboard.press('Home');
|
||||
assert.match(await slider.getAttribute('aria-valuetext'), /Memory 5.0%.*Disk 40.0%/);
|
||||
await page.keyboard.press('End');
|
||||
assert.match(await slider.getAttribute('aria-valuetext'), /Memory 0.0%.*Disk 45.0%/);
|
||||
assert.equal(await chart.locator('path').count(), 2);
|
||||
await screenshot('stored');
|
||||
await slider.blur();
|
||||
await activate(page.getByRole('button', { name: 'retained', exact: true }));
|
||||
assert.equal(await chart.locator('path').count(), 2);
|
||||
assert.match(
|
||||
await chart.locator('[data-history-deferred="memory"]').innerText(),
|
||||
/Last known/,
|
||||
);
|
||||
record.checks.push(
|
||||
'Actual stored history and trusted Home/End inspection remain separate from live/retained provenance.',
|
||||
);
|
||||
mode = 'transient';
|
||||
await activate(page.getByRole('button', { name: 'Refresh history', exact: true }));
|
||||
await page.getByRole('button', { name: 'Retry history', exact: true }).waitFor();
|
||||
assert.equal(await chart.locator('path').count(), 2);
|
||||
assert.match(
|
||||
await page.getByRole('status', { name: 'History refresh status' }).innerText(),
|
||||
/previously loaded history/,
|
||||
);
|
||||
mode = 'denied';
|
||||
await activate(page.getByRole('button', { name: 'Retry history', exact: true }));
|
||||
await chart.waitFor({ state: 'detached' });
|
||||
assert.match(await page.locator('body').innerText(), /Access denied/);
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('[data-history-deferred], [data-history-last-known], [data-history-current]')
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
await page.locator('body').innerText(),
|
||||
/private-(state|source|error)-sentinel/,
|
||||
);
|
||||
record.checks.push(
|
||||
'Transient read retains only stored evidence; access denial withdraws all History carriers.',
|
||||
);
|
||||
assert.deepEqual(record.errors, []);
|
||||
// Chromium reports deliberately failed fetches to its console; WebKit
|
||||
// need not. Retain every message and permit only the two exact endpoint
|
||||
// and status pairs exercised above, never a general error filter.
|
||||
assert.equal(record.expectedHttpFailures.length, 2);
|
||||
for (const error of record.consoleErrors) {
|
||||
assert.ok(
|
||||
record.expectedHttpFailures.some(
|
||||
(failure) =>
|
||||
error.url === failure.url &&
|
||||
error.text ===
|
||||
`Failed to load resource: the server responded with a status of ${failure.status} (${failure.status === 503 ? 'Service Unavailable' : 'Forbidden'})`,
|
||||
),
|
||||
JSON.stringify(error),
|
||||
);
|
||||
}
|
||||
assert.ok(!record.requests.some((r) => r.blocked));
|
||||
const inputs = await page.evaluate(() => window.__inputs);
|
||||
assert.ok(inputs.some((e) => e.type === 'keydown' && e.trusted));
|
||||
assert.ok(inputs.some((e) => e.type === 'click' && e.trusted));
|
||||
record.trustedInputs = inputs;
|
||||
await browser.close();
|
||||
browser = undefined;
|
||||
}
|
||||
result.result = 'passed';
|
||||
result.verified_at = new Date().toISOString();
|
||||
} catch (error) {
|
||||
result.error = String(error.stack || error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
result.browser_closed = true;
|
||||
result.server_closed = true;
|
||||
fs.writeFileSync(output + '/result.json', JSON.stringify(result, null, 2) + '\n');
|
||||
}
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
result: result.result,
|
||||
groups: result.cases.length,
|
||||
checks: result.cases.reduce((n, c) => n + c.checks.length, 0),
|
||||
screenshots: result.screenshots.length,
|
||||
resultPath: output + '/result.json',
|
||||
}) + '\n',
|
||||
);
|
||||
})().catch((e) => {
|
||||
process.stderr.write(String(e.stack || e) + '\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>Resource History provenance verification</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/browser-tests/resource-history-provenance.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,137 @@
|
||||
// Synthetic snapshots only; both drawers, History, transport and CSS are production.
|
||||
import { createMemo, createSignal } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { Router, Route } from '@solidjs/router';
|
||||
import { ResourceDetailDrawer } from '../src/components/Infrastructure/ResourceDetailDrawer';
|
||||
import { GuestDrawer } from '../src/components/Workloads/GuestDrawer';
|
||||
import { DarkModeContext } from '../src/contexts/appRuntime';
|
||||
import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
|
||||
import type { Resource } from '../src/types/resource';
|
||||
import type { WorkloadGuest } from '../src/types/workloads';
|
||||
import '../src/index.css';
|
||||
syncAIRuntimeSettings({ discovery_enabled: false });
|
||||
function Fixture() {
|
||||
const [state, setState] = createSignal('retained');
|
||||
const [seen, setSeen] = createSignal(Date.parse('2026-10-04T13:00:00Z'));
|
||||
const value = () => (state() === 'current' || state() === 'unavailable' ? 0 : 25);
|
||||
const observation = () => ({
|
||||
state:
|
||||
state() === 'current'
|
||||
? 'current'
|
||||
: state() === 'unavailable'
|
||||
? 'unavailable'
|
||||
: state() === 'unknown'
|
||||
? 'private-state-sentinel'
|
||||
: 'last-known',
|
||||
source:
|
||||
state() === 'unknown'
|
||||
? 'private-source-sentinel'
|
||||
: state() === 'current'
|
||||
? 'status-mem'
|
||||
: 'guest-agent-meminfo',
|
||||
observedAt: state() === 'unknown' ? '2999-01-01T00:00:00Z' : '2026-10-04T12:00:00Z',
|
||||
});
|
||||
const reason = () =>
|
||||
state() === 'current' ? '' : state() === 'unavailable' ? 'agent-busy' : 'prev-vm-locked';
|
||||
const resource = createMemo<Resource>(() => ({
|
||||
id: 'fixture-vm',
|
||||
type: 'vm',
|
||||
name: 'backup-guest',
|
||||
displayName: 'Backup guest',
|
||||
platformId: 'fixture',
|
||||
platformType: 'proxmox-pve',
|
||||
sourceType: 'api',
|
||||
status: 'running',
|
||||
lastSeen: seen(),
|
||||
metricsTarget: { resourceType: 'vm', resourceId: 'fixture:pve:101' },
|
||||
cpu: { current: 10 },
|
||||
memory: { current: value(), total: 100, used: value(), observation: observation() },
|
||||
disk: { current: 50 },
|
||||
network: { rxBytes: 0, txBytes: 200 },
|
||||
proxmox: {
|
||||
vmid: 101,
|
||||
node: 'pve',
|
||||
instance: 'fixture',
|
||||
diskStatusReason: reason(),
|
||||
backupInProgress: Boolean(reason()),
|
||||
},
|
||||
}));
|
||||
const guest = createMemo<WorkloadGuest>(() => ({
|
||||
id: 'fixture:pve:101',
|
||||
vmid: 101,
|
||||
node: 'pve',
|
||||
instance: 'fixture',
|
||||
name: 'Backup guest',
|
||||
type: 'qemu',
|
||||
status: 'running',
|
||||
cpu: 0.1,
|
||||
cpus: 2,
|
||||
memory: {
|
||||
total: 100,
|
||||
used: value(),
|
||||
free: 100 - value(),
|
||||
usage: value(),
|
||||
observation: observation(),
|
||||
},
|
||||
disk: { total: 100, used: 50, usage: 50 },
|
||||
diskStatusReason: reason(),
|
||||
networkIn: 0,
|
||||
networkOut: 200,
|
||||
diskRead: 0,
|
||||
diskWrite: 0,
|
||||
uptime: 3600,
|
||||
template: false,
|
||||
lastBackup: 0,
|
||||
tags: [],
|
||||
lastSeen: new Date(seen()).toISOString(),
|
||||
lock: reason() ? 'backup' : '',
|
||||
}));
|
||||
return (
|
||||
<main class="min-h-screen bg-surface p-3 text-base-content">
|
||||
<h1 class="text-base font-semibold">Resource History provenance</h1>
|
||||
<p class="my-2 text-xs text-muted">
|
||||
Synthetic observations. No backup or guest-agent operation is sent.
|
||||
</p>
|
||||
<div class="mb-3 flex flex-wrap gap-2" aria-label="Fixture observation controls">
|
||||
{['retained', 'unavailable', 'unknown', 'current'].map((s) => (
|
||||
<button
|
||||
class="min-h-11 rounded-sm border border-border px-3 text-xs"
|
||||
onClick={() => setState(s)}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
class="min-h-11 rounded-sm border border-border px-3 text-xs"
|
||||
onClick={() => setSeen(Date.parse('2026-10-05T00:00:00Z'))}
|
||||
>
|
||||
Advance snapshot
|
||||
</button>
|
||||
</div>
|
||||
<section class="mx-auto max-w-5xl" aria-label="Production drawer">
|
||||
{new URLSearchParams(location.search).has('guest') ? (
|
||||
<GuestDrawer guest={guest()} onClose={() => {}} />
|
||||
) : (
|
||||
<ResourceDetailDrawer resource={resource()} presentation="table-row" />
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
render(
|
||||
() => (
|
||||
<Router>
|
||||
<Route
|
||||
path="*"
|
||||
component={() => (
|
||||
<DarkModeContext.Provider
|
||||
value={() => document.documentElement.classList.contains('dark')}
|
||||
>
|
||||
<Fixture />
|
||||
</DarkModeContext.Provider>
|
||||
)}
|
||||
/>
|
||||
</Router>
|
||||
),
|
||||
document.getElementById('root')!,
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "c0f0474423869e5ccc3d59db2d3013669ac37215",
|
||||
"verified_at": "2026-10-05T05:35:15.985Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts",
|
||||
"frontend-modern/src/components/Workloads/guestDrawerModel.ts",
|
||||
"frontend-modern/src/utils/memoryObservation.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/utils/memoryObservation.ts": "12a617cbd1af52345fa34a0ef79ba10a31675c07e5c974e28f1158a39f93c9a4",
|
||||
"frontend-modern/src/components/Workloads/guestDrawerModel.ts": "b681ee7b391dfa328be61689a2fe10a040b7e06a01985f893d9780204aa89188",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts": "464c18ed3fefdbf0332e54519be9c8ffa95a5e95e84c8e71eed50f27ddd4c661",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "48eaffc83be59c0e5373ad938d010015eb34b24e2bf65aaa07315e3c465c5a68",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "1d0f2261db7dc56b389f2e7bd43ce822c6b4689324d74ff1877c2c1a1eeb4863"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/resource-history-provenance.html (production canonical ResourceDetailDrawer)",
|
||||
"/browser-tests/resource-history-provenance.html?guest (production dedicated GuestDrawer)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Eight Chromium desktop / touch WebKit emulated-phone light/dark cases, Playwright 1.56.1 matching integration lock. All 32 full-page captures independently hash-matched and visually inspected.",
|
||||
"Retained memory and filesystem values labelled last known; original source/time, unavailable versus measured zero, unknown state/source/future timestamp and unrelated current readings stay distinct. No invented stored samples.",
|
||||
"Same-ID snapshot refresh cannot renew an observation time, remount History or request another read. Actual stored observations keep their coordinates/keyboard inspection.",
|
||||
"Deliberate 503 retains loaded stored evidence and 403 withdraws History carriers. All negative-response console messages retained and asserted against exact exercised endpoint/status pairs; no unexpected errors, off-origin or write requests."
|
||||
],
|
||||
"interactions": [
|
||||
"Select History with trusted desktop pointer or touch input; replace retained/unavailable/unknown/current synthetic observations.",
|
||||
"Advance snapshot Last seen; verify unchanged original observation and read count; recovery preserves measured zero without asserting native thaw.",
|
||||
"Refresh stored history, use trusted keyboard Home/End inspection; refresh with synthetic transient failure then Retry into denied access."
|
||||
],
|
||||
"proof": {
|
||||
"script": "frontend-modern/browser-tests/resource-history-provenance.cjs",
|
||||
"script_sha256": "4b5dc0280bc25941a4f8193ca8db59cddb3b09abd09118f41568f0be05aa6d0b",
|
||||
"checks": 56,
|
||||
"screenshots": 32,
|
||||
"browser_closed": true,
|
||||
"server_closed": true,
|
||||
"limits": "Production ResourceDetailDrawer and GuestDrawer, History, client and CSS; synthetic local observations/APIs. Not native guest safety, provider/appliance, installed, reporter or release acceptance."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "ca255a0e3f81a8d1588f3e783b7084fc863b37c8",
|
||||
"verified_at": "2026-10-05T05:44:42.674Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/docker/DockerContainersTable.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/docker/DockerContainersTable.tsx": "85753788c2e982dcedbab9aed99ff74a1f1623ec35bc7886ab25661e86de2dd9"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/docker-composed.html (production DockerContainersTable and ResourceDetailDrawer)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Four Chromium desktop / touch WebKit emulated-phone light/dark cases; Playwright 1.56.1 matches integration lock. All 12 captures independently hash-matched and visually inspected.",
|
||||
"Combined small-positive/zero/tiny-positive CPU labels with silent-host/shared-column presentation. Available and missing host context; grouped and flat layout preserve labels and total-host-capacity guidance.",
|
||||
"Real drawer expansion/collapse uses trusted keyboard desktop and tap phone input. View selection follows actual mobile Filters disclosure. No unexpected errors, off-origin requests or operation requests."
|
||||
],
|
||||
"interactions": [
|
||||
"Toggle host context and restore it; verify host status and engine fallback separately.",
|
||||
"Open real View disclosure (phone Filters first), select List, expand and collapse small-positive container; inspect real drawer scale explanation."
|
||||
],
|
||||
"proof": {
|
||||
"script": "frontend-modern/browser-tests/docker-composed.cjs",
|
||||
"script_sha256": "7529007d411f8d40bda969e2b43470779077c263e704aba43ecba34fc21467e8",
|
||||
"checks": 12,
|
||||
"screenshots": 12,
|
||||
"browser_closed": true,
|
||||
"server_closed": true,
|
||||
"limits": "Production table, drawer and CSS with selected synthetic resources/APIs. Emulated phone, not a native phone. No collector, engine, threshold duration, notification, installed or release acceptance."
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,7 @@ const DrawerContent: Component<ResourceDetailDrawerProps> = (props) => {
|
||||
target={target()}
|
||||
range={drawer.metricsHistoryRange()}
|
||||
currentMetrics={drawer.metricsHistoryCurrentMetrics()}
|
||||
deferredMetrics={drawer.metricsHistoryDeferredMetrics()}
|
||||
groups={drawer.metricsHistoryGroups()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+90
-1
@@ -6,7 +6,9 @@ import {
|
||||
within,
|
||||
waitFor,
|
||||
} from '@solidjs/testing-library';
|
||||
import { Suspense } from 'solid-js';
|
||||
import { createSignal, Suspense } from 'solid-js';
|
||||
import { ChartsAPI } from '@/api/charts';
|
||||
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
|
||||
|
||||
import discoveryTabSource from '@/components/Discovery/DiscoveryTab.tsx?raw';
|
||||
import discoveryTabStateSource from '@/components/Discovery/useDiscoveryTabState.ts?raw';
|
||||
@@ -165,6 +167,93 @@ const baseResource = (overrides: Partial<Resource>): Resource => ({
|
||||
});
|
||||
|
||||
describe('ResourceDetailDrawer change history section', () => {
|
||||
it('keeps retained guest memory and paused filesystems out of current History fallbacks', async () => {
|
||||
resetCreateNonSuspendingQueryCacheForTest();
|
||||
const source = baseResource({
|
||||
id: 'provenance-vm',
|
||||
type: 'vm',
|
||||
platformType: 'proxmox-pve',
|
||||
metricsTarget: { resourceType: 'vm', resourceId: 'fixture:pve:101' },
|
||||
memory: {
|
||||
current: 25,
|
||||
observation: {
|
||||
state: 'last-known',
|
||||
source: 'guest-agent-meminfo',
|
||||
observedAt: '2026-10-04T12:00:00Z',
|
||||
},
|
||||
},
|
||||
disk: { current: 50 },
|
||||
proxmox: { diskStatusReason: 'prev-vm-locked', backupInProgress: true },
|
||||
});
|
||||
const read = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue({
|
||||
resourceType: 'vm',
|
||||
resourceId: 'fixture:pve:101',
|
||||
range: '24h',
|
||||
start: Date.parse('2026-10-04T12:00:00Z'),
|
||||
end: Date.parse('2026-10-05T00:00:00Z'),
|
||||
source: 'store',
|
||||
metrics: {},
|
||||
});
|
||||
const [resource, setResource] = createSignal(source);
|
||||
const view = render(() => (
|
||||
<ResourceDetailDrawer resource={resource()} presentation="table-row" />
|
||||
));
|
||||
try {
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
|
||||
await waitFor(() => expect(read).toHaveBeenCalledTimes(1));
|
||||
const chart = view.container.querySelector('[data-history-group="utilization"]')!;
|
||||
expect(chart.querySelector('[data-history-current="memory"]')).toBeNull();
|
||||
expect(chart.querySelector('[data-history-last-known="memory"]')).toHaveTextContent(
|
||||
'25.0%last known',
|
||||
);
|
||||
expect(chart.querySelector('[data-history-deferred="memory"]')).toHaveTextContent(
|
||||
'2026-10-04 12:00:00 UTC',
|
||||
);
|
||||
expect(chart.querySelector('[data-history-current="disk"]')).toBeNull();
|
||||
expect(chart.querySelector('[data-history-last-known="disk"]')).toHaveTextContent(
|
||||
'50.0%last known',
|
||||
);
|
||||
setResource({ ...source, lastSeen: Date.parse('2026-10-05T00:00:00Z') });
|
||||
expect(chart.querySelector('[data-history-deferred="memory"]')).not.toHaveTextContent(
|
||||
'2026-10-05',
|
||||
);
|
||||
setResource({
|
||||
...source,
|
||||
memory: { current: 0, observation: { state: 'unavailable', source: 'status-mem' } },
|
||||
proxmox: { diskStatusReason: 'agent-busy' },
|
||||
});
|
||||
expect(chart.querySelector('[data-history-last-known]')).toBeNull();
|
||||
expect(chart).toHaveTextContent('Memory-');
|
||||
expect(chart).toHaveTextContent('Disk-');
|
||||
setResource({
|
||||
...source,
|
||||
memory: {
|
||||
current: 0,
|
||||
observation: {
|
||||
state: 'current',
|
||||
source: 'status-mem',
|
||||
observedAt: '2026-10-04T13:00:00Z',
|
||||
},
|
||||
},
|
||||
proxmox: { diskStatusReason: '' },
|
||||
});
|
||||
expect(view.container.querySelector('[data-history-group="utilization"]')).toBe(chart);
|
||||
expect(chart.querySelector('[data-history-current="memory"]')).toHaveTextContent(
|
||||
'0.0%current',
|
||||
);
|
||||
expect(chart.querySelector('[data-history-current="disk"]')).toHaveTextContent(
|
||||
'50.0%current',
|
||||
);
|
||||
expect(chart.querySelector('[data-history-deferred]')).toBeNull();
|
||||
expect(chart.querySelector('path')).toBeNull();
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
view.unmount();
|
||||
read.mockRestore();
|
||||
resetCreateNonSuspendingQueryCacheForTest();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps current readings separate from stored history samples', () => {
|
||||
expect(guestDrawerHistorySource).toContain('currentMetrics');
|
||||
expect(guestDrawerHistorySource).toContain('No stored history in this range');
|
||||
|
||||
+145
@@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getResourceMetricsHistoryCurrentMetrics,
|
||||
getResourceMetricsHistoryDeferredMetrics,
|
||||
getResourceMetricsHistoryGroups,
|
||||
getResourceMetricsHistoryTarget,
|
||||
} from '@/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel';
|
||||
import type { MetricsHistoryTargetResourceType, Resource } from '@/types/resource';
|
||||
import { guestDiskDeferrals } from '@/components/Workloads/__fixtures__/guestDiskDeferrals';
|
||||
|
||||
// Minimal valid Resource fixture; mirrors the baseResource() convention used by
|
||||
// sibling branchcov tests in this directory.
|
||||
@@ -22,6 +24,149 @@ const baseResource = (overrides: Partial<Resource>): Resource => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('canonical drawer History honours selected guest observation provenance', () => {
|
||||
const resource = (
|
||||
state: string,
|
||||
value = 25,
|
||||
observedAt: string | undefined = '2026-10-04T12:00:00Z',
|
||||
) =>
|
||||
baseResource({
|
||||
type: 'vm',
|
||||
platformType: 'proxmox-pve',
|
||||
memory: { current: value, observation: { state, source: 'guest-agent-meminfo', observedAt } },
|
||||
disk: { current: 50 },
|
||||
cpu: { current: 10 },
|
||||
network: { rxBytes: 0, txBytes: 200 },
|
||||
proxmox: { diskStatusReason: 'prev-vm-locked' },
|
||||
});
|
||||
|
||||
it.each(['vm', 'system-container'] as const)(
|
||||
'retains %s memory with its original time, not Last seen',
|
||||
(type) => {
|
||||
const value = {
|
||||
...resource('last-known'),
|
||||
type,
|
||||
lastSeen: Date.parse('2026-10-05T00:00:00Z'),
|
||||
};
|
||||
expect(getResourceMetricsHistoryCurrentMetrics(value)).toMatchObject({
|
||||
memory: undefined,
|
||||
cpu: 10,
|
||||
netin: 0,
|
||||
});
|
||||
expect(getResourceMetricsHistoryDeferredMetrics(value).memory).toMatchObject({
|
||||
lastKnownValue: 25,
|
||||
message: expect.stringContaining('2026-10-04 12:00:00 UTC'),
|
||||
});
|
||||
expect(getResourceMetricsHistoryDeferredMetrics(value).memory.message).not.toContain(
|
||||
'2026-10-05',
|
||||
);
|
||||
// Container disk usage is not a QEMU guest-agent filesystem read.
|
||||
expect(getResourceMetricsHistoryCurrentMetrics(value).disk).toBe(
|
||||
type === 'vm' ? undefined : 50,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([-1, 101, NaN, Infinity])('withdraws invalid retained memory %s', (value) => {
|
||||
expect(
|
||||
getResourceMetricsHistoryDeferredMetrics(resource('last-known', value)).memory.lastKnownValue,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getResourceMetricsHistoryCurrentMetrics(resource('last-known', value)).memory,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([undefined, 'invalid', '0001-01-01T00:00:00Z', '2999-01-01T00:00:00Z'])(
|
||||
'does not promote unusable current timestamps %s',
|
||||
(observedAt) => {
|
||||
const value = resource('current');
|
||||
value.memory!.observation!.observedAt = observedAt;
|
||||
expect(getResourceMetricsHistoryCurrentMetrics(value).memory).toBeUndefined();
|
||||
expect(getResourceMetricsHistoryDeferredMetrics(value).memory).toMatchObject({
|
||||
lastKnownValue: 25,
|
||||
valueLabel: 'freshness unknown',
|
||||
});
|
||||
expect(getResourceMetricsHistoryDeferredMetrics(value).memory.message).toContain(
|
||||
'Observation time unknown.',
|
||||
);
|
||||
expect(getResourceMetricsHistoryDeferredMetrics(value).memory.message).not.toMatch(
|
||||
/invalid|2999|0001/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps unavailable, true zero and independent current memory distinct from disk deferral', () => {
|
||||
expect(
|
||||
getResourceMetricsHistoryDeferredMetrics(resource('unavailable', 0)).memory.lastKnownValue,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getResourceMetricsHistoryDeferredMetrics(resource('last-known', 0)).memory.lastKnownValue,
|
||||
).toBe(0);
|
||||
expect(getResourceMetricsHistoryCurrentMetrics(resource('current', 0)).memory).toBe(0);
|
||||
expect(getResourceMetricsHistoryDeferredMetrics(resource('current', 0)).memory).toBeUndefined();
|
||||
expect(
|
||||
getResourceMetricsHistoryDeferredMetrics(resource('current', 0)).disk.lastKnownValue,
|
||||
).toBe(50);
|
||||
});
|
||||
|
||||
it('qualifies unannotated Proxmox memory but leaves unrelated platforms unchanged', () => {
|
||||
const value = resource('current');
|
||||
delete value.memory!.observation;
|
||||
expect(getResourceMetricsHistoryCurrentMetrics(value).memory).toBeUndefined();
|
||||
expect(getResourceMetricsHistoryDeferredMetrics(value).memory.valueLabel).toBe(
|
||||
'freshness unknown',
|
||||
);
|
||||
const vsphere = { ...value, platformType: 'vmware-vsphere' as const };
|
||||
expect(getResourceMetricsHistoryCurrentMetrics(vsphere)).toMatchObject({
|
||||
memory: 25,
|
||||
disk: 50,
|
||||
});
|
||||
expect(getResourceMetricsHistoryDeferredMetrics(vsphere)).toEqual({});
|
||||
const scoped = { ...vsphere, platformScopes: ['proxmox-pve'] };
|
||||
expect(getResourceMetricsHistoryCurrentMetrics(scoped).memory).toBeUndefined();
|
||||
const agent = {
|
||||
...resource('last-known'),
|
||||
type: 'agent' as const,
|
||||
platformType: 'agent' as const,
|
||||
};
|
||||
expect(getResourceMetricsHistoryDeferredMetrics(agent).memory.lastKnownValue).toBe(25);
|
||||
expect(getResourceMetricsHistoryDeferredMetrics(agent).disk).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not echo unrecognised source or state text', () => {
|
||||
const value = resource('private raw state');
|
||||
value.memory!.observation!.source = 'private raw source';
|
||||
expect(getResourceMetricsHistoryDeferredMetrics(value).memory).toMatchObject({
|
||||
valueLabel: 'freshness unknown',
|
||||
message: expect.stringContaining('Unknown source'),
|
||||
});
|
||||
expect(getResourceMetricsHistoryDeferredMetrics(value).memory.message).not.toContain(
|
||||
'private raw',
|
||||
);
|
||||
});
|
||||
|
||||
it.each(guestDiskDeferrals)(
|
||||
'qualifies both retained and unavailable filesystem %s readings',
|
||||
(reason, message) => {
|
||||
const value = resource('current');
|
||||
value.proxmox!.diskStatusReason = `prev-${reason}`;
|
||||
expect(getResourceMetricsHistoryCurrentMetrics(value).disk).toBeUndefined();
|
||||
expect(getResourceMetricsHistoryDeferredMetrics(value).disk).toEqual({
|
||||
lastKnownValue: 50,
|
||||
message: `Using last known disk stats. ${message}`,
|
||||
});
|
||||
value.proxmox!.diskStatusReason = reason;
|
||||
expect(getResourceMetricsHistoryDeferredMetrics(value).disk).toEqual({
|
||||
lastKnownValue: undefined,
|
||||
message,
|
||||
});
|
||||
value.proxmox!.diskStatusReason = '';
|
||||
expect(getResourceMetricsHistoryDeferredMetrics(value).disk).toBeUndefined();
|
||||
expect(getResourceMetricsHistoryCurrentMetrics(value).disk).toBe(50);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('getResourceMetricsHistoryTarget branch coverage', () => {
|
||||
it('returns the metricsTarget pair when both resourceType and resourceId resolve', () => {
|
||||
// TRUE arm of `if (metricsType && metricsId)`.
|
||||
|
||||
+55
-3
@@ -3,7 +3,10 @@ import {
|
||||
GPU_METRICS_HISTORY_GROUPS,
|
||||
HOST_METRICS_HISTORY_GROUPS,
|
||||
} from '@/components/shared/hostMetricsHistoryModel';
|
||||
import type { GuestDrawerHistoryTarget } from '@/components/Workloads/guestDrawerModel';
|
||||
import type {
|
||||
GuestDrawerHistoryDeferredMetric,
|
||||
GuestDrawerHistoryTarget,
|
||||
} from '@/components/Workloads/guestDrawerModel';
|
||||
import {
|
||||
GUEST_DRAWER_HISTORY_GROUPS,
|
||||
type GuestDrawerHistoryGroupConfig,
|
||||
@@ -12,6 +15,8 @@ import type { HostGPUSensor } from '@/types/api';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { getDiskPercent, getMemoryPercent } from '@/types/resource';
|
||||
import { asTrimmedString } from '@/utils/stringUtils';
|
||||
import { getMemoryObservationPresentation } from '@/utils/memoryObservation';
|
||||
import { getWorkloadGuestDiskStatusMessage } from '@/utils/workloadGuestPresentation';
|
||||
|
||||
const finiteMetric = (value: number | undefined): number | undefined =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
@@ -152,15 +157,62 @@ export const getResourceMetricsHistoryTarget = (
|
||||
export const resourceSupportsMetricsHistory = (resource: Resource): boolean =>
|
||||
getResourceMetricsHistoryTarget(resource) !== null;
|
||||
|
||||
const isProxmoxGuest = (resource: Resource): boolean =>
|
||||
(resource.type === 'vm' || resource.type === 'system-container') &&
|
||||
(resource.platformType === 'proxmox-pve' ||
|
||||
resource.platformScopes?.includes('proxmox-pve') === true);
|
||||
|
||||
const memoryReading = (resource: Resource) =>
|
||||
getMemoryObservationPresentation(
|
||||
resource.memory ? finiteMetric(getMemoryPercent(resource)) : undefined,
|
||||
resource.memory?.observation,
|
||||
isProxmoxGuest(resource),
|
||||
);
|
||||
|
||||
const diskReadReason = (resource: Resource): string | undefined =>
|
||||
resource.type === 'vm' && isProxmoxGuest(resource)
|
||||
? resource.proxmox?.diskStatusReason || undefined
|
||||
: undefined;
|
||||
|
||||
export const getResourceMetricsHistoryDeferredMetrics = (
|
||||
resource: Resource,
|
||||
): Record<string, GuestDrawerHistoryDeferredMetric> => {
|
||||
const metrics: Record<string, GuestDrawerHistoryDeferredMetric> = {};
|
||||
const memory = memoryReading(resource);
|
||||
if (memory && memory.state !== 'current') {
|
||||
metrics.memory = {
|
||||
lastKnownValue:
|
||||
memory.state === 'unavailable' ? undefined : finiteMetric(getMemoryPercent(resource)),
|
||||
...(memory.state === 'unknown' ? { valueLabel: 'freshness unknown' as const } : {}),
|
||||
message: memory.message,
|
||||
};
|
||||
}
|
||||
const reason = diskReadReason(resource);
|
||||
if (reason) {
|
||||
metrics.disk = {
|
||||
lastKnownValue:
|
||||
reason.startsWith('prev-') && resource.disk
|
||||
? finiteMetric(getDiskPercent(resource))
|
||||
: undefined,
|
||||
message: getWorkloadGuestDiskStatusMessage(reason),
|
||||
};
|
||||
}
|
||||
return metrics;
|
||||
};
|
||||
|
||||
export const getResourceMetricsHistoryCurrentMetrics = (
|
||||
resource: Resource,
|
||||
): Record<string, number | undefined> => {
|
||||
const diskPercent = resource.disk ? finiteMetric(getDiskPercent(resource)) : undefined;
|
||||
const temperature = finiteMetric(resource.temperature);
|
||||
const memory = memoryReading(resource);
|
||||
return {
|
||||
cpu: finiteMetric(resource.cpu?.current),
|
||||
memory: resource.memory ? finiteMetric(getMemoryPercent(resource)) : undefined,
|
||||
disk: diskPercent,
|
||||
memory:
|
||||
resource.memory && (!memory || memory.state === 'current')
|
||||
? finiteMetric(getMemoryPercent(resource))
|
||||
: undefined,
|
||||
disk: diskReadReason(resource) ? undefined : diskPercent,
|
||||
usage: diskPercent,
|
||||
netin: finiteMetric(resource.network?.rxBytes),
|
||||
netout: finiteMetric(resource.network?.txBytes),
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
} from './resourceDetailDrawerOperationalModel';
|
||||
import {
|
||||
getResourceMetricsHistoryCurrentMetrics,
|
||||
getResourceMetricsHistoryDeferredMetrics,
|
||||
getResourceMetricsHistoryGroups,
|
||||
getResourceMetricsHistoryTarget,
|
||||
resourceSupportsMetricsHistory,
|
||||
@@ -371,6 +372,9 @@ export const useResourceDetailDrawerDerivedState = (
|
||||
const metricsHistoryCurrentMetrics = createMemo(() =>
|
||||
getResourceMetricsHistoryCurrentMetrics(resource()),
|
||||
);
|
||||
const metricsHistoryDeferredMetrics = createMemo(() =>
|
||||
getResourceMetricsHistoryDeferredMetrics(resource()),
|
||||
);
|
||||
const metricsHistoryGroups = createMemo(() => getResourceMetricsHistoryGroups(resource()));
|
||||
const hasMetricsHistory = createMemo(() => resourceSupportsMetricsHistory(resource()));
|
||||
const hasDiscoveryTab = createMemo(
|
||||
@@ -509,6 +513,7 @@ export const useResourceDetailDrawerDerivedState = (
|
||||
serviceDetailsSummary,
|
||||
metricsHistoryTarget,
|
||||
metricsHistoryCurrentMetrics,
|
||||
metricsHistoryDeferredMetrics,
|
||||
metricsHistoryGroups,
|
||||
hasMetricsHistory,
|
||||
hasDiscoveryTab,
|
||||
|
||||
@@ -220,6 +220,57 @@ afterEach(() => {
|
||||
// ── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GuestDrawer', () => {
|
||||
it('keeps the shared memory provenance policy in the full guest drawer after live replacement', async () => {
|
||||
chartsApiMocks.getMetricsHistory.mockResolvedValue({
|
||||
resourceType: 'vm',
|
||||
resourceId: 'inst1:node1:100',
|
||||
range: '24h',
|
||||
start: 1,
|
||||
end: 3,
|
||||
source: 'store',
|
||||
metrics: {},
|
||||
});
|
||||
const [guest, setGuest] = createSignal(
|
||||
makeGuest({
|
||||
memory: {
|
||||
total: 100,
|
||||
used: 0,
|
||||
free: 100,
|
||||
usage: 0,
|
||||
observation: {
|
||||
state: 'last-known',
|
||||
source: 'guest-agent-meminfo',
|
||||
observedAt: '2026-10-04T12:00:00Z',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
render(() => <GuestDrawer guest={guest()} onClose={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
|
||||
await waitFor(() => expect(chartsApiMocks.getMetricsHistory).toHaveBeenCalledTimes(1));
|
||||
const chart = screen.getAllByTestId('guest-history-group-chart')[0];
|
||||
expect(chart.querySelector('[data-history-last-known="memory"]')).toHaveTextContent(
|
||||
'0.0%last known',
|
||||
);
|
||||
expect(chart.querySelector('[data-history-current="memory"]')).toBeNull();
|
||||
setGuest({ ...guest(), lastSeen: '2026-10-05T00:00:00Z' });
|
||||
expect(chart.querySelector('[data-history-deferred="memory"]')).toHaveTextContent(
|
||||
'2026-10-04 12:00:00 UTC',
|
||||
);
|
||||
setGuest({
|
||||
...guest(),
|
||||
memory: {
|
||||
...guest().memory,
|
||||
observation: { state: 'current', source: 'status-mem', observedAt: '2026-10-04T13:00:00Z' },
|
||||
},
|
||||
});
|
||||
expect(screen.getAllByTestId('guest-history-group-chart')[0]).toBe(chart);
|
||||
expect(chart.querySelector('[data-history-current="memory"]')).toHaveTextContent('0.0%current');
|
||||
expect(chart.querySelector('[data-history-deferred="memory"]')).toBeNull();
|
||||
expect(chart.querySelector('path')).toBeNull();
|
||||
expect(chartsApiMocks.getMetricsHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps stored History gaps through live guest recovery without another read', async () => {
|
||||
const points = makeHistoryPoints(10);
|
||||
const payload = {
|
||||
|
||||
@@ -17,6 +17,11 @@ import { getWorkloadMetricHistoryTarget } from '@/utils/workloadMetricHistoryTar
|
||||
import type { NestedWorkloadContext } from './nestedWorkloadContext';
|
||||
import type { WorkloadsMemoryDisplayBasis } from './workloadsFilterModel';
|
||||
|
||||
import {
|
||||
getMemoryObservationPresentation,
|
||||
type MemoryObservationPresentation,
|
||||
} from '@/utils/memoryObservation';
|
||||
|
||||
type Guest = WorkloadGuest;
|
||||
|
||||
export interface GuestDrawerProps {
|
||||
@@ -107,24 +112,7 @@ const getGuestDrawerDiskUsage = (guest: Guest): number | undefined => {
|
||||
return typeof usage === 'number' && Number.isFinite(usage) && usage >= 0 ? usage : undefined;
|
||||
};
|
||||
|
||||
export interface GuestDrawerMemoryReading {
|
||||
state: 'current' | 'last-known' | 'unavailable' | 'unknown';
|
||||
summary: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const memorySourceLabels: Record<string, string> = {
|
||||
'guest-agent-meminfo': 'QEMU guest agent',
|
||||
'guest-agent-meminfo-derived': 'QEMU guest agent',
|
||||
agent: 'Pulse Agent',
|
||||
'available-field': 'Proxmox',
|
||||
'derived-free-buffers-cached': 'Proxmox',
|
||||
'derived-total-minus-used': 'Proxmox',
|
||||
'status-mem': 'Proxmox',
|
||||
'status-freemem': 'Proxmox',
|
||||
'cluster-resources': 'Proxmox',
|
||||
'previous-snapshot': 'Previous snapshot',
|
||||
};
|
||||
export type GuestDrawerMemoryReading = MemoryObservationPresentation;
|
||||
|
||||
const getGuestDrawerMemoryUsage = (guest: Guest): number | undefined => {
|
||||
const usage = guest.memory?.usage;
|
||||
@@ -149,41 +137,11 @@ export const getGuestDrawerMemoryReading = (guest: Guest): GuestDrawerMemoryRead
|
||||
guest.type === 'lxc' ||
|
||||
guest.platformScopes?.includes('proxmox-pve') ||
|
||||
(guest.vmid > 0 && Boolean(guest.node && guest.instance)));
|
||||
// Unannotated unrelated platforms retain their existing behaviour. Legacy
|
||||
// Proxmox readings lack authority to assert current freshness.
|
||||
if (!proxmoxGuest && !observation) return null;
|
||||
const timestamp =
|
||||
typeof observation?.observedAt === 'string' ? Date.parse(observation.observedAt) : NaN;
|
||||
const validTime = Number.isFinite(timestamp) && timestamp > 0 && timestamp <= Date.now();
|
||||
const observed = validTime
|
||||
? new Date(timestamp).toISOString().replace('T', ' ').replace('.000Z', 'Z').replace('Z', ' UTC')
|
||||
: null;
|
||||
const state =
|
||||
getGuestDrawerMemoryUsage(guest) === undefined || observation?.state === 'unavailable'
|
||||
? 'unavailable'
|
||||
: observation?.state === 'last-known'
|
||||
? 'last-known'
|
||||
: observation?.state === 'current' && validTime
|
||||
? 'current'
|
||||
: 'unknown';
|
||||
const label =
|
||||
state === 'current'
|
||||
? 'Current'
|
||||
: state === 'last-known'
|
||||
? 'Last known'
|
||||
: state === 'unavailable'
|
||||
? 'Unavailable'
|
||||
: 'Freshness unknown';
|
||||
const source =
|
||||
observation && Object.hasOwn(memorySourceLabels, observation.source)
|
||||
? memorySourceLabels[observation.source]
|
||||
: 'Unknown source';
|
||||
const time = observed ? `Observed: ${observed}.` : 'Observation time unknown.';
|
||||
return {
|
||||
state,
|
||||
summary: `${label} · ${source}${observed ? ` · ${observed}` : ' · time unknown'}`,
|
||||
message: `${label}. Source: ${source}. ${time}${state === 'last-known' || state === 'unknown' ? ' Not a current measurement.' : ''}`,
|
||||
};
|
||||
return getMemoryObservationPresentation(
|
||||
getGuestDrawerMemoryUsage(guest),
|
||||
observation,
|
||||
proxmoxGuest,
|
||||
);
|
||||
};
|
||||
|
||||
export const getGuestDrawerDeferredMetrics = (
|
||||
|
||||
@@ -11,3 +11,70 @@ export const readMemoryObservation = (value: unknown): MemoryObservation | undef
|
||||
...(typeof record.observedAt === 'string' ? { observedAt: record.observedAt } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export interface MemoryObservationPresentation {
|
||||
state: 'current' | 'last-known' | 'unavailable' | 'unknown';
|
||||
summary: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const memorySourceLabels: Record<string, string> = {
|
||||
'guest-agent-meminfo': 'QEMU guest agent',
|
||||
'guest-agent-meminfo-derived': 'QEMU guest agent',
|
||||
agent: 'Pulse Agent',
|
||||
'available-field': 'Proxmox',
|
||||
'derived-free-buffers-cached': 'Proxmox',
|
||||
'derived-total-minus-used': 'Proxmox',
|
||||
'status-mem': 'Proxmox',
|
||||
'status-freemem': 'Proxmox',
|
||||
'cluster-resources': 'Proxmox',
|
||||
'previous-snapshot': 'Previous snapshot',
|
||||
};
|
||||
|
||||
// Both guest and canonical resource drawers must honour the original observation,
|
||||
// not a snapshot refresh time or another metric's read state.
|
||||
export const getMemoryObservationPresentation = (
|
||||
value: number | undefined,
|
||||
observation: MemoryObservation | undefined,
|
||||
requiresObservation = false,
|
||||
): MemoryObservationPresentation | null => {
|
||||
// Unannotated unrelated platforms retain their existing behaviour. Legacy
|
||||
// Proxmox readings lack authority to assert current freshness.
|
||||
if (!requiresObservation && !observation) return null;
|
||||
const timestamp =
|
||||
typeof observation?.observedAt === 'string' ? Date.parse(observation.observedAt) : NaN;
|
||||
const validTime = Number.isFinite(timestamp) && timestamp > 0 && timestamp <= Date.now();
|
||||
const observed = validTime
|
||||
? new Date(timestamp).toISOString().replace('T', ' ').replace('.000Z', 'Z').replace('Z', ' UTC')
|
||||
: null;
|
||||
const state =
|
||||
typeof value !== 'number' ||
|
||||
!Number.isFinite(value) ||
|
||||
value < 0 ||
|
||||
value > 100 ||
|
||||
observation?.state === 'unavailable'
|
||||
? 'unavailable'
|
||||
: observation?.state === 'last-known'
|
||||
? 'last-known'
|
||||
: observation?.state === 'current' && validTime
|
||||
? 'current'
|
||||
: 'unknown';
|
||||
const label =
|
||||
state === 'current'
|
||||
? 'Current'
|
||||
: state === 'last-known'
|
||||
? 'Last known'
|
||||
: state === 'unavailable'
|
||||
? 'Unavailable'
|
||||
: 'Freshness unknown';
|
||||
const source =
|
||||
observation && Object.hasOwn(memorySourceLabels, observation.source)
|
||||
? memorySourceLabels[observation.source]
|
||||
: 'Unknown source';
|
||||
const time = observed ? `Observed: ${observed}.` : 'Observation time unknown.';
|
||||
return {
|
||||
state,
|
||||
summary: `${label} · ${source}${observed ? ` · ${observed}` : ' · time unknown'}`,
|
||||
message: `${label}. Source: ${source}. ${time}${state === 'last-known' || state === 'unknown' ? ' Not a current measurement.' : ''}`,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user