From ca255a0e3f81a8d1588f3e783b7084fc863b37c8 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:42:55 +0100 Subject: [PATCH 1/2] Keep retained guest readings truthful in canonical History Share the original memory observation policy with the dedicated guest drawer and carry filesystem deferrals through the existing History presentation. Preserve measured zero, stored samples, source and access isolation, and same-ID read budgets. Record fresh eight-state browser proof; native thaw and workload recovery remain independent. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 30 +- .../subsystems/performance-and-scalability.md | 18 +- .../internal/subsystems/unified-resources.md | 27 +- .../browser-tests/docker-composed.cjs | 302 +++++++++++++ .../browser-tests/docker-composed.html | 12 + .../browser-tests/docker-composed.tsx | 87 ++++ .../resource-history-provenance.cjs | 400 ++++++++++++++++++ .../resource-history-provenance.html | 12 + .../resource-history-provenance.tsx | 137 ++++++ .../4beced7d2f607011.json | 54 +++ .../Infrastructure/ResourceDetailDrawer.tsx | 1 + .../ResourceDetailDrawer.history.test.tsx | 91 +++- ...rMetricsHistoryModel.branchcov0712.test.ts | 145 +++++++ ...resourceDetailDrawerMetricsHistoryModel.ts | 58 ++- .../useResourceDetailDrawerDerivedState.ts | 5 + .../components/Workloads/GuestDrawer.test.tsx | 51 +++ .../components/Workloads/guestDrawerModel.ts | 64 +-- .../src/utils/memoryObservation.ts | 67 +++ 18 files changed, 1484 insertions(+), 77 deletions(-) create mode 100644 frontend-modern/browser-tests/docker-composed.cjs create mode 100644 frontend-modern/browser-tests/docker-composed.html create mode 100644 frontend-modern/browser-tests/docker-composed.tsx create mode 100644 frontend-modern/browser-tests/resource-history-provenance.cjs create mode 100644 frontend-modern/browser-tests/resource-history-provenance.html create mode 100644 frontend-modern/browser-tests/resource-history-provenance.tsx create mode 100644 frontend-modern/browser-verification/4beced7d2f607011.json diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 8913a0a0f1..2dcb43e592 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. @@ -4066,7 +4083,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 7d9b86a7a7..2dda34dc2b 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..8266b0f504 --- /dev/null +++ b/frontend-modern/browser-tests/docker-composed.cjs @@ -0,0 +1,302 @@ +// 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-complete'; +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 })); + 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/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.' : ''}`, + }; +}; From d358adf7d0becc1deeca0bcb1a135fea024b4702 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:51:02 +0100 Subject: [PATCH 2/2] Verify composed container labels and host context Record inspected desktop and touch-WebKit phone light/dark evidence for the final fractional CPU, silent-host and shared-column content. Exercise the actual mobile Filters and View controls before flat-row drawer expansion; production container source is unchanged. Change-source: pulse-maintainer --- .../browser-tests/docker-composed.cjs | 3 +- .../66ad68daaba822c9.json | 43 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 frontend-modern/browser-verification/66ad68daaba822c9.json diff --git a/frontend-modern/browser-tests/docker-composed.cjs b/frontend-modern/browser-tests/docker-composed.cjs index 8266b0f504..7ceae8b40e 100644 --- a/frontend-modern/browser-tests/docker-composed.cjs +++ b/frontend-modern/browser-tests/docker-composed.cjs @@ -5,7 +5,7 @@ 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-complete'; +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'; @@ -223,6 +223,7 @@ const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)) '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'); 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." + } +}