diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 07375fbcc7..f3603e9e31 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 +### History honours explicit selected-source staleness (13 October 2026) + +The shared memory presentation reads the server's explicit `agent.stale` +invalidation only when the selected observation is from `agent`. A dated +current carrier then becomes visibly last known, with its original source, +time and measured value, including zero. It does not supply current sorting, +metric-filter pressure or guest/canonical History fallbacks. The visible source +label explains that the supplying Pulse Agent stopped reporting. Missing or +invalid provenance remains unknown; unavailable telemetry stays unavailable. + +QGA and Proxmox observations remain independent of a stale linked Pulse Agent +and of filesystem deferrals. No client TTL, source timestamp renewal, metadata +rewrite, API schema, History point or guest operation is added. The source +can restore current measured zero when it reports again. This is presentation +safety, not native restart/thaw recovery or release availability. + +`staleAgentMemory.test.ts` connects qualification to both History models, +sorting and metric search, while `GuestRow.test.tsx` mounts same-ID source +updates in bars and sparklines. The static production-component browser +fixture `guest-memory-agent-stale.cjs` exercises agent-facet-only canonical +deltas through the actual Workloads projection, both drawers and trusted +keyboard/touch controls at desktop and phone widths. + ### Patrol setup panel uses existing form primitives `PatrolSetupPanel.tsx` builds the provider choice from `SelectionCardGroup` diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 434f644fd7..3a8291864f 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -5911,7 +5911,8 @@ "frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.branchcov0712.test.ts", "frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts", "frontend-modern/src/components/Workloads/__tests__/nodeDrawerModel.branchcov0713.test.ts", - "frontend-modern/src/features/docker/__tests__/dockerHostDrawerModel.test.ts" + "frontend-modern/src/features/docker/__tests__/dockerHostDrawerModel.test.ts", + "frontend-modern/src/utils/__tests__/staleAgentMemory.test.ts" ] }, { diff --git a/frontend-modern/browser-tests/guest-memory-agent-stale.cjs b/frontend-modern/browser-tests/guest-memory-agent-stale.cjs new file mode 100644 index 0000000000..5dc58cf9c4 --- /dev/null +++ b/frontend-modern/browser-tests/guest-memory-agent-stale.cjs @@ -0,0 +1,251 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); +const { + createPublicationFixtureServer, +} = require('/workspace/frontend-modern/browser-tests/publication-fixture-server.cjs'); +const root = '/workspace/frontend-modern'; +const output = '/workspace/tmp/guest-memory-agent-stale'; +const origin = 'http://127.0.0.1:5344'; +const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const settle = (page) => + page.evaluate(async () => { + await document.fonts.ready; + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }); + +(async () => { + fs.mkdirSync(output, { recursive: true }); + const result = { + result: 'incomplete', + playwright: require('playwright/package.json').version, + cases: [], + captures: [], + cleanup: {}, + driver_sha256: hash(root + '/browser-tests/guest-memory-agent-stale.cjs'), + limits: + 'Real canonical delta merger, Workloads projection, row/bar/sparkline, guest drawer and canonical History with synthetic source-facet updates and empty History API fixtures. No collector, native restart, thaw, guest operation, installed or release acceptance.', + }; + let server, browser; + try { + assert.equal( + result.playwright, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + server = createPublicationFixtureServer( + root, + 5344, + 'browser-tests/guest-memory-agent-stale.html', + ); + result.compiled = server.binding; + await server.listen(); + for (const [name, engine, width, dark] of [ + ['desktop', chromium, 1365, false], + ['phone', webkit, 390, true], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const context = await browser.newContext({ + viewport: { width, height: 900 }, + isMobile: width < 768, + hasTouch: width < 768, + locale: 'en-GB', + timezoneId: 'UTC', + reducedMotion: 'reduce', + }); + const page = await context.newPage(); + page.setDefaultTimeout(10_000); + const record = { + name, + width, + browser: browser.version(), + checks: [], + requests: [], + errors: [], + }; + result.cases.push(record); + page.on('pageerror', (error) => record.errors.push(error.message)); + await page.route('**/*', (route) => { + const request = route.request(), + url = new URL(request.url()); + assert.equal(url.origin, origin, 'external request refused'); + if (!url.pathname.startsWith('/api/')) return route.continue(); + record.requests.push({ path: url.pathname, method: request.method() }); + assert.equal(request.method(), 'GET', 'no mutations'); + assert(!/diagnostics|\/run|guest-agent|qemu/.test(url.pathname), 'no guest operations'); + let body = { success: true, data: [], alerts: [], anomalies: [], config: null }; + if (url.pathname.includes('/charts/')) + body = { + resourceType: 'vm', + resourceId: url.searchParams.get('resourceId') || 'fixture:pve1:101', + range: url.searchParams.get('range') || '24h', + start: 1791633600000, + end: 1791720000000, + source: 'store', + metrics: {}, + }; + if (url.pathname === '/api/license/runtime-capabilities') + body = { + capabilities: [], + limits: [], + max_history_days: 7, + hosted_mode: false, + runtime: { build: 'community' }, + blocked_capabilities: [], + }; + return route.fulfill({ json: body }); + }); + await page.addInitScript( + (dark) => + document.addEventListener('DOMContentLoaded', () => + document.documentElement.classList.toggle('dark', dark), + ), + dark, + ); + await page.goto(origin, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__staleAgentMemory?.evidence().projected); + const row = page.getByRole('region', { name: 'Workload row', exact: true }); + const workload = page.getByRole('region', { name: 'Workload drawer', exact: true }); + const canonical = page.getByRole('region', { name: 'Canonical drawer', exact: true }); + const notice = row.locator('[data-workload-memory-read-status]'); + assert.equal(await notice.count(), 0); + await page.evaluate(() => window.__staleAgentMemory.stale()); + await notice.waitFor(); + assert.match(await notice.innerText(), width < 768 ? /Prior/ : /Last known/); + assert.match(await notice.textContent(), /stopped reporting/); + await workload + .getByText(/Last known · Pulse Agent \(stopped reporting\)/) + .first() + .waitFor(); + const evidence = await page.evaluate(() => window.__staleAgentMemory.evidence()); + assert.equal(evidence.projected.agentStale, true); + assert.equal( + evidence.projected.memory.observation.state, + 'current', + 'wire carrier not rewritten', + ); + assert.equal(evidence.projected.memory.observation.observedAt, '2026-10-10T12:00:00Z'); + assert.equal(evidence.filterHigh, false); + assert.deepEqual(evidence.sort, ['independent', 'fixture:pve1:101']); + record.source_facet = evidence; + record.checks.push( + 'agent-only canonical delta invalidates its current carrier; original source/time and value survive; filter/sort exclude it', + ); + const capture = async (label, locator) => { + await settle(page); + const file = name + '-' + label + '.png'; + await locator.screenshot({ path: output + '/' + file }); + result.captures.push({ file, sha256: hash(output + '/' + file) }); + }; + await capture('stale-overview', workload); + await page.getByLabel('Metric display').selectOption('sparklines'); + await row + .getByRole('img', { name: 'linked-guest memory history, last known 95%', exact: true }) + .waitFor(); + await page.getByLabel('Memory basis').selectOption('host'); + await row + .getByRole('img', { + name: 'linked-guest host memory share history, last known 48%', + exact: true, + }) + .waitFor(); + await capture('stale-row', row); + record.checks.push( + 'real display/basis controls preserve retained bar/sparkline accessibility', + ); + const historyTab = async (surface) => { + const tab = surface.getByRole('tab', { name: 'History', exact: true }); + if (width < 768) await tab.tap(); + else { + await tab.focus(); + await page.keyboard.press('Enter'); + } + const chart = surface.locator('[data-history-group="utilization"]'); + await chart.locator('[data-history-last-known="memory"]').waitFor(); + assert.equal(await chart.locator('[data-history-current="memory"]').count(), 0); + assert.match( + await chart.locator('[data-history-last-known="memory"]').innerText(), + /95\.0%.*last known/s, + ); + assert.match( + await chart.locator('[data-history-deferred="memory"]').innerText(), + /stopped reporting/, + ); + assert.match( + await chart.locator('[data-history-deferred="memory"]').innerText(), + /2026-10-10 12:00:00 UTC/, + ); + return chart; + }; + const guestChart = await historyTab(workload); + const resourceChart = await historyTab(canonical); + await capture('stale-history', canonical); + record.checks.push( + 'keyboard/touch History in both real drawers retains the original value, not a current fallback or invented trend', + ); + for (const source of ['guest-agent-meminfo', 'status-mem']) { + await page.evaluate((source) => window.__staleAgentMemory.reading(source, 10), source); + await guestChart.locator('[data-history-current="memory"]').waitFor(); + await resourceChart.locator('[data-history-current="memory"]').waitFor(); + assert.equal(await guestChart.locator('[data-history-last-known="memory"]').count(), 0); + assert.equal(await resourceChart.locator('[data-history-last-known="memory"]').count(), 0); + assert.equal( + (await page.evaluate(() => window.__staleAgentMemory.evidence())).projected.agentStale, + true, + ); + } + record.checks.push( + 'unrelated QGA/Proxmox readings stay current while linked agent remains stale', + ); + await page.evaluate(() => window.__staleAgentMemory.reading('agent', 0)); + await guestChart.locator('[data-history-last-known="memory"]').waitFor(); + assert.match( + await guestChart.locator('[data-history-last-known="memory"]').innerText(), + /0\.0%.*last known/s, + ); + await page.evaluate(() => window.__staleAgentMemory.recover()); + await guestChart.locator('[data-history-current="memory"]').waitFor(); + await resourceChart.locator('[data-history-current="memory"]').waitFor(); + assert.match( + await guestChart.locator('[data-history-current="memory"]').innerText(), + /0\.0%.*current/s, + ); + assert.match( + await resourceChart.locator('[data-history-current="memory"]').innerText(), + /0\.0%.*current/s, + ); + assert.equal(await notice.count(), 0); + await capture('reporting-zero', canonical); + record.checks.push( + 'same-ID reporting source restores measured zero in both drawers; stale zero was not missing or healthy current', + ); + assert.deepEqual(record.errors, []); + const layout = await page.evaluate(() => ({ + client: document.documentElement.clientWidth, + scroll: document.documentElement.scrollWidth, + })); + assert(layout.scroll <= layout.client + 1, JSON.stringify(layout)); + record.layout = layout; + await context.close(); + await browser.close(); + browser = null; + } + 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(); + result.cleanup.browser = true; + if (server) await server.close(); + result.cleanup.server = true; + fs.writeFileSync(output + '/result.json', JSON.stringify(result, null, 2) + '\n'); + } +})(); diff --git a/frontend-modern/browser-tests/guest-memory-agent-stale.html b/frontend-modern/browser-tests/guest-memory-agent-stale.html new file mode 100644 index 0000000000..0e73e26bd0 --- /dev/null +++ b/frontend-modern/browser-tests/guest-memory-agent-stale.html @@ -0,0 +1,12 @@ + + + + + + Source-owned agent memory fixture + + +
+ + + diff --git a/frontend-modern/browser-tests/guest-memory-agent-stale.tsx b/frontend-modern/browser-tests/guest-memory-agent-stale.tsx new file mode 100644 index 0000000000..df77b18f61 --- /dev/null +++ b/frontend-modern/browser-tests/guest-memory-agent-stale.tsx @@ -0,0 +1,207 @@ +// A source-facet-only update through the real canonical merger and Workloads +// projection. No guest operations, native restart or recovery are simulated. +import { createSignal, Show } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { DarkModeContext } from '../src/contexts/appRuntime'; +import { useWorkloads } from '../src/hooks/useWorkloads'; +import { mergeCanonicalResourceDeltaSnapshot } from '../src/utils/resourceStateAdapters'; +import { GuestRow } from '../src/components/Workloads/GuestRow'; +import { GuestDrawer } from '../src/components/Workloads/GuestDrawer'; +import { ResourceDetailDrawer } from '../src/components/Infrastructure/ResourceDetailDrawer'; +import { getWorkloadTableLayoutModeForContainer } from '../src/components/Workloads/guestRowModel'; +import { createWorkloadSortComparator } from '../src/components/Workloads/workloadSelectors'; +import { evaluateFilterStack, parseFilterStack } from '../src/utils/searchQuery'; +import type { Resource } from '../src/types/resource'; +import type { MemoryObservation } from '../src/types/api'; +import '../src/index.css'; + +const capacity = 4 * 1024 ** 3; +const observedAt = '2026-10-10T12:00:00Z'; +const initial: Resource = { + id: 'fixture:pve1:101', + type: 'vm', + name: 'linked-guest', + status: 'online', + platformType: 'proxmox-pve', + platformScopes: ['proxmox-pve'], + sources: ['proxmox', 'agent'], + metricsTarget: { resourceType: 'vm', resourceId: 'fixture:pve1:101' }, + lastSeen: Date.parse(observedAt), + cpu: { current: 10 }, + memory: { + current: 95, + used: capacity * 0.95, + total: capacity, + observation: { state: 'current', source: 'agent', observedAt }, + }, + disk: { current: 50, used: capacity / 2, total: capacity }, + proxmox: { + vmid: 101, + nodeName: 'pve1', + instance: 'fixture', + runtimeStatus: 'running', + guestAgentStatus: 'deferred', + diskStatusReason: 'prev-vm-locked', + lock: 'backup', + }, + agent: { stale: false, version: 'fixture-version', lastReportAt: observedAt }, +}; + +// Only the transport is synthetic; the canonical merger, mapper and mounted +// presentation use production code. No websocket connection is attempted. +window.__pulseWsStore = { + state: { resources: [], activeAlerts: [], recentlyResolved: [] }, + connected: () => false, + initialDataReceived: () => true, + resourceSnapshotReceived: () => false, + resourceChange: () => ({ version: 0, changedIds: null, changedKeys: null }), + changedResourceMetaSince: () => null, + changedResourceIdsSince: () => null, + shutdown: () => {}, +} as unknown as NonNullable; + +function Fixture() { + const [snapshot, setSnapshot] = createSignal([initial]); + const workloads = useWorkloads(() => true, { resourceSnapshot: snapshot }); + const guest = () => workloads.workloads()[0]; + const [mode, setMode] = createSignal<'bars' | 'sparklines'>('bars'); + const [basis, setBasis] = createSignal<'guest' | 'host'>('guest'); + const layout = getWorkloadTableLayoutModeForContainer(Math.min(window.innerWidth, 896) - 40); + const update = (patch: Partial, keys: (keyof Resource)[]) => + setSnapshot((previous) => + mergeCanonicalResourceDeltaSnapshot( + [{ id: initial.id, ...patch } as Resource], + previous, + new Set([initial.id]), + new Map([[initial.id, keys]]), + ), + ); + (window as any).__staleAgentMemory = { + stale: () => + update( + { + agent: { ...snapshot()[0].agent, stale: true }, + lastSeen: Date.parse('2026-10-11T12:00:00Z'), + }, + ['agent', 'lastSeen'], + ), + reading: (source: string, usage: number, state = 'current') => + update( + { + memory: { + current: usage, + used: (capacity * usage) / 100, + total: capacity, + observation: { state, source, observedAt } as MemoryObservation, + }, + }, + ['memory'], + ), + recover: () => + update( + { + agent: { ...snapshot()[0].agent, stale: false, lastReportAt: '2026-10-11T12:00:00Z' }, + memory: { + current: 0, + used: 0, + total: capacity, + observation: { state: 'current', source: 'agent', observedAt: '2026-10-11T12:00:00Z' }, + }, + }, + ['agent', 'memory'], + ), + evidence: () => ({ + canonical: snapshot()[0], + projected: guest(), + filterHigh: evaluateFilterStack(guest(), parseFilterStack('memory>90')), + sort: [ + guest(), + { + ...guest(), + id: 'independent', + memory: { + ...guest().memory, + usage: 10, + observation: { state: 'current', source: 'guest-agent-meminfo', observedAt }, + }, + }, + ] + .sort(createWorkloadSortComparator('memory', 'desc')!) + .map((value) => value.id), + }), + }; + return ( +
+

Source-owned memory evidence

+

Synthetic source updates. No guest checks are sent.

+
+ + +
+ +
+ + + {}} + /> + +
+
+
+ {}} /> +
+
+ +
+
+
+ ); +} +render( + () => ( + + ( + document.documentElement.classList.contains('dark')} + > + + + )} + /> + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-tests/publication-fixture-server.cjs b/frontend-modern/browser-tests/publication-fixture-server.cjs index f92a1a1164..0cf096780f 100644 --- a/frontend-modern/browser-tests/publication-fixture-server.cjs +++ b/frontend-modern/browser-tests/publication-fixture-server.cjs @@ -11,6 +11,8 @@ const runtimePaths = [ 'src/components/Workloads/GuestRow.tsx', 'src/components/Workloads/MetricMiniSparkline.tsx', 'src/components/Workloads/guestDrawerModel.ts', + 'src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts', + 'src/utils/memoryObservation.ts', 'src/features/patrol/PatrolAttentionWorkbench.tsx', ]; const entryPaths = [ @@ -18,6 +20,7 @@ const entryPaths = [ 'browser-tests/guest-disk-provenance.html', 'browser-tests/guest-reading-help.html', 'browser-tests/guest-row-memory-provenance.html', + 'browser-tests/guest-memory-agent-stale.html', 'browser-tests/patrol-rule-removal.html', ]; const harnessPaths = [ @@ -28,6 +31,7 @@ const harnessPaths = [ 'browser-tests/guest-disk-provenance.cjs', 'browser-tests/guest-reading-help.cjs', 'browser-tests/guest-row-memory-provenance.cjs', + 'browser-tests/guest-memory-agent-stale.cjs', 'browser-tests/patrol-rule-removal.cjs', 'browser-tests/patrol-rule-removal-final.cjs', ]; diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts index a8f78cb0a0..719e5cfbb4 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts @@ -171,6 +171,7 @@ const memoryReading = (resource: Resource) => resource.memory ? finiteMetric(getMemoryPercent(resource)) : undefined, resource.memory?.observation, isProxmoxGuest(resource), + { agentStale: resource.agent?.stale === true }, ); const guestDiskReading = (resource: Resource) => { diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx index 6eb1bd0ac0..820cd5f0cc 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx @@ -1219,6 +1219,49 @@ describe('GuestRow', () => { const observation = (state: string, date = observedAt, source = 'guest-agent-meminfo') => makeMemory({ observation: { state, source, observedAt: date } }); const noticeSelector = '[data-workload-memory-read-status]'; + it.each(['bars', 'sparklines'] as const)( + 'invalidates only a stale agent-owned carrier in the mounted %s row', + (mode) => { + const [guest, setGuest] = createSignal( + makeGuest({ memory: observation('current', observedAt, 'agent') }), + ); + const view = render(() => ( + + + + +
+ )); + expect(view.container.querySelector(noticeSelector)).toBeNull(); + setGuest({ ...guest(), agentStale: true, lastSeen: '2026-10-01T12:00:00Z' }); + const notice = view.container.querySelector(noticeSelector)!; + expect(notice).toHaveTextContent('Last known'); + expect(notice).toHaveTextContent('stopped reporting'); + expect(notice).toHaveTextContent('2026-09-30 11:00:00 UTC'); + expect(notice).not.toHaveTextContent('2026-10-01'); + if (mode === 'bars') + expect(screen.getByTestId('memory-bar')).toHaveAttribute( + 'data-reading-state', + 'last-known', + ); + else + expect( + screen.getByRole('img', { name: 'test-vm memory history, last known 50%' }), + ).toBeInTheDocument(); + setGuest({ ...guest(), memory: observation('current') }); + expect(view.container.querySelector(noticeSelector)).toBeNull(); + setGuest({ + ...guest(), + agentStale: false, + memory: observation('current', observedAt, 'agent'), + }); + expect(view.container.querySelector(noticeSelector)).toBeNull(); + }, + ); const history = { getGuestMetricSeries: vi.fn(() => [ { diff --git a/frontend-modern/src/utils/__tests__/memoryObservation.test.ts b/frontend-modern/src/utils/__tests__/memoryObservation.test.ts index f5ba44db2c..1f5b125f39 100644 --- a/frontend-modern/src/utils/__tests__/memoryObservation.test.ts +++ b/frontend-modern/src/utils/__tests__/memoryObservation.test.ts @@ -255,7 +255,7 @@ describe('current workload memory selection', () => { expect(iso).not.toHaveBeenCalled(); }); - it.each(['guest-agent-meminfo', 'agent', 'status-mem'])( + it.each(['guest-agent-meminfo', 'status-mem'])( 'selects current %s memory independently of a QEMU disk deferral', (source) => { const input = current({ @@ -268,6 +268,12 @@ describe('current workload memory selection', () => { }, ); + it('selects a reporting agent independently of a QEMU disk deferral', () => { + const input = current({ diskStatusReason: 'prev-vm-locked', lock: 'backup' }); + input.memory.observation!.source = 'agent'; + expect(getCurrentWorkloadMemoryUsage(input)).toBe(25); + }); + it('cannot use current metadata to revive unavailable telemetry', () => { const input = current(); input.memory.usageUnavailable = true; diff --git a/frontend-modern/src/utils/__tests__/staleAgentMemory.test.ts b/frontend-modern/src/utils/__tests__/staleAgentMemory.test.ts new file mode 100644 index 0000000000..ae24c7a611 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/staleAgentMemory.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; +import type { Resource } from '@/types/resource'; +import type { WorkloadGuest } from '@/types/workloads'; +import { + getCurrentWorkloadMemoryUsage, + getWorkloadMemoryObservationPresentation, +} from '@/utils/memoryObservation'; +import { evaluateFilterStack, parseFilterStack } from '@/utils/searchQuery'; +import { createWorkloadSortComparator } from '@/components/Workloads/workloadSelectors'; +import { + getGuestDrawerCurrentMetrics, + getGuestDrawerDeferredMetrics, +} from '@/components/Workloads/guestDrawerModel'; +import { + getResourceMetricsHistoryCurrentMetrics, + getResourceMetricsHistoryDeferredMetrics, +} from '@/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel'; + +const observedAt = '2026-10-10T12:00:00Z'; +const guest = (source = 'agent', state = 'current', usage = 95): WorkloadGuest => + ({ + id: 'fixture:pve:101', + name: 'linked-guest', + type: 'qemu', + status: 'running', + vmid: 101, + node: 'pve', + instance: 'fixture', + platformScopes: ['proxmox-pve'], + agentStale: true, + // Another producer can renew this without a new agent report. + lastSeen: '2026-10-11T12:00:00Z', + memory: { + total: 100, + used: usage, + free: 100 - usage, + usage, + observation: { state, source, observedAt }, + }, + }) as WorkloadGuest; + +const resource = (input: WorkloadGuest): Resource => ({ + id: input.id, + name: input.name, + type: 'vm', + status: 'online', + platformType: 'proxmox-pve', + lastSeen: Date.parse(input.lastSeen!), + agent: { stale: input.agentStale }, + memory: { current: input.memory.usage, observation: input.memory.observation }, +}); + +describe('source-owned stale agent memory', () => { + it.each([0, 95])( + 'retains a value (%s) without asserting a current agent measurement', + (usage) => { + const input = guest('agent', 'current', usage); + const original = structuredClone(input); + const reading = getWorkloadMemoryObservationPresentation(input, { includeCurrent: false }); + expect(reading?.state).toBe('last-known'); + expect(reading?.summary).toContain('Last known · Pulse Agent (stopped reporting)'); + expect(reading?.summary).toContain('2026-10-10 12:00:00 UTC'); + expect(reading?.message).toContain('stopped reporting'); + expect(reading?.message).not.toContain('2026-10-11'); + expect(getCurrentWorkloadMemoryUsage(input)).toBeNull(); + expect(input).toEqual(original); + }, + ); + + it.each(['guest-agent-meminfo', 'guest-agent-meminfo-derived', 'status-mem', 'available-field'])( + 'does not downgrade independent current %s memory when the linked agent is stale', + (source) => { + const input = guest(source); + expect(getCurrentWorkloadMemoryUsage(input)).toBe(95); + expect(getWorkloadMemoryObservationPresentation(input)?.state).toBe('current'); + expect(getWorkloadMemoryObservationPresentation(input)?.message).not.toContain( + 'stopped reporting', + ); + expect(getResourceMetricsHistoryCurrentMetrics(resource(input)).memory).toBe(95); + }, + ); + + it('never supplies missing provenance or revives unavailable telemetry', () => { + for (const state of ['unavailable', 'future-state']) { + const input = guest('agent', state); + expect(getCurrentWorkloadMemoryUsage(input)).toBeNull(); + expect(getWorkloadMemoryObservationPresentation(input)?.state).toBe( + state === 'unavailable' ? 'unavailable' : 'unknown', + ); + } + for (const time of [undefined, 'invalid', '2099-01-01T00:00:00Z']) { + const input = guest(); + input.memory.observation!.observedAt = time; + expect(getCurrentWorkloadMemoryUsage(input)).toBeNull(); + expect(getWorkloadMemoryObservationPresentation(input)?.state).toBe('unknown'); + } + const input = guest(); + input.memory.usageUnavailable = true; + expect(getWorkloadMemoryObservationPresentation(input)?.state).toBe('unavailable'); + }); + + it('does not rank or filter a retained high reading as current pressure', () => { + const stale = guest(); + const current = { ...guest('guest-agent-meminfo', 'current', 10), id: 'current' }; + for (const direction of ['asc', 'desc'] as const) { + expect([stale, current].sort(createWorkloadSortComparator('memory', direction)!)).toEqual([ + current, + stale, + ]); + } + for (const query of ['memory>90', 'memory<100', 'memory=95']) { + expect(evaluateFilterStack(stale, parseFilterStack(query))).toBe(false); + } + }); + + it.each([0, 95])( + 'keeps the value (%s) as original last-known evidence in both History consumers', + (usage) => { + const input = guest('agent', 'current', usage); + const canonical = resource(input); + expect(getGuestDrawerCurrentMetrics(input).memory).toBeUndefined(); + expect(getResourceMetricsHistoryCurrentMetrics(canonical).memory).toBeUndefined(); + for (const deferred of [ + getGuestDrawerDeferredMetrics(input), + getResourceMetricsHistoryDeferredMetrics(canonical), + ]) { + expect(deferred.memory.lastKnownValue).toBe(usage); + expect(deferred.memory.message).toContain('2026-10-10 12:00:00 UTC'); + expect(deferred.memory.message).toContain('stopped reporting'); + } + }, + ); + + it('restores current measured zero only when its source reports again', () => { + const input = guest('agent', 'current', 0); + input.agentStale = false; + input.memory.observation!.observedAt = '2026-10-11T12:00:00Z'; + expect(getCurrentWorkloadMemoryUsage(input)).toBe(0); + expect(getWorkloadMemoryObservationPresentation(input)?.state).toBe('current'); + expect(getWorkloadMemoryObservationPresentation(input)?.message).not.toContain( + 'stopped reporting', + ); + expect(getGuestDrawerCurrentMetrics(input).memory).toBe(0); + expect(getResourceMetricsHistoryCurrentMetrics(resource(input)).memory).toBe(0); + expect(getGuestDrawerDeferredMetrics(input)).not.toHaveProperty('memory'); + expect(getResourceMetricsHistoryDeferredMetrics(resource(input))).not.toHaveProperty('memory'); + }); +}); diff --git a/frontend-modern/src/utils/memoryObservation.ts b/frontend-modern/src/utils/memoryObservation.ts index 8592b557e7..a497e10ecc 100644 --- a/frontend-modern/src/utils/memoryObservation.ts +++ b/frontend-modern/src/utils/memoryObservation.ts @@ -75,13 +75,16 @@ const memoryObservationState = ( value: number | undefined, observation: MemoryObservation | undefined, timestamp: number | null, + agentStale = false, ): MemoryObservationPresentation['state'] => !isMemoryPercent(value) || observation?.state === 'unavailable' ? 'unavailable' : observation?.state === 'last-known' ? 'last-known' : observation?.state === 'current' && timestamp !== null - ? 'current' + ? agentStale && observation.source === 'agent' + ? 'last-known' + : 'current' : 'unknown'; const requiresWorkloadMemoryObservation = (guest: WorkloadGuest): boolean => { @@ -116,6 +119,7 @@ export const getCurrentWorkloadMemoryUsage = (guest: WorkloadGuest): number | nu memory.usage, memory.observation, memoryObservationTime(memory.observation), + guest.agentStale === true, ) !== 'current' ) { return null; @@ -142,13 +146,17 @@ export const getMemoryObservationPresentation = ( value: number | undefined, observation: MemoryObservation | undefined, requiresObservation = false, - options: { includeCurrent?: boolean; guestKind?: MemoryGuestKind } = {}, + options: { includeCurrent?: boolean; guestKind?: MemoryGuestKind; agentStale?: boolean } = {}, ): 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 = memoryObservationTime(observation); - const state = memoryObservationState(value, observation, timestamp); + // An agent-facet update can arrive without replacing the selected memory + // carrier. Honour the server's explicit source invalidation, but only for + // that agent's own reading: QGA and Proxmox remain independent. Do not add a + // client lease, renew the observation time or mutate its wire metadata. + const state = memoryObservationState(value, observation, timestamp, options.agentStale); // An unavailable reading shows no number, so there is nothing to qualify. const mayIncludeCache = state !== 'unavailable' && @@ -180,10 +188,14 @@ export const getMemoryObservationPresentation = ( : 'Unknown source'; const time = observed ? `Observed: ${observed}.` : 'Observation time unknown.'; const cacheNote = mayIncludeCache ? getCacheNote(options.guestKind) : undefined; + const sourceStatus = + state !== 'unavailable' && options.agentStale && observation?.source === 'agent' + ? ' The Pulse Agent that supplied this reading stopped reporting.' + : ''; return { state, - summary: `${label} · ${source}${mayIncludeCache ? ' (may include cache)' : ''}${observed ? ` · ${observed}` : ' · time unknown'}`, - message: `${label}. Source: ${source}. ${time}${state === 'last-known' || state === 'unknown' ? ' Not a current measurement.' : ''}${cacheNote ? ` ${cacheNote}` : ''}`, + summary: `${label} · ${source}${sourceStatus ? ' (stopped reporting)' : ''}${mayIncludeCache ? ' (may include cache)' : ''}${observed ? ` · ${observed}` : ' · time unknown'}`, + message: `${label}. Source: ${source}. ${time}${state === 'last-known' || state === 'unknown' ? ' Not a current measurement.' : ''}${sourceStatus}${cacheNote ? ` ${cacheNote}` : ''}`, mayIncludeCache, ...(cacheNote ? { cacheNote } : {}), }; @@ -216,6 +228,7 @@ export const getWorkloadMemoryObservationPresentation = ( { ...options, guestKind: getWorkloadMemoryGuestKind(guest), + agentStale: guest.agentStale === true, }, ); };