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.' : ''}`, + }; +};