diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index b9e77e82f5..b2359b7bbf 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -69,6 +69,27 @@ The legacy Workloads `DiskList` is exercised only by tests/fixtures, not a
shipped caller. Its empty-state title is not evidence of readable touch guidance;
the shipped guest Overview supplies the readable pre-sample explanation.
+Guest History applies the same provenance to its live legend fallback. A VM
+filesystem reason withdraws the `current` disk fallback; only a `prev-` reason
+with a valid retained value supplies a separately labelled `last known` value.
+Explicit telemetry unavailability and unknown/non-finite values stay unavailable,
+not zero. The readable fixed-reason notice remains beside the utilization group
+even when stored disk observations exist. Its wording identifies a live read,
+not the date or provenance of those stored points. Legend assistive descriptions
+reference that notice. CPU, memory and I/O evidence remain independent.
+
+Deferred live evidence never supplies a chart point, timestamp or dated
+inspection value. Keyboard/pointer inspection continues to show only the
+selected stored observations, and denied History withdraws the whole chart
+including its deferred evidence. Fresh same-VM evidence clears the notice;
+lock clearance without a new read does not. `GuestDrawer.test.tsx`,
+`GuestDrawerHistory.disk-provenance.test.tsx` and `GuestDrawerHistory.sparse.test.tsx`
+cover the model, actual drawer plumbing, shared fallback precedence, retained
+zero, pre-sample status, dated inspection, access denial and resumption.
+`browser-tests/guest-history-provenance.cjs` uses the production full drawer
+and History API client at desktop and touch widths with synthetic responses.
+These checks do not establish native guest thaw, collection or release availability.
+
### Completed guest backup evidence versus current activity
The guest Overview keeps the last completed backup age and its existing
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 bc2298fb1b..4f62c67d56 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,19 @@
## Purpose
+### Guest History live filesystem provenance
+
+The guest drawer derives deferred filesystem context from its current snapshot,
+separately from current CPU, memory and I/O. A retained `prev-` value may populate
+only the labelled last-known legend, never the current fallback or chart geometry.
+The added presentation input does not create a polling timer, trigger a guest
+read, or change History target/range ownership. Stable-target context updates
+reuse the mounted chart and existing query; the drawer's pre-existing target
+recomputation on guest-snapshot changes is unchanged. Mounted regressions in
+`GuestDrawer.test.tsx` and `GuestDrawerHistory.disk-provenance.test.tsx` verify
+the actual fallback/resumption path and request counts. This is presentation
+proof, not a native CPU improvement, QGA/thaw result or delivery acceptance.
+
### Guest filesystem provenance preserves dense row geometry
Visible retained/unavailable filesystem cues must fit the existing dense row
diff --git a/frontend-modern/browser-tests/guest-history-provenance.cjs b/frontend-modern/browser-tests/guest-history-provenance.cjs
new file mode 100644
index 0000000000..66a428810b
--- /dev/null
+++ b/frontend-modern/browser-tests/guest-history-provenance.cjs
@@ -0,0 +1,340 @@
+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/guest-history-provenance';
+const origin = 'http://127.0.0.1:5316';
+const runtime = [
+ 'frontend-modern/src/components/Workloads/GuestDrawer.tsx',
+ 'frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx',
+ 'frontend-modern/src/components/Workloads/guestDrawerModel.ts',
+];
+const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
+const settle = (page) =>
+ page.evaluate(async () => {
+ await document.fonts.ready;
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ });
+(async () => {
+ fs.mkdirSync(output, { recursive: true });
+ const result = {
+ result: 'incomplete',
+ playwright: require('playwright/package.json').version,
+ runtime_hashes: Object.fromEntries(runtime.map((file) => [file, hash('/workspace/' + file)])),
+ cases: [],
+ screenshots: [],
+ limits:
+ 'Production full GuestDrawer/History, API client and CSS with synthetic local responses. Not native QGA/thaw, provider collection, physical-phone, installed recovery 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',
+ server: { host: '127.0.0.1', port: 5316, strictPort: true },
+ });
+ let browser;
+ try {
+ await server.listen();
+ for (const [name, engine, width, dark] of [
+ ['chromium-desktop', chromium, 1365, false],
+ ['chromium-phone', chromium, 390, false],
+ ['webkit-phone', webkit, 360, 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(15000);
+ page.setDefaultNavigationTimeout(60000);
+ let mode = 'empty';
+ const record = {
+ name,
+ width,
+ dark,
+ browser: browser.version(),
+ errors: [],
+ requests: [],
+ states: [],
+ checks: [],
+ };
+ result.cases.push(record);
+ page.on('pageerror', (error) => record.errors.push(error.message));
+ await page.route('**/*', async (route) => {
+ const url = new URL(route.request().url());
+ if (url.origin !== origin) {
+ record.requests.push({ origin: url.origin, blocked: true });
+ return route.abort();
+ }
+ if (!url.pathname.startsWith('/api/')) return route.continue();
+ record.requests.push({ path: url.pathname, method: route.request().method(), mode });
+ if (url.pathname === '/api/metrics-store/history') {
+ if (mode === '403' || mode === '503')
+ return route.fulfill({
+ status: Number(mode),
+ json: { error: 'fixture-private-error-body' },
+ });
+ const start = Date.UTC(2026, 9, 4, 2),
+ end = start + 60 * 60_000;
+ const point = (timestamp, value) => ({ timestamp, value, min: value, max: value });
+ const metrics =
+ mode === 'stored'
+ ? {
+ cpu: [point(start, 10), point(end, 15)],
+ disk: [point(start, 20), point(end, 30)],
+ }
+ : mode === 'cpu-only'
+ ? { cpu: [point(start, 10), point(end, 15)] }
+ : {};
+ return route.fulfill({
+ json: {
+ resourceType: url.searchParams.get('resourceType'),
+ resourceId: url.searchParams.get('resourceId'),
+ range: url.searchParams.get('range'),
+ start,
+ end,
+ metrics,
+ source: 'store',
+ },
+ });
+ }
+ return route.fulfill({
+ json:
+ url.pathname === '/api/license/runtime-capabilities'
+ ? {
+ capabilities: [],
+ limits: [],
+ max_history_days: 7,
+ hosted_mode: false,
+ runtime: { build: 'community' },
+ blocked_capabilities: [],
+ }
+ : { success: true, data: [], alerts: [], anomalies: [], config: null },
+ });
+ });
+ await page.addInitScript(
+ (isDark) =>
+ document.addEventListener('DOMContentLoaded', () => {
+ if (isDark) document.documentElement.classList.add('dark');
+ }),
+ dark,
+ );
+ await page.goto(origin + '/browser-tests/guest-history-provenance.html');
+ await page.waitForFunction(() => window.__guestHistoryProvenance, undefined, {
+ timeout: 60000,
+ });
+ 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"]');
+ const notice = chart.locator('[data-history-deferred="disk"]');
+ const waitRead = async () => {
+ await page.waitForFunction(() => document.querySelector('button[aria-busy="false"]'));
+ await settle(page);
+ };
+ const observe = async (next) => {
+ await page.evaluate((value) => window.__guestHistoryProvenance.observe(value), next);
+ await waitRead();
+ };
+ const screenshot = async (state) => {
+ const file = `${name}-${state}.png`;
+ await page.screenshot({
+ path: output + '/' + file,
+ fullPage: true,
+ animations: 'disabled',
+ });
+ result.screenshots.push({ file, sha256: hash(output + '/' + file), state, name });
+ };
+ const checkGeometry = async () => {
+ const geometry = await notice.evaluate((el) => {
+ const rect = el.getBoundingClientRect(),
+ group = el.closest('section').getBoundingClientRect();
+ const range = document.createRange();
+ range.selectNodeContents(el);
+ return {
+ left: rect.left,
+ right: rect.right,
+ bottom: rect.bottom,
+ groupRight: group.right,
+ textRight: Math.max(...Array.from(range.getClientRects(), (r) => r.right)),
+ plotTop: el.nextElementSibling.getBoundingClientRect().top,
+ overflow: document.documentElement.scrollWidth > innerWidth,
+ };
+ });
+ assert.equal(geometry.overflow, false);
+ assert.ok(
+ geometry.left >= 0 &&
+ geometry.right <= width &&
+ geometry.textRight <= geometry.groupRight + 1,
+ );
+ assert.ok(geometry.bottom <= geometry.plotTop + 1);
+ return geometry;
+ };
+ await waitRead();
+ const reasons = await page.evaluate(() => window.__guestHistoryProvenance.deferrals);
+ for (const [reason, message] of reasons) {
+ for (const retained of [true, false]) {
+ await observe({
+ reason: (retained ? 'prev-' : '') + reason,
+ usage: 50,
+ lock: reason === 'vm-locked' ? 'backup' : '',
+ });
+ assert.equal(await chart.locator('[data-history-current="disk"]').count(), 0);
+ assert.equal(
+ await chart.locator('[data-history-last-known="disk"]').count(),
+ retained ? 1 : 0,
+ );
+ assert.equal((await notice.innerText()).includes(message), true);
+ if (retained) {
+ const legend = chart.locator('[data-history-last-known="disk"]');
+ assert.match(await legend.innerText(), /50\.0%[\s\S]*last known/);
+ assert.equal(
+ await legend.evaluate((el) =>
+ document
+ .getElementById(el.getAttribute('aria-describedby'))
+ .textContent.includes('Using last known'),
+ ),
+ true,
+ );
+ }
+ assert.equal(await chart.locator('path').count(), 0);
+ assert.equal(await chart.locator('[data-history-observation]').count(), 0);
+ assert.equal(await chart.locator('[data-history-current="cpu"]').count(), 1);
+ record.states.push({ reason, retained, geometry: await checkGeometry() });
+ }
+ }
+ await observe({ reason: 'prev-vm-locked', usage: 0 });
+ assert.match(
+ await chart.locator('[data-history-last-known="disk"]').innerText(),
+ /0\.0%[\s\S]*last known/,
+ );
+ record.checks.push('Retained zero is not lost or plotted.');
+ await observe({ reason: 'prev-vm-locked', usage: 50, lock: 'backup' });
+ await screenshot('last-known');
+ await observe({ reason: 'prev-agent-busy', lock: '' });
+ assert.match(await notice.innerText(), /earlier guest request is still in progress/);
+ assert.equal(await chart.locator('[data-history-current="disk"]').count(), 0);
+ record.checks.push('Lock clearance alone does not restore current evidence.');
+ await observe({ reason: 'vm-locked', usage: -1 });
+ assert.equal(await chart.locator('[data-history-last-known]').count(), 0);
+ await screenshot('unavailable');
+ await observe({ reason: 'prev-agent-timeout', usage: 50 });
+ mode = 'stored';
+ await activate(page.getByRole('button', { name: 'Refresh history', exact: true }));
+ await page.waitForFunction(
+ () => document.querySelectorAll('[data-history-group="utilization"] path').length === 2,
+ );
+ await settle(page);
+ const paths = await chart
+ .locator('path')
+ .evaluateAll((nodes) => nodes.map((el) => el.getAttribute('d')));
+ assert.equal(await chart.locator('[data-history-last-known]').count(), 0);
+ assert.match(
+ await notice.innerText(),
+ /Completion is uncertain\. Do not restart the guest agent during a backup/,
+ );
+ const slider = chart.getByRole('slider', { name: 'Inspect Utilization history' });
+ if (width < 768) await slider.tap({ position: { x: 3, y: 20 } });
+ else {
+ await slider.focus();
+ await slider.press('Home');
+ }
+ await settle(page);
+ assert.match(await slider.getAttribute('aria-valuetext'), /Disk 20\.0%/);
+ assert.equal(await chart.locator('[data-history-current]').count(), 0);
+ assert.deepEqual(
+ await chart.locator('path').evaluateAll((nodes) => nodes.map((el) => el.getAttribute('d'))),
+ paths,
+ );
+ await screenshot('stored-inspection');
+ await slider.blur();
+ record.checks.push(
+ 'Keyboard/touch inspection uses actual stored 20% at its own date, not retained 50%; paths remain unchanged.',
+ );
+ mode = 'cpu-only';
+ await activate(page.getByRole('button', { name: 'Refresh history', exact: true }));
+ await page.waitForFunction(
+ () => document.querySelectorAll('[data-history-group="utilization"] path').length === 1,
+ );
+ await settle(page);
+ await slider.focus();
+ await slider.press('Home');
+ assert.match(await slider.getAttribute('aria-valuetext'), /Disk no observation/);
+ assert.equal(await chart.locator('[data-history-last-known]').count(), 0);
+ await slider.blur();
+ assert.equal(await chart.locator('[data-history-last-known="disk"]').count(), 1);
+ record.checks.push('A dated CPU observation cannot borrow the retained filesystem value.');
+ mode = '503';
+ 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(), 1);
+ assert.equal(await notice.count(), 1);
+ assert.equal(
+ (await page.locator('main').innerText()).includes('fixture-private-error-body'),
+ false,
+ );
+ mode = '403';
+ await activate(page.getByRole('button', { name: 'Retry history', exact: true }));
+ await page.waitForFunction(() => !document.querySelector('[data-history-group]'));
+ assert.equal(await notice.count(), 0);
+ assert.equal(await page.locator('[data-history-last-known]').count(), 0);
+ record.checks.push(
+ 'Transient failure keeps prior stored data with warning; final access denial withdraws charts, fallback and notices.',
+ );
+ mode = 'empty';
+ await activate(page.getByRole('button', { name: 'Retry history', exact: true }));
+ await notice.waitFor();
+ await waitRead();
+ await observe({ reason: '', usage: 75, lock: '' });
+ assert.equal(await notice.count(), 0);
+ assert.equal(await chart.locator('[data-history-last-known]').count(), 0);
+ assert.match(
+ await chart.locator('[data-history-current="disk"]').innerText(),
+ /75\.0%[\s\S]*current/,
+ );
+ assert.equal(await chart.locator('path').count(), 0);
+ await screenshot('fresh-resumption');
+ record.checks.push(
+ 'Successful read and fresh same-VM data restore current 75%, clear provenance, and create no history points.',
+ );
+ await observe({ reason: 'prev-vm-locked', kind: 'lxc', usage: 50 });
+ assert.equal(await notice.count(), 0);
+ assert.equal(await chart.locator('[data-history-current="disk"]').count(), 1);
+ assert.equal(
+ record.requests.every((request) => request.method === 'GET' && !request.blocked),
+ true,
+ );
+ assert.deepEqual(record.errors, []);
+ await page.close();
+ await browser.close();
+ browser = null;
+ }
+ result.result = 'passed';
+ } catch (error) {
+ result.error = { name: error.name, message: error.message };
+ throw error;
+ } finally {
+ fs.writeFileSync(output + '/result.json', JSON.stringify(result, null, 2) + '\n');
+ if (browser) await browser.close();
+ await server.close();
+ }
+})().catch((error) => {
+ process.stderr.write(error.stack + '\n');
+ process.exitCode = 1;
+});
diff --git a/frontend-modern/browser-tests/guest-history-provenance.html b/frontend-modern/browser-tests/guest-history-provenance.html
new file mode 100644
index 0000000000..f4fad04d47
--- /dev/null
+++ b/frontend-modern/browser-tests/guest-history-provenance.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Guest History filesystem provenance verification
+
+
+
+
+
+
diff --git a/frontend-modern/browser-tests/guest-history-provenance.tsx b/frontend-modern/browser-tests/guest-history-provenance.tsx
new file mode 100644
index 0000000000..60400436e4
--- /dev/null
+++ b/frontend-modern/browser-tests/guest-history-provenance.tsx
@@ -0,0 +1,85 @@
+// Full production drawer/CSS and client; observations and API responses are
+// synthetic. No backup, guest-agent or native recovery operation is performed.
+import { createMemo, createSignal } from 'solid-js';
+import { render } from 'solid-js/web';
+import { Router, Route } from '@solidjs/router';
+import { GuestDrawer } from '../src/components/Workloads/GuestDrawer';
+import { DarkModeContext } from '../src/contexts/appRuntime';
+import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
+import type { WorkloadGuest } from '../src/types/workloads';
+import { guestDiskDeferrals } from '../src/components/Workloads/__fixtures__/guestDiskDeferrals';
+import '../src/index.css';
+
+syncAIRuntimeSettings({ discovery_enabled: false });
+
+function Fixture() {
+ const [observation, setObservation] = createSignal({
+ reason: 'prev-vm-locked',
+ usage: 50,
+ lock: 'backup',
+ kind: 'qemu',
+ });
+ const guest = createMemo(() => ({
+ id: 'fixture-pve:pve-a:101',
+ vmid: 101,
+ name: 'backup-guest',
+ node: 'pve-a',
+ instance: 'fixture-pve',
+ status: 'running',
+ type: observation().kind,
+ cpu: 0.1,
+ cpus: 2,
+ memory: { total: 1024 ** 3, used: 256 * 1024 ** 2, free: 768 * 1024 ** 2, usage: 25 },
+ disk: {
+ total: 10 * 1024 ** 3,
+ used: (observation().usage / 100) * 10 * 1024 ** 3,
+ usage: observation().usage,
+ },
+ diskStatusReason: observation().reason,
+ lock: observation().lock,
+ networkIn: 0,
+ networkOut: 0,
+ diskRead: 0,
+ diskWrite: 0,
+ uptime: 3600,
+ template: false,
+ lastBackup: 0,
+ backupInProgress: Boolean(observation().lock),
+ tags: [],
+ lastSeen: '2026-10-04T03:00:00Z',
+ }));
+ (window as unknown as Record).__guestHistoryProvenance = {
+ observe: (next: Partial>) =>
+ setObservation((prev) => ({ ...prev, ...next })),
+ deferrals: guestDiskDeferrals,
+ };
+ return (
+
+ Guest History filesystem provenance
+
+ Synthetic observations. No backup or guest-agent command is sent.
+
+
+
+ );
+}
+
+render(
+ () => (
+
+ (
+ document.documentElement.classList.contains('dark')}
+ >
+
+
+ )}
+ />
+
+ ),
+ document.getElementById('root')!,
+);
diff --git a/frontend-modern/browser-verification/d5b903be5d5f5368.json b/frontend-modern/browser-verification/d5b903be5d5f5368.json
new file mode 100644
index 0000000000..edab379a9c
--- /dev/null
+++ b/frontend-modern/browser-verification/d5b903be5d5f5368.json
@@ -0,0 +1,44 @@
+{
+ "version": 1,
+ "base_sha": "d77f56bee4e2e79c9c41f1244f9a1b3e65b60dbb",
+ "verified_at": "2026-10-04T03:28:54Z",
+ "result": "passed",
+ "changed_paths": [
+ "frontend-modern/src/components/Workloads/GuestDrawer.tsx",
+ "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx",
+ "frontend-modern/src/components/Workloads/guestDrawerModel.ts"
+ ],
+ "content_sha256": {
+ "frontend-modern/src/components/Workloads/GuestDrawer.tsx": "8713b06fd7a8ec67d111b1b97ff84c9421e9bd738b769b28c09d5217dd41f50a",
+ "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "7422c574ca1dc1ce8ffc9d28b176656a9f9be951f319f1fa8ef38e8a9b4aec8c",
+ "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "eb057472aa06df1aa2f687a6d99f6409ecf849817fc70fad1e4c23e623bb1d9f"
+ },
+ "routes": [
+ "/browser-tests/guest-history-provenance.html"
+ ],
+ "viewports": [
+ {
+ "width": 1365,
+ "height": 900
+ },
+ {
+ "width": 390,
+ "height": 844
+ },
+ {
+ "width": 360,
+ "height": 844
+ }
+ ],
+ "states": [
+ "Production full GuestDrawer, History renderer, model, API client and CSS with synthetic transport/observations; desktop/phone Chromium and dark phone WebKit. Playwright 1.56.1 matches integration lock. All twelve final screenshots opened.",
+ "48 fixed-deferral states: eight reasons x retained/unavailable x three contexts, visible safe reason with fitting geometry, independently current CPU/memory and never a current disk during deferral. Retained zero and pre-sample unavailable controls pass.",
+ "Stored disk 20/30% remains dated chart evidence, independent from retained live 50%; dated CPU-only inspection reports no disk observation. Fallbacks never become plotted points.",
+ "No physical phone, native QGA/backup/thaw/collection, installed recovery, release publication or customer acceptance established. Earlier failed fixture assertions/startup/tab attempts retained separately; only corrected fixture is accepted."
+ ],
+ "interactions": [
+ "Real mouse/phone touch opens the existing full drawer History tab and native refresh; desktop keyboard Home and phone touch range inspection use actual stored 20% at its own date without altering paths.",
+ "Lock clearance with an in-flight read reason keeps the live note and last-known context. Successful fresh same-VM 75% withdraws provenance, updates current fallback and creates no history. Non-VM control is unaffected.",
+ "Real same-origin synthetic 503 retains stored observations with warning; final 403 withdraws chart, fallback and deferral note until successful recovery. Private fixture error body stays hidden. All API requests are GET and no page errors occurred."
+ ]
+}
diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx
index 47d2960bba..a357439143 100644
--- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx
+++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx
@@ -220,6 +220,59 @@ afterEach(() => {
// ── Tests ──────────────────────────────────────────────────────────────
describe('GuestDrawer', () => {
+ it('keeps deferred filesystem fallback last-known through History and fresh resumption', async () => {
+ chartsApiMocks.getMetricsHistory.mockResolvedValue({
+ resourceType: 'vm',
+ resourceId: 'inst1:node1:100',
+ range: '24h',
+ start: 1,
+ end: 3,
+ metrics: {},
+ source: 'store',
+ });
+ const [guest, setGuest] = createSignal(
+ makeGuest({
+ disk: { total: 100, used: 50, usage: 50 },
+ diskStatusReason: 'prev-vm-locked',
+ lock: 'backup',
+ }),
+ );
+ render(() => );
+ fireEvent.click(screen.getByRole('tab', { name: 'History' }));
+ await waitFor(() =>
+ expect(screen.getAllByText('No stored history in this range')).toHaveLength(3),
+ );
+ const history = screen.getAllByTestId('guest-history-group-chart')[0];
+ expect(history.querySelector('[data-history-current="disk"]')).toBeNull();
+ expect(history.querySelector('[data-history-last-known="disk"]')).toHaveTextContent(
+ 'Disk50.0%last known',
+ );
+ expect(history.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(
+ 'Guest reads paused while Proxmox reports a VM operation lock',
+ );
+ setGuest({ ...guest(), lock: '', diskStatusReason: 'prev-agent-busy' });
+ expect(history.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(
+ 'earlier guest request is still in progress',
+ );
+ expect(history.querySelector('[data-history-current="disk"]')).toBeNull();
+ setGuest({ ...guest(), diskStatusReason: '', disk: { total: 100, used: 75, usage: 75 } });
+ expect(history.querySelector('[data-history-last-known]')).toBeNull();
+ expect(history.querySelector('[data-history-deferred]')).toBeNull();
+ expect(history.querySelector('[data-history-current="disk"]')).toHaveTextContent(
+ 'Disk75.0%current',
+ );
+ expect(history.querySelector('path')).toBeNull();
+ // Existing drawer target recomputation follows each guest snapshot.
+ // This presentation repair does not change that request ownership.
+ for (const [request] of chartsApiMocks.getMetricsHistory.mock.calls) {
+ expect(request).toMatchObject({
+ resourceType: 'vm',
+ resourceId: 'inst1:node1:100',
+ range: '24h',
+ });
+ }
+ });
+
it('uses the shared discovery loading fallback instead of a drawer-local spinner row', () => {
expect(guestDrawerSource).toContain('DiscoveryLoadingFallback');
expect(guestDrawerSource).not.toContain(
diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.tsx
index 291b30525e..1439f7a212 100644
--- a/frontend-modern/src/components/Workloads/GuestDrawer.tsx
+++ b/frontend-modern/src/components/Workloads/GuestDrawer.tsx
@@ -6,7 +6,11 @@ import { DiscoveryReadinessBadge } from '@/components/shared/DiscoveryReadinessB
import { ObjectDrawerHeader } from '@/components/shared/ObjectDrawerHeader';
import { Subtabs, type SubtabOption } from '@/components/shared/Subtabs';
import { getSimpleStatusIndicator } from '@/utils/status';
-import { getGuestDrawerCurrentMetrics, type GuestDrawerProps } from './guestDrawerModel';
+import {
+ getGuestDrawerCurrentMetrics,
+ getGuestDrawerDeferredMetrics,
+ type GuestDrawerProps,
+} from './guestDrawerModel';
import { useGuestDrawerState } from './useGuestDrawerState';
import { GuestDrawerHistory, GuestDrawerHistoryRangeSelect } from './GuestDrawerHistory';
import { GuestDrawerOverview } from './GuestDrawerOverview';
@@ -49,6 +53,7 @@ export const GuestDrawer: Component = (props) => {
} = useGuestDrawerState(props);
const headingId = () => `guest-drawer-heading-${guestId()}`;
const historyCurrentMetrics = createMemo(() => getGuestDrawerCurrentMetrics(props.guest));
+ const historyDeferredMetrics = createMemo(() => getGuestDrawerDeferredMetrics(props.guest));
const headerIndicator = createMemo(() => getSimpleStatusIndicator(props.guest.status));
@@ -132,6 +137,7 @@ export const GuestDrawer: Component = (props) => {
target={historyTarget()}
range={historyRange()}
currentMetrics={historyCurrentMetrics()}
+ deferredMetrics={historyDeferredMetrics()}
/>
)}
diff --git a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx
index bf2b5246bc..c8881b9407 100644
--- a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx
+++ b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx
@@ -41,12 +41,14 @@ import {
getGuestDrawerHistoryValueLabel,
normalizeGuestDrawerHistoryPoints,
type GuestDrawerHistoryGroupConfig,
+ type GuestDrawerHistoryDeferredMetric,
type GuestDrawerHistoryTarget,
type GuestDrawerHistoryTimeBounds,
} from './guestDrawerModel';
interface GuestDrawerHistoryProps {
currentMetrics?: Record;
+ deferredMetrics?: Record;
groups?: GuestDrawerHistoryGroupConfig[];
range: HistoryTimeRange;
target: GuestDrawerHistoryTarget | null;
@@ -65,6 +67,7 @@ interface GuestDrawerHistoryQueryKey {
interface GuestDrawerHistoryGroupChartProps {
currentMetrics?: Record;
+ deferredMetrics?: Record;
group: GuestDrawerHistoryGroupConfig;
loading: boolean;
metrics: Record;
@@ -309,24 +312,31 @@ const GuestDrawerHistoryGroupChart: Component
return byMetric;
});
const displaySeries = createMemo(() =>
- series().map((item) => {
+ series().map((item, index) => {
const hovered = hoveredByMetric().get(item.metric);
- const currentValue = props.currentMetrics?.[item.metric];
+ const deferred = props.deferredMetrics?.[item.metric];
+ const fallbackValue = deferred
+ ? deferred.lastKnownValue
+ : props.currentMetrics?.[item.metric];
+ const isFallback =
+ activeTimestamp() === null &&
+ item.points.length === 0 &&
+ typeof fallbackValue === 'number' &&
+ Number.isFinite(fallbackValue);
return {
...item,
- isCurrent:
- activeTimestamp() === null &&
- item.points.length === 0 &&
- typeof currentValue === 'number' &&
- Number.isFinite(currentValue),
+ deferred,
+ descriptionId: `${inspectionId}-deferred-${index}`,
+ isCurrent: isFallback && !deferred,
+ isLastKnown: isFallback && Boolean(deferred),
valueLabel: hovered
? getGuestDrawerHistoryValueLabel([hovered.point], item.unit)
: activeTimestamp() !== null
? '-'
: item.points.length > 0
? getGuestDrawerHistoryValueLabel(item.points, item.unit)
- : typeof currentValue === 'number' && Number.isFinite(currentValue)
- ? formatHistoryChartTooltipValue(currentValue, item.unit)
+ : typeof fallbackValue === 'number' && Number.isFinite(fallbackValue)
+ ? formatHistoryChartTooltipValue(fallbackValue, item.unit)
: '-',
};
}),
@@ -382,6 +392,8 @@ const GuestDrawerHistoryGroupChart: Component
)}
+ item.deferred)}>
+ {(item) => (
+
+ {item.label} live reading: {item.deferred?.message}
+
+ )}
+
+
{(tick) => (
@@ -720,6 +747,7 @@ export const GuestDrawerHistory: Component = (props) =>
loading={historyQuery.loading() && !historyQuery.resolvedOnce()}
metrics={metrics()}
currentMetrics={props.currentMetrics}
+ deferredMetrics={props.deferredMetrics}
range={props.range}
sourceKey={`${props.target?.resourceType}:${props.target?.resourceId}:${props.range}`}
timeBounds={timeBounds()}
diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.disk-provenance.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.disk-provenance.test.tsx
new file mode 100644
index 0000000000..6c1bcb45e5
--- /dev/null
+++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.disk-provenance.test.tsx
@@ -0,0 +1,261 @@
+import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
+import { createSignal } from 'solid-js';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { ChartsAPI, type AllMetricsHistoryResponse } from '@/api/charts';
+import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
+import type { WorkloadGuest } from '@/types/workloads';
+import { GuestDrawerHistory } from '../GuestDrawerHistory';
+import { getGuestDrawerCurrentMetrics, getGuestDrawerDeferredMetrics } from '../guestDrawerModel';
+import { guestDiskDeferrals } from '../__fixtures__/guestDiskDeferrals';
+
+vi.mock('@/stores/license', () => ({
+ loadRuntimeCapabilities: vi.fn(async () => undefined),
+ maxHistoryDays: () => 90,
+ isRangeLocked: () => false,
+}));
+
+afterEach(() => {
+ cleanup();
+ resetCreateNonSuspendingQueryCacheForTest();
+ vi.restoreAllMocks();
+});
+
+const guest = (reason: string, usage = 50): WorkloadGuest => ({
+ id: 'fixture:pve-a:101',
+ vmid: 101,
+ name: 'backup-guest',
+ node: 'pve-a',
+ instance: 'fixture',
+ type: 'qemu',
+ status: 'running',
+ cpu: 0.1,
+ cpus: 2,
+ memory: { total: 100, used: 25, free: 75, usage: 25 },
+ disk: { total: 100, used: usage, usage },
+ networkIn: 100,
+ networkOut: 200,
+ diskRead: 300,
+ diskWrite: 400,
+ uptime: 3600,
+ template: false,
+ lastBackup: 0,
+ tags: [],
+ lock: '',
+ diskStatusReason: reason,
+ lastSeen: '2026-10-04T03:00:00Z',
+});
+const start = Date.UTC(2026, 9, 4, 2);
+const end = start + 60 * 60_000;
+const response = (
+ metrics: AllMetricsHistoryResponse['metrics'] = {},
+): AllMetricsHistoryResponse => ({
+ resourceType: 'vm',
+ resourceId: 'fixture:pve-a:101',
+ range: '1h',
+ start,
+ end,
+ metrics,
+ source: 'store',
+});
+const point = (timestamp: number, value: number) => ({ timestamp, value, min: value, max: value });
+const target = { resourceType: 'vm' as const, resourceId: 'fixture:pve-a:101' };
+const mount = (value: () => WorkloadGuest) =>
+ render(() => (
+
+ ));
+const utilization = () => screen.getAllByTestId('guest-history-group-chart')[0];
+
+// Actual backend provenance remains authoritative. Removing a VM lock alone
+// cannot turn retained readings into current data or prove native thaw.
+describe('GuestDrawerHistory filesystem provenance', () => {
+ it.each(guestDiskDeferrals)(
+ 'labels retained %s as last known without creating a stored observation',
+ async (reason, message) => {
+ const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
+ mount(() => guest(`prev-${reason}`));
+ await waitFor(() =>
+ expect(screen.getAllByText('No stored history in this range')).toHaveLength(3),
+ );
+ const chart = utilization();
+ expect(chart.querySelector('[data-history-current="disk"]')).toBeNull();
+ const retained = chart.querySelector('[data-history-last-known="disk"]')!;
+ expect(retained).toHaveTextContent('Disk50.0%last known');
+ expect(retained).toHaveAccessibleDescription(
+ `Disk live reading: Using last known disk stats. ${message}`,
+ );
+ expect(chart.querySelector('[data-history-deferred="disk"]')).toBeVisible();
+ expect(chart.querySelector('[data-history-current="cpu"]')).toHaveTextContent(
+ 'CPU10.0%current',
+ );
+ expect(chart.querySelector('[data-history-current="memory"]')).toHaveTextContent(
+ 'Memory25.0%current',
+ );
+ expect(chart.querySelector('path')).toBeNull();
+ expect(chart.querySelector('[data-history-observation]')).toBeNull();
+ expect(fetch).toHaveBeenCalledTimes(1);
+ },
+ );
+
+ it.each(guestDiskDeferrals)(
+ 'shows unavailable %s without promoting an unretained numeric summary',
+ async (reason, message) => {
+ vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
+ mount(() => guest(reason));
+ await waitFor(() =>
+ expect(screen.getAllByText('No stored history in this range')).toHaveLength(3),
+ );
+ const chart = utilization();
+ expect(chart.querySelector('[data-history-current="disk"]')).toBeNull();
+ expect(chart.querySelector('[data-history-last-known]')).toBeNull();
+ expect(chart).toHaveTextContent('Disk-');
+ expect(chart.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(message);
+ },
+ );
+
+ it.each([-1, NaN, Infinity, undefined])(
+ 'does not display unusable retained disk usage %s',
+ (usage) => {
+ const value = guest('prev-vm-locked');
+ value.disk.usage = usage as number;
+ expect(getGuestDrawerCurrentMetrics(value).disk).toBeUndefined();
+ expect(getGuestDrawerDeferredMetrics(value).disk.lastKnownValue).toBeUndefined();
+ expect(getGuestDrawerDeferredMetrics(value).disk.message).toContain('Guest reads paused');
+ },
+ );
+
+ it('keeps retained zero valid and honours explicit disk telemetry unavailability', () => {
+ const value = guest('prev-agent-busy', 0);
+ expect(getGuestDrawerDeferredMetrics(value).disk.lastKnownValue).toBe(0);
+ value.telemetryAvailability = {
+ cpu: true,
+ memory: true,
+ disk: false,
+ networkIO: true,
+ diskIO: true,
+ uptime: true,
+ };
+ expect(getGuestDrawerDeferredMetrics(value).disk.lastKnownValue).toBeUndefined();
+ expect(getGuestDrawerCurrentMetrics(value).disk).toBeUndefined();
+ expect(getGuestDrawerCurrentMetrics(value)).toMatchObject({
+ cpu: 10,
+ memory: 25,
+ diskread: 300,
+ diskwrite: 400,
+ });
+ });
+
+ it('keeps non-VM data independent and maps unknown reason text without exposing it', () => {
+ for (const type of ['lxc', 'app-container'] as const) {
+ const value = { ...guest('prev-vm-locked'), type };
+ expect(getGuestDrawerDeferredMetrics(value)).toEqual({});
+ expect(getGuestDrawerCurrentMetrics(value).disk).toBe(50);
+ }
+ const value = guest('private transport error');
+ expect(getGuestDrawerDeferredMetrics(value).disk.message).toBe(
+ 'Disk stats unavailable. Guest agent may not be installed.',
+ );
+ expect(getGuestDrawerCurrentMetrics(value).disk).toBeUndefined();
+ });
+
+ it('keeps real stored points and dated keyboard inspection separate from live deferral', async () => {
+ const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
+ response({
+ disk: [point(start, 20), point(end, 30)],
+ cpu: [point(start, 10), point(end, 15)],
+ }),
+ );
+ mount(() => guest('prev-agent-timeout'));
+ await waitFor(() => expect(utilization().querySelectorAll('path')).toHaveLength(2));
+ const chart = utilization();
+ expect(chart).toHaveTextContent('Disk30.0%');
+ expect(chart.querySelector('[data-history-last-known]')).toBeNull();
+ expect(chart.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(
+ 'Completion is uncertain. Do not restart the guest agent during a backup.',
+ );
+ const slider = within(chart).getByRole('slider');
+ slider.focus();
+ fireEvent.input(slider, { target: { value: '0' } });
+ expect(slider).toHaveAttribute(
+ 'aria-valuetext',
+ `${new Date(start).toLocaleString()}. CPU 10.0%. Memory no observation. Disk 20.0%.`,
+ );
+ expect(chart).toHaveTextContent('Disk20.0%');
+ expect(chart.querySelector('[data-history-current]')).toBeNull();
+ expect(chart.querySelector('[data-history-deferred="disk"]')).toBeVisible();
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not borrow retained live disk evidence during another metric's dated inspection", async () => {
+ vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(
+ response({ cpu: [point(start, 10), point(end, 15)] }),
+ );
+ mount(() => guest('prev-vm-locked'));
+ await waitFor(() => expect(screen.getByRole('slider')).toBeInTheDocument());
+ const slider = screen.getByRole('slider');
+ slider.focus();
+ fireEvent.input(slider, { target: { value: '0' } });
+ expect(utilization()).toHaveTextContent('Disk-');
+ expect(utilization().querySelector('[data-history-last-known]')).toBeNull();
+ expect(slider).toHaveAttribute(
+ 'aria-valuetext',
+ expect.stringContaining('Disk no observation.'),
+ );
+ fireEvent.blur(slider);
+ expect(utilization().querySelector('[data-history-last-known="disk"]')).toHaveTextContent(
+ '50.0%last known',
+ );
+ });
+
+ it('withdraws retained context only with fresh same-source data, without refetch or remount', async () => {
+ const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
+ const [value, setValue] = createSignal(guest('prev-vm-locked'));
+ mount(value);
+ await waitFor(() =>
+ expect(screen.getAllByText('No stored history in this range')).toHaveLength(3),
+ );
+ const chart = utilization();
+ setValue({ ...value(), lock: '', diskStatusReason: 'prev-agent-busy' });
+ expect(chart.querySelector('[data-history-deferred="disk"]')).toHaveTextContent(
+ 'still in progress',
+ );
+ expect(chart.querySelector('[data-history-current="disk"]')).toBeNull();
+ setValue(guest('', 75));
+ expect(utilization()).toBe(chart);
+ expect(chart.querySelector('[data-history-deferred]')).toBeNull();
+ expect(chart.querySelector('[data-history-last-known]')).toBeNull();
+ expect(chart.querySelector('[data-history-current="disk"]')).toHaveTextContent('75.0%current');
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not reveal retained evidence through denied History', async () => {
+ const fetch = vi
+ .spyOn(ChartsAPI, 'getMetricsHistory')
+ .mockResolvedValueOnce(response())
+ .mockRejectedValueOnce(Object.assign(new Error('private refusal'), { status: 403 }))
+ .mockResolvedValueOnce(response());
+ mount(() => guest('prev-vm-locked'));
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Refresh history' })).toHaveAttribute(
+ 'aria-busy',
+ 'false',
+ ),
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'Refresh history' }));
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Retry history' })).toBeInTheDocument(),
+ );
+ expect(screen.queryByTestId('guest-history-group-chart')).not.toBeInTheDocument();
+ expect(document.querySelector('[data-history-deferred]')).toBeNull();
+ expect(screen.queryByText('private refusal')).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Retry history' }));
+ await waitFor(() =>
+ expect(utilization().querySelector('[data-history-last-known]')).not.toBeNull(),
+ );
+ expect(fetch).toHaveBeenCalledTimes(3);
+ });
+});
diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx
index 84e17dd262..5ca01afbf3 100644
--- a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx
+++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.sparse.test.tsx
@@ -49,6 +49,30 @@ const group = (id = 'utilization') =>
const dots = () => document.querySelectorAll('[data-history-observation]');
describe('GuestDrawerHistory sparse observations and current provenance', () => {
+ it('gives explicit deferred evidence precedence over a numeric current fallback', async () => {
+ vi.spyOn(ChartsAPI, 'getMetricsHistory').mockResolvedValue(response());
+ render(() => (
+
+ ));
+ await waitFor(() =>
+ expect(screen.getAllByText('No stored history in this range')).toHaveLength(3),
+ );
+ expect(group().querySelector('[data-history-current]')).toBeNull();
+ expect(group().querySelector('[data-history-last-known="disk"]')).toHaveTextContent(
+ 'Disk50.0%last known',
+ );
+ expect(group().querySelector('[data-history-last-known="disk"]')).toHaveAccessibleDescription(
+ 'Disk live reading: Guest reads paused.',
+ );
+ expect(group().querySelector('path')).toBeNull();
+ expect(dots()).toHaveLength(0);
+ });
+
it.each([
['cpu', 'utilization', 'CPU 0.0%'],
['netin', 'network', 'In 0 B/s'],
diff --git a/frontend-modern/src/components/Workloads/guestDrawerModel.ts b/frontend-modern/src/components/Workloads/guestDrawerModel.ts
index 88fcafee6b..47ba99773e 100644
--- a/frontend-modern/src/components/Workloads/guestDrawerModel.ts
+++ b/frontend-modern/src/components/Workloads/guestDrawerModel.ts
@@ -8,7 +8,10 @@ import type {
import { formatHistoryChartTooltipValue } from '@/components/shared/historyChartModel';
import { formatBytes, formatPercent, getBackupInfo, type BackupThresholds } from '@/utils/format';
-import { getWorkloadsGuestBackupStatusPresentation } from '@/utils/workloadGuestPresentation';
+import {
+ getWorkloadGuestDiskStatusMessage,
+ getWorkloadsGuestBackupStatusPresentation,
+} from '@/utils/workloadGuestPresentation';
import { getWorkloadCPUPercent, resolveWorkloadType } from '@/utils/workloads';
import { getWorkloadMetricHistoryTarget } from '@/utils/workloadMetricHistoryTarget';
import type { NestedWorkloadContext } from './nestedWorkloadContext';
@@ -60,6 +63,11 @@ export interface GuestDrawerHistoryTimeBounds {
endTime: number;
}
+export interface GuestDrawerHistoryDeferredMetric {
+ lastKnownValue?: number;
+ message: string;
+}
+
export interface GuestDrawerBackupPresentation {
ageClass: string;
ageLabel: string;
@@ -93,6 +101,25 @@ export const getGuestDrawerAlertMessage = (
return `${alert.message} (${comparison})`;
};
+const getGuestDrawerDiskUsage = (guest: Guest): number | undefined => {
+ const usage = guest.telemetryAvailability?.disk === false ? undefined : guest.disk?.usage;
+ return typeof usage === 'number' && Number.isFinite(usage) && usage >= 0 ? usage : undefined;
+};
+
+export const getGuestDrawerDeferredMetrics = (
+ guest: Guest,
+): Record => {
+ if (!isGuestDrawerVM(guest) || !guest.diskStatusReason) return {};
+ return {
+ disk: {
+ lastKnownValue: guest.diskStatusReason.startsWith('prev-')
+ ? getGuestDrawerDiskUsage(guest)
+ : undefined,
+ message: getWorkloadGuestDiskStatusMessage(guest.diskStatusReason),
+ },
+ };
+};
+
// Current-value metrics displayed beside history legends while the metrics
// store is still accumulating samples. These values never become chart points:
// a current reading is not evidence of a historical trend.
@@ -103,13 +130,10 @@ export const getGuestDrawerCurrentMetrics = (guest: Guest): Record= 0
- ? reportedDiskUsage
- : undefined;
+ isGuestDrawerVM(guest) && guest.diskStatusReason ? undefined : getGuestDrawerDiskUsage(guest);
const finite = (value: number | undefined): number | undefined =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
return {