diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index d2ec78a78e..0c5594a831 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -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
diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
index 5a34a790e0..5eaa84950b 100644
--- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
+++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
@@ -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
diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md
index 4bd75bc400..c16a13ec1d 100644
--- a/docs/release-control/v6/internal/subsystems/unified-resources.md
+++ b/docs/release-control/v6/internal/subsystems/unified-resources.md
@@ -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.
diff --git a/frontend-modern/browser-tests/docker-composed.cjs b/frontend-modern/browser-tests/docker-composed.cjs
new file mode 100644
index 0000000000..7ceae8b40e
--- /dev/null
+++ b/frontend-modern/browser-tests/docker-composed.cjs
@@ -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;
+});
diff --git a/frontend-modern/browser-tests/docker-composed.html b/frontend-modern/browser-tests/docker-composed.html
new file mode 100644
index 0000000000..93c2e13114
--- /dev/null
+++ b/frontend-modern/browser-tests/docker-composed.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Composed container presentation proof
+
+
+
+
+
+
diff --git a/frontend-modern/browser-tests/docker-composed.tsx b/frontend-modern/browser-tests/docker-composed.tsx
new file mode 100644
index 0000000000..b411c751a9
--- /dev/null
+++ b/frontend-modern/browser-tests/docker-composed.tsx
@@ -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 (
+ document.documentElement.classList.contains('dark')}>
+
+ Composed container presentation
+ Synthetic metrics. No container or notification action.
+
+ }
+ emptyTitle="No containers"
+ emptyDescription="No containers"
+ />
+
+
+ );
+}
+render(
+ () => (
+
+
+
+ ),
+ document.getElementById('root')!,
+);
diff --git a/frontend-modern/browser-tests/resource-history-provenance.cjs b/frontend-modern/browser-tests/resource-history-provenance.cjs
new file mode 100644
index 0000000000..11afe10c2a
--- /dev/null
+++ b/frontend-modern/browser-tests/resource-history-provenance.cjs
@@ -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;
+});
diff --git a/frontend-modern/browser-tests/resource-history-provenance.html b/frontend-modern/browser-tests/resource-history-provenance.html
new file mode 100644
index 0000000000..bc1b2c039f
--- /dev/null
+++ b/frontend-modern/browser-tests/resource-history-provenance.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Resource History provenance verification
+
+
+
+
+
+
diff --git a/frontend-modern/browser-tests/resource-history-provenance.tsx b/frontend-modern/browser-tests/resource-history-provenance.tsx
new file mode 100644
index 0000000000..f6f53974cb
--- /dev/null
+++ b/frontend-modern/browser-tests/resource-history-provenance.tsx
@@ -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(() => ({
+ 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(() => ({
+ 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 (
+
+ Resource History provenance
+
+ Synthetic observations. No backup or guest-agent operation is sent.
+
+
+ {['retained', 'unavailable', 'unknown', 'current'].map((s) => (
+
+ ))}
+
+
+
+ {new URLSearchParams(location.search).has('guest') ? (
+ {}} />
+ ) : (
+
+ )}
+
+
+ );
+}
+render(
+ () => (
+
+ (
+ document.documentElement.classList.contains('dark')}
+ >
+
+
+ )}
+ />
+
+ ),
+ document.getElementById('root')!,
+);
diff --git a/frontend-modern/browser-verification/4beced7d2f607011.json b/frontend-modern/browser-verification/4beced7d2f607011.json
new file mode 100644
index 0000000000..f6ca41e3be
--- /dev/null
+++ b/frontend-modern/browser-verification/4beced7d2f607011.json
@@ -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."
+ }
+}
diff --git a/frontend-modern/browser-verification/66ad68daaba822c9.json b/frontend-modern/browser-verification/66ad68daaba822c9.json
new file mode 100644
index 0000000000..5e83b4061d
--- /dev/null
+++ b/frontend-modern/browser-verification/66ad68daaba822c9.json
@@ -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."
+ }
+}
diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx
index 4175655c0e..23ea1a0de7 100644
--- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx
+++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx
@@ -202,6 +202,7 @@ const DrawerContent: Component = (props) => {
target={target()}
range={drawer.metricsHistoryRange()}
currentMetrics={drawer.metricsHistoryCurrentMetrics()}
+ deferredMetrics={drawer.metricsHistoryDeferredMetrics()}
groups={drawer.metricsHistoryGroups()}
/>
diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx
index 62a2047e4f..26c4f65899 100644
--- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx
+++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx
@@ -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 => ({
});
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(() => (
+
+ ));
+ 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');
diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.branchcov0712.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.branchcov0712.test.ts
index 579e32ec39..bae3d26c0e 100644
--- a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.branchcov0712.test.ts
+++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.branchcov0712.test.ts
@@ -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 => ({
...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)`.
diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts
index 17767e0b72..2fc02a3e4c 100644
--- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts
+++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts
@@ -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 => {
+ const metrics: Record = {};
+ 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 => {
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),
diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts
index 52fcbcc4ff..0131b0df70 100644
--- a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts
+++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts
@@ -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,
diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx
index bf2f1419c9..443cbbd5d5 100644
--- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx
+++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx
@@ -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(() => );
+ 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 = {
diff --git a/frontend-modern/src/components/Workloads/guestDrawerModel.ts b/frontend-modern/src/components/Workloads/guestDrawerModel.ts
index 7435d99698..974a0b8f84 100644
--- a/frontend-modern/src/components/Workloads/guestDrawerModel.ts
+++ b/frontend-modern/src/components/Workloads/guestDrawerModel.ts
@@ -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 = {
- '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 = (
diff --git a/frontend-modern/src/utils/memoryObservation.ts b/frontend-modern/src/utils/memoryObservation.ts
index f53e2ff612..e34fe41ad6 100644
--- a/frontend-modern/src/utils/memoryObservation.ts
+++ b/frontend-modern/src/utils/memoryObservation.ts
@@ -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 = {
+ '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.' : ''}`,
+ };
+};