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 2877df4d3a..1b95e57542 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,28 @@ ## Purpose +### Workload projection preserves native guest-read evidence + +`useWorkloads` forwards the existing Proxmox disk-status reason, guest-agent +state/expected flag and VM operation lock through both its resource-API and +owned canonical-snapshot paths. Numeric retained metrics cannot erase those +read-state facts. `useGuestRowState` also withdraws bars/current-value labels +for non-retained deferrals or unavailable canonical telemetry, rather than +reviving a richer stale number. Same-identity read-state changes participate in the existing +row signature even when numeric values are unchanged; unrelated rows retain +their identity. No extra resource read, timer, workload command or graph is added. + +Full and fast canonical facet merges share the same bounded optional-field +clearing: a native VM facet with positive VMID, runtime state and explicit +guest-agent outcome supersedes its omitted disk-status reason, lock and +expected flag. Other richer fields and partial/legacy omission remain retained. +`useWorkloads.test.ts` and `resourceStateAdapters.test.ts` verify forwarding, +clearing, false/zero values, unchanged peer-site rows and full/fast equivalence. +`browser-tests/workload-read-evidence.cjs` composes the real hook, canonical +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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 109e1ad0bb..73db5c65df 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -23,6 +23,37 @@ and sort the complete canonical change table while startup and ingestion wait. ## Purpose +### Native VM read outcomes survive browser projection and recovery + +The existing Proxmox guest read-state facet remains intact at the WorkloadGuest +boundary: diskStatusReason, guestAgentStatus, guestAgentExpected and lock are +forwarded from the resource API or the platform's owned canonical snapshot. +A retained numeric disk reading does not become current merely by passing +through that adapter, and an operation lock is not silently discarded. +Rows withdraw numeric bars/sparkline labels for a non-retained deferral or +explicitly unavailable canonical telemetry, even if the merged resource still +carries old numeric values. Valid `prev-` and measured-zero readings remain. +`GuestRow.test.tsx` verifies this with numeric stale props, not only sentinel data. + +Optional native VM fields are omitted on the wire when empty/false. When a new +facet identifies a positive VMID and explicitly reports runtime state and a +guest-agent outcome, omitted diskStatusReason/lock/guestAgentExpected replace +the previous values rather than keeping an obsolete deferral or backup lock. +This applies to the canonical and compatibility provider facets and the +validated fast-delta path. Partial or legacy facets without that combination +retain previous evidence; no generic omission-based clearing is introduced. +Other richer metadata, source withdrawal, canonical identity, History samples +and permissions remain unchanged. A guest-agent state or cleared lock proves +neither filesystem thaw nor workload liveness. + +The owning hook and adapter regressions cover REST/snapshot forwarding, +retained/unavailable/fresh transitions with unchanged numbers, explicit false, +measured zero, partial omissions and full/delta/fast recovery. The connected +`browser-tests/workload-read-evidence.cjs` checks actual production row, Overview +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 diff --git a/frontend-modern/browser-tests/workload-read-evidence.cjs b/frontend-modern/browser-tests/workload-read-evidence.cjs new file mode 100644 index 0000000000..69fc5654bc --- /dev/null +++ b/frontend-modern/browser-tests/workload-read-evidence.cjs @@ -0,0 +1,354 @@ +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/workload-read-evidence'; +const origin = 'http://127.0.0.1:5318'; +const runtime = [ + 'frontend-modern/src/hooks/useWorkloads.ts', + 'frontend-modern/src/components/Workloads/useGuestRowState.ts', + 'frontend-modern/src/utils/resourceStateAdapters.ts', +]; +const hash = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); +const initial = () => ({ + reason: 'prev-vm-locked', + usage: 50, + lock: 'backup', + status: 'deferred', + expected: true, +}); +const resource = (read) => { + const capacity = 10 * 1024 ** 3; + return { + id: 'fixture-a-pve1-101', + type: 'vm', + name: 'backup-guest', + status: 'online', + sources: ['proxmox'], + lastSeen: '2026-10-04T05:00:00Z', + metrics: { + cpu: { percent: 10 }, + memory: { percent: 25, used: capacity / 4, total: capacity }, + ...(read.usage === undefined + ? {} + : { disk: { percent: read.usage, used: (capacity * read.usage) / 100, total: capacity } }), + }, + proxmox: { + vmid: 101, + nodeName: 'pve1', + instance: 'fixture-a', + runtimeStatus: 'running', + guestAgentStatus: read.status, + disks: + read.usage === undefined + ? [] + : [ + { + mountpoint: '/data', + filesystem: 'ext4', + total: capacity, + used: (capacity * read.usage) / 100, + }, + ], + ...(read.reason === undefined ? {} : { diskStatusReason: read.reason }), + ...(read.lock === undefined ? {} : { lock: read.lock }), + ...(read.expected === undefined ? {} : { guestAgentExpected: read.expected }), + }, + }; +}; +(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: + 'Real useWorkloads API client and owned snapshot, full/delta/fast canonical merges, production GuestRow and GuestDrawer/CSS. Synthetic read state and local API responses; no native QGA/thaw, workload liveness, installed or physical-phone acceptance, publication or release proof.', + }; + 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: 5318, strictPort: true }, + }); + let browser; + try { + await server.listen(); + for (const [name, engine, width, dark] of [ + ['chromium-desktop', chromium, 1365, false], + ['webkit-phone', webkit, 390, true], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + for (const transport of ['api', 'canonical-full', 'canonical-delta', 'canonical-fast']) { + 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 read = initial(); + const record = { + name, + transport, + width, + dark, + browser: browser.version(), + errors: [], + requests: [], + states: [], + }; + result.cases.push(record); + page.on('pageerror', (e) => record.errors.push(e.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() }); + assert.equal(route.request().method(), 'GET'); + if (url.pathname === '/api/resources') + return route.fulfill({ json: { data: [resource(read)], meta: { totalPages: 1 } } }); + if (url.pathname === '/api/metrics-store/history') + return route.fulfill({ + json: { + resourceType: url.searchParams.get('resourceType'), + resourceId: url.searchParams.get('resourceId'), + range: url.searchParams.get('range'), + start: Date.UTC(2026, 9, 4, 2), + end: Date.UTC(2026, 9, 4, 3), + 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/workload-read-evidence.html?transport=' + transport, + ); + await page.getByRole('region', { name: 'Workload row' }).waitFor(); + const row = page.getByRole('region', { name: 'Workload row' }); + const cell = row.locator('[data-workload-col="disk"]'); + const badge = row.locator('[data-workload-disk-read-status]'); + const drawer = page.getByRole('region', { name: 'Guest details' }); + const activate = (locator) => (width < 768 ? locator.tap() : locator.click()); + const history = drawer.getByRole('tab', { name: 'History', exact: true }); + await activate(history); + const chart = drawer.locator('[data-history-group="utilization"]'); + await chart.waitFor(); + await page.waitForFunction(() => document.querySelector('button[aria-busy="false"]')); + await page.evaluate(() => { + window.__rowIdentity = document.querySelector('[data-workload-col="disk"]'); + window.__chartIdentity = document.querySelector('[data-history-group="utilization"]'); + }); + const evidence = () => page.evaluate(() => window.__workloadReadEvidence.evidence()); + const id = (await evidence()).id; + const observe = async (value) => { + read = value; + await page.evaluate(async (next) => window.__workloadReadEvidence.update(next), value); + await page.evaluate(async () => { + await document.fonts.ready; + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + }); + assert.equal((await evidence()).id, id); + assert.equal( + await page.evaluate( + () => window.__rowIdentity === document.querySelector('[data-workload-col="disk"]'), + ), + true, + ); + assert.equal( + await page.evaluate( + () => + window.__chartIdentity === + document.querySelector('[data-history-group="utilization"]'), + ), + true, + ); + }; + const check = async (state, reason, retained, usage, lock, agent, expected) => { + const current = await evidence(); + assert.equal(current.reason, reason); + assert.equal(current.lock, lock || ''); + assert.equal(current.agent, agent); + assert.equal(current.expected, expected); + const trigger = cell.locator('[data-stacked-disk-trigger]'); + assert.equal(await trigger.count(), reason && !retained ? 0 : 1); + if (!reason || retained) { + await page.waitForFunction((text) => { + const value = document.querySelector( + '[data-workload-col="disk"] [data-animated-number]', + ); + return ( + value && value.textContent === text && value.getAttribute('aria-label') === text + ); + }, usage + '%'); + } else { + assert.equal(await cell.locator('[data-animated-number]').count(), 0); + assert.equal((await cell.innerText()).includes('50%'), false); + } + assert.equal(await badge.count(), reason ? 1 : 0); + if (reason) { + assert.match( + await badge.innerText(), + retained ? /Last known|Prior/ : /Unavailable|N\/A/, + ); + assert.match( + await cell.getAttribute('title'), + retained ? /Using last known/ : /Guest reads|Guest request/, + ); + } else assert.equal(await cell.getAttribute('title'), null); + assert.equal(/lock:/i.test(await row.innerText()), Boolean(lock)); + assert.equal( + await chart.locator('[data-history-current="disk"]').count(), + reason ? 0 : 1, + ); + assert.equal( + await chart.locator('[data-history-last-known="disk"]').count(), + retained ? 1 : 0, + ); + assert.equal( + await chart.locator('[data-history-deferred="disk"]').count(), + reason ? 1 : 0, + ); + if (retained) + assert.match( + await chart.locator('[data-history-last-known="disk"]').innerText(), + new RegExp(usage.toFixed(1) + '%[\\s\\S]*last known'), + ); + if (!reason) + assert.match( + await chart.locator('[data-history-current="disk"]').innerText(), + new RegExp(usage.toFixed(1) + '%'), + ); + assert.equal(await chart.locator('path').count(), 0); // no invented trend from the snapshot + assert.equal(await chart.locator('[data-history-observation]').count(), 0); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), + false, + ); + record.states.push({ state, evidence: current }); + }; + const screenshot = async (state) => { + if (!( + (transport === 'api' && name === 'chromium-desktop') || + (transport === 'canonical-fast' && name === 'webkit-phone') + )) + return; + const file = `${name}-${transport}-${state}.png`; + await page.screenshot({ + path: output + '/' + file, + fullPage: true, + animations: 'disabled', + }); + result.screenshots.push({ + file, + sha256: hash(output + '/' + file), + state, + name, + transport, + }); + }; + await check('retained-backup', 'prev-vm-locked', true, 50, 'backup', 'deferred', true); + await screenshot('retained'); + // No lock on wire does not by itself restore current disk data. + await observe({ reason: 'prev-agent-busy', usage: 50, status: 'deferred', expected: true }); + await check( + 'lock-cleared-still-deferred', + 'prev-agent-busy', + true, + 50, + '', + 'deferred', + true, + ); + // Omitted reason/lock with a fresh explicit native outcome supersedes the old fields. + await observe({ usage: 50, status: 'available' }); + await check('fresh-same-number', undefined, false, 50, '', 'available', undefined); + await observe({ reason: 'agent-timeout', status: 'deferred', expected: false }); + await check('unavailable', 'agent-timeout', false, 0, '', 'deferred', false); + assert.match( + await chart.locator('[data-history-deferred="disk"]').innerText(), + /Completion is uncertain.*Do not restart the guest agent during a backup/, + ); + await screenshot('unavailable'); + await observe({ usage: 0, status: 'available' }); + await check('fresh-measured-zero', undefined, false, 0, '', 'available', undefined); + await screenshot('zero'); + // Existing Overview updates as well; real History tab can be revisited with keyboard/touch. + await activate(drawer.getByRole('tab', { name: 'Overview', exact: true })); + assert.equal((await drawer.innerText()).includes('Using last known'), false); + assert.match(await drawer.innerText(), /0%/); + await activate(drawer.getByRole('tab', { name: 'History', exact: true })); + assert.equal(await chart.locator('[data-history-current="disk"]').count(), 1); + assert.equal(await chart.locator('[data-history-deferred="disk"]').count(), 0); + const resourceRequests = record.requests.filter((r) => r.path === '/api/resources'); + if (transport !== 'api') assert.equal(resourceRequests.length, 0); + assert.deepEqual(record.errors, []); + assert.equal( + record.requests.some((r) => r.blocked), + false, + ); + record.checks = [ + 'Native read-state forwarded rather than discarded.', + 'Recovery clears obsolete optional state without remounting row/History.', + 'Lock clearance alone preserves explicit deferral.', + 'Measured zero remains current; snapshot values never become stored points.', + 'Real tab activation, readable narrow layout, no page errors, mutations or external requests.', + 'Owned snapshot never fetches a second workload inventory.', + ]; + await page.close(); + } + await browser.close(); + browser = undefined; + } + result.result = 'passed'; + result.verified_at = new Date().toISOString(); + } 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/workload-read-evidence.html b/frontend-modern/browser-tests/workload-read-evidence.html new file mode 100644 index 0000000000..2789b59804 --- /dev/null +++ b/frontend-modern/browser-tests/workload-read-evidence.html @@ -0,0 +1,12 @@ + + + + + + Workload read evidence fixture + + +
+ + + diff --git a/frontend-modern/browser-tests/workload-read-evidence.tsx b/frontend-modern/browser-tests/workload-read-evidence.tsx new file mode 100644 index 0000000000..c3d28bcbcc --- /dev/null +++ b/frontend-modern/browser-tests/workload-read-evidence.tsx @@ -0,0 +1,164 @@ +// Real workload adapter/merges and production panels, synthetic read snapshots. +// No backup, guest-agent, freeze/thaw or native recovery operation is performed. +import { createSignal, Show } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { useWorkloads } from '../src/hooks/useWorkloads'; +import { + mergeCanonicalResourceSnapshot, + mergeCanonicalResourceDeltaSnapshot, +} from '../src/utils/resourceStateAdapters'; +import { GuestRow } from '../src/components/Workloads/GuestRow'; +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 { VM } from '../src/types/api'; +import '../src/index.css'; + +syncAIRuntimeSettings({ discovery_enabled: false }); +const capacity = 10 * 1024 ** 3; +type Read = { + reason?: string; + usage?: number; + lock?: string; + status: string; + expected?: boolean; +}; +const initial: Read = { + reason: 'prev-vm-locked', + usage: 50, + lock: 'backup', + status: 'deferred', + expected: true, +}; +const nativeResource = (read: Read): Resource => ({ + id: 'fixture-a-pve1-101', + type: 'vm', + name: 'backup-guest', + displayName: 'backup-guest', + platformType: 'proxmox-pve', + sourceType: 'api', + status: 'online', + sources: ['proxmox'], + lastSeen: Date.UTC(2026, 9, 4, 5), + cpu: { current: 10 }, + memory: { current: 25, used: capacity / 4, total: capacity }, + ...(read.usage === undefined + ? {} + : { + disk: { current: read.usage, used: (capacity * read.usage) / 100, total: capacity }, + }), + proxmox: { + vmid: 101, + nodeName: 'pve1', + instance: 'fixture-a', + runtimeStatus: 'running', + guestAgentStatus: read.status, + disks: + read.usage === undefined + ? [] + : [ + { + mountpoint: '/data', + type: 'ext4', + total: capacity, + used: (capacity * read.usage) / 100, + usage: read.usage, + }, + ], + ...(read.reason === undefined ? {} : { diskStatusReason: read.reason }), + ...(read.lock === undefined ? {} : { lock: read.lock }), + ...(read.expected === undefined ? {} : { guestAgentExpected: read.expected }), + }, +}); + +function Fixture() { + const transport = new URLSearchParams(window.location.search).get('transport') || 'api'; + const [snapshot, setSnapshot] = createSignal( + mergeCanonicalResourceSnapshot([nativeResource(initial)], []), + ); + const state = useWorkloads( + () => true, + transport === 'api' + ? {} + : { + resourceSnapshot: snapshot, + }, + ); + const guest = () => state.workloads()[0]!; + (window as any).__workloadReadEvidence = { + update: async (read: Read) => { + if (transport === 'api') { + await state.refetch(); + return; + } + const incoming = nativeResource(read); + setSnapshot((previous) => + transport === 'canonical-full' + ? mergeCanonicalResourceSnapshot([incoming], previous) + : mergeCanonicalResourceDeltaSnapshot( + [incoming], + previous, + new Set([incoming.id]), + transport === 'canonical-fast' + ? new Map([[incoming.id, ['proxmox', 'disk']]]) + : undefined, + ), + ); + }, + evidence: () => { + const vm = guest() as VM; + return { + id: vm.id, + reason: vm.diskStatusReason, + lock: vm.lock, + agent: vm.guestAgentStatus, + expected: vm.guestAgentExpected, + usage: vm.disk.usage, + available: guest().telemetryAvailability?.disk, + }; + }, + }; + return ( +
+

Workload read evidence

+

+ Synthetic {transport} observations. No guest commands are sent. +

+ +
+ + + + +
+
+
+ {}} /> +
+
+
+ ); +} +render( + () => ( + + ( + document.documentElement.classList.contains('dark')} + > + + + )} + /> + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/fe7c10feafce0f66.json b/frontend-modern/browser-verification/fe7c10feafce0f66.json new file mode 100644 index 0000000000..1345a0cced --- /dev/null +++ b/frontend-modern/browser-verification/fe7c10feafce0f66.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "base_sha": "53cfdf6020f91f14981007e9723d666113d24c7b", + "verified_at": "2026-10-04T05:28:12.394Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/useGuestRowState.ts", + "frontend-modern/src/hooks/useWorkloads.ts", + "frontend-modern/src/utils/resourceStateAdapters.ts" + ], + "content_sha256": { + "frontend-modern/src/hooks/useWorkloads.ts": "40833d2d47d7a8a41646a47568d92f1df375bc252e37d70d5303e22889441ce0", + "frontend-modern/src/components/Workloads/useGuestRowState.ts": "ef0e31343f20759c3d628653179fcfb424fa5e9e255f37643114714222215729", + "frontend-modern/src/utils/resourceStateAdapters.ts": "9ba757addef70fff50d0fe00accd06f3ef8ee44a86ce2b88de7ab93478b2d09a" + }, + "routes": [ + "/browser-tests/workload-read-evidence.html?transport=api", + "/browser-tests/workload-read-evidence.html?transport=canonical-full", + "/browser-tests/workload-read-evidence.html?transport=canonical-delta", + "/browser-tests/workload-read-evidence.html?transport=canonical-fast" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "40 same-VM synthetic read transitions through actual useWorkloads, canonical full/delta/fast merges and production GuestRow/GuestDrawer with real CSS; desktop Chromium and dark phone WebKit, Playwright 1.56.1 matches integration lock.", + "Retained lock/deferred readings remain visibly prior; lock clearance alone does not restore a current reading. New explicit native outcomes clear obsolete omitted optional fields even when numeric readings are unchanged.", + "Non-retained and unavailable read states have no numeric bar, animated numeric label, current History disk value or invented chart point. Explicit false is forwarded, measured zero remains current, and History no-data stays empty.", + "Six final screenshots inspected. Superseded script-only pass showed an adverse retained green bar; mounted source controls and no-bar browser assertions now cover it. Number text and aria target converge before final captures.", + "This is not native guest-agent/freeze/thaw, workload liveness, installed recovery, physical-phone, publication or release acceptance." + ], + "interactions": [ + "Same-identity API refetch and full/delta/fast snapshot replacement preserve row cell and active History DOM. Real Overview/History tab activation uses desktop mouse and phone touch.", + "Owned canonical snapshots issue no second workload-inventory GET. All fixture API traffic is local and GET-only, no off-origin request/page error and no guest/backup command.", + "Labels and safety notices fit the narrow viewport; measured-zero numeric text is checked at the final animation value, not an intermediate frame." + ] +} diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx index 362d24e6e5..e944724eb3 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx @@ -688,6 +688,58 @@ describe('GuestRow', () => { }; for (const mode of ['bars', 'sparklines'] as const) { + it.each(guestDiskDeferrals)( + `withdraws stale numeric ${mode} when %s has no retained reading`, + (reason) => { + const { container } = renderGuestRow({ + guest: makeGuest({ diskStatusReason: reason, disk: makeDisk({ usage: 50 }) }), + visibleColumnIds: ['name', 'disk'], + metricDisplayMode: mode, + }); + expect(screen.queryByTestId('disk-bar')).toBeNull(); + const cell = container.querySelector('[data-workload-col="disk"]')!; + expect(cell).toHaveTextContent('Unavailable'); + const chart = cell.querySelector('[role="img"]'); + if (chart) expect(chart.getAttribute('aria-label')).not.toMatch(/50%/); + }, + ); + + it(`does not revive an unavailable canonical metric in ${mode}, preserving measured zero`, () => { + const [guest, setGuest] = createSignal( + makeGuest({ + diskStatusReason: 'prev-vm-locked', + telemetryAvailability: { + cpu: true, + memory: true, + disk: false, + networkIO: true, + diskIO: true, + uptime: true, + }, + }), + ); + const { container } = render(() => ( + + + + +
+ )); + expect(screen.queryByTestId('disk-bar')).toBeNull(); + const cell = container.querySelector('[data-workload-col="disk"]')!; + const chart = cell.querySelector('[role="img"]'); + if (chart) expect(chart.getAttribute('aria-label')).not.toMatch(/50%/); + setGuest(makeGuest({ disk: makeDisk({ used: 0, usage: 0 }) })); + expect(container.querySelector('[data-workload-col="disk"]')).toBe(cell); + expect(container.querySelector('[data-workload-disk-read-status]')).toBeNull(); + if (mode === 'bars') expect(screen.getByTestId('disk-bar')).toBeTruthy(); + else expect(cell.querySelector('[role="img"]')?.getAttribute('aria-label')).toMatch(/0%/); + }); + it.each(guestDiskDeferrals)( `labels retained ${mode} for %s without a hover`, (reason, message) => { diff --git a/frontend-modern/src/components/Workloads/useGuestRowState.ts b/frontend-modern/src/components/Workloads/useGuestRowState.ts index 6034b22b98..7b5d58bfc5 100644 --- a/frontend-modern/src/components/Workloads/useGuestRowState.ts +++ b/frontend-modern/src/components/Workloads/useGuestRowState.ts @@ -226,9 +226,22 @@ export function useGuestRowState(props: GuestRowProps) { }); const hasDiskUsage = createMemo(() => { - if (!props.guest.disk) return false; - if ((props.guest.disk.total ?? 0) <= 0) return false; - return diskPercent() !== -1; + const guest = props.guest; + if (!guest.disk || guest.telemetryAvailability?.disk === false) return false; + const total = guest.disk.total ?? 0; + if (!Number.isFinite(total) || total <= 0) return false; + // A generic canonical merge can retain the previous numeric metric while + // the provider explicitly reports no usable guest reading. Only prev- + // reasons allow that number to remain visible as last-known evidence. + if ( + workloadType() === 'vm' && + 'diskStatusReason' in guest && + guest.diskStatusReason && + !guest.diskStatusReason.startsWith('prev-') + ) + return false; + const percent = diskPercent(); + return Number.isFinite(percent) && percent >= 0; }); const parentOnline = createMemo(() => props.parentNodeOnline !== false); diff --git a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts index fc20e938e7..57f864710f 100644 --- a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts +++ b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts @@ -1,6 +1,8 @@ import { batch, createEffect, createRoot, createSignal } from 'solid-js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import useWorkloadsSource from '../useWorkloads.ts?raw'; +import { guestDiskDeferrals } from '@/components/Workloads/__fixtures__/guestDiskDeferrals'; +import type { VM } from '@/types/api'; type UseWorkloadsModule = typeof import('@/hooks/useWorkloads'); @@ -124,6 +126,138 @@ describe('useWorkloads', () => { vi.resetModules(); }); + it.each(guestDiskDeferrals)( + 'preserves %s and the native lock through the resources API', + async (reason) => { + apiFetchJSONMock.mockResolvedValueOnce({ + data: [ + { + ...sampleResource, + proxmox: { + vmid: 101, + nodeName: 'pve1', + instance: 'cluster-a', + diskStatusReason: `prev-${reason}`, + guestAgentStatus: 'deferred', + guestAgentExpected: true, + lock: 'backup', + }, + }, + ], + meta: { totalPages: 1 }, + }); + let dispose = () => {}; + let result: ReturnType; + createRoot((d) => { + dispose = d; + result = useWorkloads(); + }); + try { + await waitForWorkloadCount(() => result.workloads().length); + expect(result!.workloads()[0]).toMatchObject({ + diskStatusReason: `prev-${reason}`, + guestAgentStatus: 'deferred', + guestAgentExpected: true, + lock: 'backup', + disk: { usage: 20 }, + }); + expect(apiFetchJSONMock).toHaveBeenCalledTimes(1); + } finally { + dispose(); + } + }, + ); + + it.each(guestDiskDeferrals)( + 'preserves %s through the owning Proxmox snapshot without another request', + async (reason) => { + let dispose = () => {}; + let result: ReturnType; + createRoot((d) => { + dispose = d; + result = useWorkloads(() => true, { + resourceSnapshot: () => + [ + { + ...sampleResource, + proxmox: { + vmid: 101, + nodeName: 'pve1', + instance: 'cluster-a', + diskStatusReason: reason, + guestAgentStatus: 'deferred', + guestAgentExpected: false, + lock: 'migrate', + }, + disk: { current: 20, used: 20 * 1024, total: 100 * 1024 }, + }, + ] as any, + }); + }); + try { + await flushAsync(); + expect(result!.workloads()[0]).toMatchObject({ + diskStatusReason: reason, + guestAgentStatus: 'deferred', + guestAgentExpected: false, + lock: 'migrate', + disk: { usage: 20 }, + }); + expect(apiFetchJSONMock).not.toHaveBeenCalled(); + } finally { + dispose(); + } + }, + ); + + it('updates read evidence even with unchanged metrics, preserving other site rows', async () => { + const buildVM = (instance: string, proxmox: Record = {}, current = 20) => + ({ + ...sampleResource, + id: `${instance}-pve1-101`, + proxmox: { vmid: 101, nodeName: 'pve1', instance, ...proxmox }, + disk: { current, used: current * 1024, total: 100 * 1024 }, + }) as any; + const other = buildVM('cluster-b'); + const [snapshot, setSnapshot] = createSignal([ + buildVM('cluster-a', { + diskStatusReason: 'prev-vm-locked', + guestAgentStatus: 'deferred', + guestAgentExpected: true, + lock: 'backup', + }), + other, + ]); + let dispose = () => {}; + let result: ReturnType; + createRoot((d) => { + dispose = d; + result = useWorkloads(() => true, { resourceSnapshot: snapshot }); + }); + const target = () => result!.workloads().find((row) => row.instance === 'cluster-a')! as VM; + try { + await flushAsync(); + const prior = target(); + const otherRow = result!.workloads().find((row) => row.instance === 'cluster-b'); + expect(prior.diskStatusReason).toBe('prev-vm-locked'); + setSnapshot([buildVM('cluster-a', { guestAgentStatus: 'available' }), other]); + await flushAsync(); + expect(target()).not.toBe(prior); + expect(target().diskStatusReason).toBeUndefined(); + expect(target().lock).toBe(''); + expect(target().guestAgentStatus).toBe('available'); + expect(target().guestAgentExpected).toBeUndefined(); + expect(target().disk.usage).toBe(20); + expect(result!.workloads().find((row) => row.instance === 'cluster-b')).toBe(otherRow); + setSnapshot([buildVM('cluster-a', { guestAgentStatus: 'available' }, 0), other]); + await flushAsync(); + expect(target().disk.usage).toBe(0); + expect(apiFetchJSONMock).not.toHaveBeenCalled(); + } finally { + dispose(); + } + }); + it('reuses fresh cache on remount without an extra network fetch', async () => { let disposeFirst = () => {}; createRoot((d) => { diff --git a/frontend-modern/src/hooks/useWorkloads.ts b/frontend-modern/src/hooks/useWorkloads.ts index 89e6eeb684..56ca84945c 100644 --- a/frontend-modern/src/hooks/useWorkloads.ts +++ b/frontend-modern/src/hooks/useWorkloads.ts @@ -113,6 +113,10 @@ type APIResource = { lastBackup?: string; backupInProgress?: boolean; disks?: APIDiskInfo[]; + diskStatusReason?: string; + guestAgentStatus?: string; + guestAgentExpected?: boolean; + lock?: string; swapUsed?: number; swapTotal?: number; balloon?: number; @@ -569,7 +573,11 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => { })(), disk: buildMetric(resource.metrics?.disk), disks: normalizeDiskArray(resource.proxmox?.disks ?? resource.agent?.disks), - diskStatusReason: undefined, + // Both the API and owning platform snapshot pass through this mapper. + // A retained numeric metric must not lose the provider's read deferral. + diskStatusReason: resource.proxmox?.diskStatusReason, + guestAgentStatus: resource.proxmox?.guestAgentStatus, + guestAgentExpected: resource.proxmox?.guestAgentExpected, ipAddresses: resource.identity?.ipAddresses ?? [], // Guest OS info: agent.osName/osVersion is the universal fallback the // workload table reads. Proxmox writes to resource.proxmox.osName / @@ -606,7 +614,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => { // cell, so the vSphere facet wins for every vSphere workload — including // the untagged ones, which must render empty rather than fall back. tags: vmwareRowTags(resource) ?? resource.tags ?? [], - lock: '', + lock: resource.proxmox?.lock ?? '', lastSeen: toIsoString(resource.lastSeen), isOci: workloadType === 'system-container' ? (resource.proxmox?.isOci ?? false) : false, osTemplate: workloadType === 'system-container' ? resource.proxmox?.osTemplate : undefined, diff --git a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts index da7e9dbfba..d38b96df74 100644 --- a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts @@ -1464,6 +1464,141 @@ describe('fast merge path for metrics-only delta patches', () => { new Set(raw.map((row) => row.id)), ); + it('clears omitted guest read-state fields on a new native outcome in full and fast deltas', () => { + const raw = createPveGuestRaw(); + raw.proxmox = { + ...raw.proxmox, + runtimeStatus: 'running', + guestAgentStatus: 'deferred', + diskStatusReason: 'prev-vm-locked', + guestAgentExpected: true, + lock: 'backup', + }; + const previous = seedDisplayRows([raw]); + const incoming = structuredClone(raw); + incoming.proxmox = { + vmid: 100, + nodeName: 'pve-node-1', + runtimeStatus: 'running', + guestAgentStatus: 'available', + }; + const changed = new Set([raw.id]); + const keys = new Map([[raw.id, ['proxmox']]]); + expect(getFastResourceMergePatchKeys(keys, raw.id, previous[0])).toEqual(['proxmox']); + const full = mergeCanonicalResourceSnapshot([incoming], previous); + const delta = mergeCanonicalResourceDeltaSnapshot([incoming], previous, changed); + const fast = mergeCanonicalResourceDeltaSnapshot([incoming], previous, changed, keys); + expect(fast[0]).toEqual(delta[0]); + expect(fast[0]).toEqual(full[0]); + for (const [merged] of [full, delta, fast]) { + expect(merged.proxmox?.guestAgentStatus).toBe('available'); + expect(merged.proxmox?.diskStatusReason).toBeUndefined(); + expect(merged.proxmox?.lock).toBeUndefined(); + expect(merged.proxmox?.guestAgentExpected).toBeUndefined(); + expect(merged.proxmox?.uptime).toBe(1000); // unrelated richer facet is retained + expect(buildFastResourceStorePatchOps(merged, ['proxmox'])).toContainEqual({ + key: 'proxmox', + value: merged.proxmox, + mode: 'reconcile', + }); + } + }); + + it('applies the same read-state clearing to the compatibility provider facet', () => { + const raw = createPveGuestRaw(); + raw.proxmox = { + ...raw.proxmox, + runtimeStatus: 'running', + guestAgentStatus: 'deferred', + diskStatusReason: 'prev-vm-locked', + guestAgentExpected: true, + lock: 'backup', + }; + raw.platformData = { ...raw.platformData, proxmox: raw.proxmox }; + const previous = seedDisplayRows([raw]); + const incoming = structuredClone(raw); + incoming.proxmox = { + vmid: 100, + nodeName: 'pve-node-1', + runtimeStatus: 'running', + guestAgentStatus: 'available', + }; + incoming.platformData = { ...incoming.platformData, proxmox: incoming.proxmox }; + const [merged] = mergeCanonicalResourceSnapshot([incoming], previous); + expect(merged.platformData?.proxmox).toEqual(merged.proxmox); + expect(merged.proxmox?.diskStatusReason).toBeUndefined(); + expect(merged.proxmox?.lock).toBeUndefined(); + }); + + it('preserves read-state evidence on partial facet omission, including the fast path', () => { + const raw = createPveGuestRaw(); + raw.proxmox = { + ...raw.proxmox, + runtimeStatus: 'running', + guestAgentStatus: 'deferred', + diskStatusReason: 'prev-agent-timeout', + guestAgentExpected: true, + lock: 'backup', + }; + const previous = seedDisplayRows([raw]); + for (const proxmox of [ + undefined, + { vmid: 100, uptime: 1001 }, + { guestAgentStatus: 'available' }, + { vmid: 0, runtimeStatus: 'running', guestAgentStatus: 'available' }, + { vmid: 1.5, runtimeStatus: 'running', guestAgentStatus: 'available' }, + { vmid: Infinity, runtimeStatus: 'running', guestAgentStatus: 'available' }, + { vmid: 100, runtimeStatus: ' ', guestAgentStatus: 'available' }, + { vmid: 100, runtimeStatus: 'running', guestAgentStatus: ' ' }, + ]) { + const incoming = { ...raw, proxmox } as Resource; + const full = mergeCanonicalResourceSnapshot([incoming], previous); + const fast = mergeCanonicalResourceDeltaSnapshot( + [incoming], + previous, + new Set([raw.id]), + new Map([[raw.id, ['proxmox']]]), + ); + for (const [merged] of [full, fast]) { + expect(merged.proxmox).toMatchObject({ + diskStatusReason: 'prev-agent-timeout', + guestAgentExpected: true, + lock: 'backup', + }); + } + } + }); + + it('keeps explicit lock and deferral evidence on a new outcome, including false expected', () => { + const raw = createPveGuestRaw(); + raw.proxmox = { + ...raw.proxmox, + runtimeStatus: 'running', + guestAgentStatus: 'available', + }; + const previous = seedDisplayRows([raw]); + const incoming = structuredClone(raw); + incoming.proxmox = { + ...raw.proxmox, + guestAgentStatus: 'deferred', + diskStatusReason: 'agent-busy', + guestAgentExpected: false, + lock: 'migrate', + }; + const [merged] = mergeCanonicalResourceDeltaSnapshot( + [incoming], + previous, + new Set([raw.id]), + new Map([[raw.id, ['proxmox']]]), + ); + expect(merged.proxmox).toMatchObject({ + guestAgentStatus: 'deferred', + diskStatusReason: 'agent-busy', + guestAgentExpected: false, + lock: 'migrate', + }); + }); + it('produces the same merged row as the full path for a metrics-only patch', () => { const raw = createPveGuestRaw(); const display = seedDisplayRows([raw]); diff --git a/frontend-modern/src/utils/resourceStateAdapters.ts b/frontend-modern/src/utils/resourceStateAdapters.ts index 8315e0e87f..58408371bf 100644 --- a/frontend-modern/src/utils/resourceStateAdapters.ts +++ b/frontend-modern/src/utils/resourceStateAdapters.ts @@ -122,6 +122,31 @@ const mergeRecord = (incoming?: T, existing?: T): T | unde return { ...existing, ...incoming }; }; +// Native VM facets carry a runtime state and an explicit guest-agent outcome. +// Their optional read reason, lock and false expected flag are omitempty on +// wire. A new outcome supersedes those fields, not unrelated richer metadata. +// Partial/legacy facets without that evidence must retain the prior facts. +const mergeProxmoxFacet = (incoming?: T, existing?: T): T | undefined => { + const merged = mergeRecord(incoming, existing); + if ( + !incoming || + !merged || + typeof incoming.vmid !== 'number' || + !Number.isInteger(incoming.vmid) || + incoming.vmid <= 0 || + typeof incoming.runtimeStatus !== 'string' || + !incoming.runtimeStatus.trim() || + typeof incoming.guestAgentStatus !== 'string' || + !incoming.guestAgentStatus.trim() + ) + return merged; + const next = { ...merged }; + for (const key of ['diskStatusReason', 'guestAgentExpected', 'lock']) { + if (!Object.prototype.hasOwnProperty.call(incoming, key)) delete next[key]; + } + return next; +}; + const mergePlatformData = ( incomingValue: Resource['platformData'], existingValue: Resource['platformData'], @@ -151,7 +176,10 @@ const mergePlatformData = ( delete merged[key]; continue; } - const nested = mergeRecord(asRecord(incoming[key]), asRecord(existing[key])); + const nested = + key === 'proxmox' + ? mergeProxmoxFacet(asRecord(incoming[key]), asRecord(existing[key])) + : mergeRecord(asRecord(incoming[key]), asRecord(existing[key])); if (key === 'docker') { if (hasDockerFacetEvidence(nested)) { merged[key] = nested; @@ -864,7 +892,9 @@ const mergeCanonicalSourceFacet = ( ...sourceCandidates: string[] ): T | undefined => shouldKeepSourceFacet(incomingSources, ...sourceCandidates) - ? mergeRecord(incomingFacet, existingFacet) + ? sourceCandidates.includes('proxmox-pve') + ? mergeProxmoxFacet(incomingFacet, existingFacet) + : mergeRecord(incomingFacet, existingFacet) : incomingFacet; // A missing field alone can be a partial snapshot. Explicit unavailable raw @@ -1130,7 +1160,7 @@ const applyFastResourceMergePatch = ( getCanonicalSourceList(existing, existing.platformData), 'proxmox-pve', ) - ? { ...existingFacet, ...cloned } + ? mergeProxmoxFacet(cloned, existingFacet) : cloned; continue; }