diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 2d7ffe53ea..74c740fffe 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -294,6 +294,14 @@ expose only a raw reason. A same-VM fresh observation withdraws the notice and updates values without remounting the row. Permission, disabled-agent and actual unavailable cases retain their own explanations. +When a linked Pulse agent's filesystems fill in for a VM with no Proxmox guest +filesystems (`disksFromAgent`), the reading follows that agent instead of the +Proxmox reason: current while it reports, last known once it is stale, and +unavailable once the guest is stopped. `getWorkloadGuestDiskRead` in +`workloadGuestPresentation.ts` owns that rule for the workload row's disk cell, +the guest drawer's filesystem rows and its History, so the three cannot +disagree about one guest; `workloadGuestPresentation.test.ts` pins it. + Verification: `workloadGuestPresentation.test.ts`, the mounted `GuestRow`, `StackedDiskBar` and `GuestDrawerOverview.filesystems` regressions, and `browser-tests/guest-disk-deferral.cjs` cover the fixed reasons, retained/no diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 796fc46ca0..6a0fc2d3bc 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -4491,6 +4491,13 @@ disk usage when the last VM snapshot is still recent guest-agent truth rather than an already carried-forward fallback. That keeps transient guest-agent or status-call failures from regressing a VM back to misleading allocated-disk data while still avoiding indefinite replay of stale disk summaries. +The previous VM that `previousVMFromView` builds from the unified read state +carries disk usage only when it is Proxmox's own reading. When a manually +linked Pulse agent's disk filled in because Proxmox had no guest filesystems +(`VMView.DiskFromLinkedAgent`), carrying it would relabel the agent's value as +a Proxmox `prev-` read, and with Proxmox outranking the agent on guests that +copy would then freeze over the agent's live disk. +`TestPreviousVMFromViewKeepsLinkedAgentDiskOutOfProxmoxCarry` pins both owners. That compatibility boundary also applies to historical snapshot labels that may still exist in tests, live in-memory state, or pre-canonical diagnostic paths: legacy aliases such as `rrd-available`, `rrd-data`, `node-status-available`, 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 ef776a8f02..b2e638b2ee 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -2712,9 +2712,26 @@ unavailable once the guest is stopped, whether its status or the Proxmox `vm-stopped` reason says so, and Proxmox's own guest filesystems keep their Proxmox read state. The two flags ride the existing row mapping and add no query or poll; `GuestDrawer.test.tsx` and `useWorkloads.test.ts` pin -both sources. The workload table's disk cell and the drawer History still read -the aggregate disk metric and the Proxmox reason, and the agent's OS, addresses -and physical disk health carry no stale cue yet. +both sources. The workload table's disk cell (`useGuestRowState.ts`, +`GuestRow.tsx`) and the drawer History (`guestDrawerModel.ts`) judge the same +guest through the drawer rows' rule, `getWorkloadGuestDiskRead` in +`workloadGuestPresentation.ts`: a silent agent's bars carry the Last known +label, a live agent's bars stay current under Proxmox `agent-not-running`, and +History reads the aggregate disk metric as current, last known or unavailable +the same way. The rule covers the aggregate because Proxmox records no guest +disk usage without guest filesystems, so a manually linked guest then keeps the +agent's disk metric, and the poller no longer carries that agent reading +forward as Proxmox's own `prev-` read (see the monitoring contract). +`TestResourceRegistry_ManualGuestAgentLinkDiskMetricFollowsProxmoxGuestFilesystems` +pins that merge; `GuestRow.test.tsx` and `GuestDrawer.test.tsx` pin the cell and +History. On the websocket path (`resourceStateAdapters.ts`) a native Proxmox VM +facet's new guest-read outcome drops filesystems it no longer reports, a native +agent facet without `disks` drops the agent's, and the agent facet always +carries `stale`, so the rows switch source and lose the Last known label when +the agent resumes, without a reload. A stale Proxmox source +can still lose the aggregate to a live agent while its own filesystem rows +remain; such a guest stays on the Proxmox rule. The agent's OS, addresses and +physical disk health carry no stale cue yet. Guest, node, and Docker-host drawer headers follow the same frontend-primitives dependency boundary for collapse: Workloads owns which inline row is selected and the close handler, while diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index d1199c9224..1f5c8cad6f 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -6867,6 +6867,15 @@ The performance report and reporting runtime snapshot handlers apply the same test before tabulating a disk temperature. That changes only which held value a report shows, not any storage or recovery path. +### A linked agent's stale flag and guest disk owner open no storage path + +`internal/unifiedresources/types.go` now always sends `AgentData.Stale`, so a +resumed agent clears its stopped-reporting mark in browsers that merge agent +facets field by field, and `VMView.DiskFromLinkedAgent` keeps the poller from +carrying a linked agent's guest disk forward as a Proxmox read. Both describe +which source's reading is current; no backup, retention, migration or recovery +path is added or moved. + ### Demo write guard ignores websocket upgrade headers `internal/api/demo_middleware.go` no longer exempts every request carrying diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 69b3cf77e3..9644854453 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -2303,6 +2303,16 @@ evidence of what the array was doing when the agent went quiet, but without live status colour. Where a retained rebuild percentage is shown, it reads "Rebuild was at N%" with no speed or progress bar, and a member that was not healthy names its state in its badge. +A Pulse agent manually linked into a guest carries the same signal there. +`AgentData.Stale` is always on the wire, because browsers merge agent facets +field by field (`resourceStateAdapters.ts`) and would keep a resumed agent's +omitted false as true; `TestAgentDataAlwaysSendsStale` pins it. That always +present `stale` also marks a native agent facet, so one without `disks` has +withdrawn them, and a native Proxmox VM facet's new guest-read outcome likewise +drops `disks` it no longer reports; either way the other source's filesystems +take over without a reload (`resourceStateAdapters.test.ts`). `VMView.DiskFromLinkedAgent` names the agent +as the owner of a guest's selected disk metric, which the monitoring poller +reads before it carries a Proxmox disk reading forward. For SMART disk temperatures on rows that still render, the provenance travels on `agent.sensors.smart[].collection`: the Machines temperature cell diff --git a/frontend-modern/browser-verification/1214e26434830968.json b/frontend-modern/browser-verification/1214e26434830968.json new file mode 100644 index 0000000000..00fd597c11 --- /dev/null +++ b/frontend-modern/browser-verification/1214e26434830968.json @@ -0,0 +1,60 @@ +{ + "version": 1, + "base_sha": "48c2d6cdce8d019151e3567873de88e21b76bb22", + "verified_at": "2026-10-07T19:21:21Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx", + "frontend-modern/src/components/Workloads/GuestRow.tsx", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts", + "frontend-modern/src/components/Workloads/useGuestRowState.ts", + "frontend-modern/src/utils/resourceStateAdapters.ts", + "frontend-modern/src/utils/workloadGuestPresentation.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "1cf03b76a8d8732db0fda9b2ec30c0245c350eadcb36a36909493a9f4d6e7831", + "frontend-modern/src/components/Workloads/GuestRow.tsx": "c7b8bb81f158d8d64195be65fa69b8ee18019414e602043c5413693efc06d1d6", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "a30e4435523c0d30f618eb7f0e803cfba9a3a5203851d642f1d5b25c2f33b10d", + "frontend-modern/src/components/Workloads/useGuestRowState.ts": "1c41975ec9447f3657cd4e1cf0e9fc6ce187fdca18e7f050f882ac5d483379c9", + "frontend-modern/src/utils/resourceStateAdapters.ts": "35e7e01344e2f78cfcf550cc0b277aa06e42c544a110a6c33b054d97d9b16765", + "frontend-modern/src/utils/workloadGuestPresentation.ts": "3d1458ee02891047f722d643e74978fac6ec4edae5a24f5eb1808331de2e3b8d" + }, + "routes": [ + "/proxmox?q=checkout-web-01", + "/proxmox?q=checkout-web-02", + "/proxmox?q=analytics-batch-01", + "/proxmox?q=checkout-web-193", + "/standalone" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 1920, + "height": 1000 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack (PULSE_MOCK_MODE, embedded production build of this tree on origin/main dace9295c6, rebased onto 48c2d6cdce which changes none of the verified frontend sources) with a verification-only generator overlay: Proxmox guest filesystems stripped to usage -1 + agent-not-running (or vm-stopped) for checkout-web-01/02/193, and standalone agents Apollo-114 and Meridian-731 held offline from a control file", + "Agents linked into VMs through POST /api/resources/{vm}/link: Apollo-114 into checkout-web-01 (silent), Spectrum-709 into checkout-web-02 (live), Meridian-848 into analytics-batch-01 (Proxmox filesystems kept, control), Meridian-731 into stopped checkout-web-193 (silent); /api/resources confirmed agent.stale, agent.disks, absent proxmox.disks and the agent-sourced metrics.disk", + "Mock-mode websocket state does not apply manual links (record-ingested resources skip applyManualLinks; chipped separately), so guest passes ran the Proxmox page on the linked REST resources with the websocket held by Playwright routeWebSocket", + "Before (main frontend + backend, same data): checkout-web-01 and checkout-web-02 disk cells read '- Unavailable' with 'Guest agent not running' and History deferred the disk on that message at 1440 and 390", + "After, live agent (checkout-web-02): agent filesystem bars with no read-status label, nothing deferred in History, drawer filesystem rows current (1440 and 390)", + "After, silent agent (checkout-web-01): bars plus 'Last known' (phone 'Prior') with 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.', drawer filesystem rows last known, History disk deferred on the same message (1440 and 390)", + "After, stopped guest (checkout-web-193): '- Unavailable' / 'N/A' with the vm-stopped message in cell, drawer and History; analytics-batch-01 with Proxmox filesystems unchanged", + "Machines (/standalone at 1920): Apollo-114 held silent read '(stale)' with the stopped-reporting title; after releasing the hold mid-session without a reload, main kept '(stale)' although /api/state had dropped stale, while this build received stale:false and cleared it with metrics resuming", + "No page errors or console errors on /proxmox (guest rows, drawers, History) and /standalone, with and without the websocket" + ], + "interactions": [ + "Playwright 1440x900 and 390x844: open /proxmox?q= for four guests, read the disk cell text, read-status notice and bar, open the guest drawer (phone: second visible cell), read the filesystem status line, switch to History and read the disk current/last-known/deferred legends; screenshots of rows and History", + "Same pass against a before binary built from main's frontend and backend on the same data dir", + "Playwright 1920x1000 on /standalone: watch Apollo-114 while the control file releases its silent hold, polling /api/state for the agent's stale flag and re-reading the row without reloading", + "API: GET /api/resources/{vm} and /api/state before and after linking and after backend restarts" + ] +} diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx index 2e4f080e38..9b3ba8e5e3 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx @@ -440,6 +440,54 @@ describe('GuestDrawer', () => { } }); + it("judges a linked agent's disk metric in History by that agent, not the Proxmox reason", async () => { + chartsApiMocks.getMetricsHistory.mockResolvedValue({ + resourceType: 'vm', + resourceId: 'inst1:node1:100', + range: '24h', + start: 1, + end: 3, + metrics: {}, + source: 'store', + }); + // Proxmox has no guest filesystems, so the agent's filesystems and disk + // metric fill in while Proxmox still reports its own agent-not-running. + const [guest, setGuest] = createSignal( + makeGuest({ + disk: { total: 100, used: 40, free: 60, usage: 40 }, + disks: [{ total: 100, used: 40, free: 60, usage: 40, mountpoint: '/' }], + disksFromAgent: true, + diskStatusReason: 'agent-not-running', + }), + ); + render(() => ); + fireEvent.click(screen.getByRole('tab', { name: 'History' })); + await waitFor(() => + expect(screen.getAllByText('No stored history in this range')).toHaveLength(3), + ); + const history = screen.getAllByTestId('guest-history-group-chart')[0]; + expect(history.querySelector('[data-history-current="disk"]')).toHaveTextContent( + 'Disk40.0%current', + ); + expect(history.querySelector('[data-history-deferred="disk"]')).toBeNull(); + + setGuest({ ...guest(), agentStale: true }); + expect(history.querySelector('[data-history-current="disk"]')).toBeNull(); + expect(history.querySelector('[data-history-last-known="disk"]')).toHaveTextContent( + 'Disk40.0%last known', + ); + expect(history.querySelector('[data-history-deferred="disk"]')).toHaveTextContent( + 'The Pulse Agent in this guest stopped reporting.', + ); + + setGuest({ ...guest(), status: 'stopped' }); + expect(history.querySelector('[data-history-last-known="disk"]')).toBeNull(); + expect(history.querySelector('[data-history-current="disk"]')).toBeNull(); + expect(history.querySelector('[data-history-deferred="disk"]')).toHaveTextContent( + 'Guest filesystem stats unavailable while the VM is stopped.', + ); + }); + it('uses the shared discovery loading fallback instead of a drawer-local spinner row', () => { expect(guestDrawerSource).toContain('DiscoveryLoadingFallback'); expect(guestDrawerSource).not.toContain( diff --git a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx index 7218755137..bce936019d 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx @@ -15,7 +15,7 @@ import type { DiscoveryIdentifiedSummary } from '@/utils/discoveryPresentation'; import { formatBytes } from '@/utils/format'; import type { MetricDisplayThresholds } from '@/utils/metricThresholds'; import { - getWorkloadGuestDiskStatusMessage, + getWorkloadGuestDiskRead, getWorkloadsGuestProtectionPresentation, } from '@/utils/workloadGuestPresentation'; @@ -152,44 +152,19 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { props.guest.vmware?.datacenterName || props.guest.vmware?.clusterName, ); - // Rows the linked Pulse agent supplied follow that agent's freshness, not - // the Proxmox reason for its own guest reads. A stopped guest has no - // current filesystems from either source, with or without the Proxmox - // vm-stopped reason, which it gives only for a guest with allocated disk. - const vmDiskReason = isGuestDrawerVM(props.guest) ? props.guest.diskStatusReason : undefined; - const agentDisks = props.guest.disksFromAgent === true; - const agentDisksStopped = - agentDisks && (props.guest.status === 'stopped' || vmDiskReason === 'vm-stopped'); - const diskReason = agentDisks ? undefined : vmDiskReason; - const diskReadState = agentDisks - ? agentDisksStopped + // Rows the linked Pulse agent supplied follow that agent's freshness, the + // same rule the workload table's disk cell and History read. + const diskRead = getWorkloadGuestDiskRead(props.guest, isGuestDrawerVM(props.guest)); + const diskReadState = + !props.guest.disksFromAgent && props.guest.telemetryAvailability?.disk === false ? 'unavailable' - : agentLastKnownReason() - ? 'last-known' - : 'current' - : props.guest.telemetryAvailability?.disk === false - ? 'unavailable' - : diskReason - ? diskReason.startsWith('prev-') - ? 'last-known' - : 'unavailable' - : 'current'; + : diskRead.state; const diskRows = (props.guest.disks ?? []).map((disk, index) => buildWorkloadsDiskPresentation(disk, index, props.diskThresholds, diskReadState), ); - const diskStatusMessage = agentDisks - ? agentDisksStopped - ? isGuestDrawerVM(props.guest) - ? getWorkloadGuestDiskStatusMessage('vm-stopped') - : 'Filesystem usage is unavailable.' - : agentLastKnownReason() - ? 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.' - : null - : diskReason - ? getWorkloadGuestDiskStatusMessage(diskReason) - : diskReadState === 'unavailable' - ? 'Filesystem usage is unavailable.' - : null; + const diskStatusMessage = + diskRead.message ?? + (diskReadState === 'unavailable' ? 'Filesystem usage is unavailable.' : null); return compactDetailSections([ discovery diff --git a/frontend-modern/src/components/Workloads/GuestRow.tsx b/frontend-modern/src/components/Workloads/GuestRow.tsx index ef0d60564d..fbdfce3e53 100644 --- a/frontend-modern/src/components/Workloads/GuestRow.tsx +++ b/frontend-modern/src/components/Workloads/GuestRow.tsx @@ -54,10 +54,6 @@ export { GUEST_COLUMNS, VIEW_MODE_COLUMNS } from './guestRowModel'; export type { GuestRowProps, WorkloadIOEmphasis } from './guestRowModel'; import { getGuestColumnStyle } from './guestRowModel'; -// Disk-read reasons that point at something broken the user can fix, as -// opposed to a guest that simply has no agent or is not running. -const DISK_READ_ACTIONABLE_REASONS = new Set(['permission-denied', 'agent-error']); - export function GuestRow(props: GuestRowProps) { const [rowActionProps] = splitProps(props, ['onClick']); const selectionGuard = createRowTextSelectionGuard(); @@ -82,6 +78,7 @@ export function GuestRow(props: GuestRowProps) { discoveryReadinessPresentation, diskRead, diskThresholds, + diskUsageRead, diskWrite, displayId, dockerHostId, @@ -286,7 +283,9 @@ export function GuestRow(props: GuestRowProps) { }; const getDiskStatusTooltip = () => { - if (!isVM(props.guest)) return 'Disk stats unavailable'; + const message = diskUsageRead().message; + if (message) return message; + if (!isVM(props.guest) || props.guest.disksFromAgent) return 'Disk stats unavailable'; const vm = props.guest as VM; return getWorkloadGuestDiskStatusMessage(vm.diskStatusReason); @@ -294,10 +293,9 @@ export function GuestRow(props: GuestRowProps) { // A native title or bar tooltip is not a visible freshness cue on touch, // and disappears from the chart's accessible value when the history lens opens. const diskReadStatus = createMemo(() => { - const guest = props.guest; - if (!isVM(guest) || !guest.diskStatusReason) return undefined; - const reason = guest.diskStatusReason; - const retained = reason.startsWith('prev-'); + const read = diskUsageRead(); + if (read.state === 'current') return undefined; + const retained = read.state === 'last-known'; return { label: retained ? usesCompactTableLayout() @@ -306,11 +304,11 @@ export function GuestRow(props: GuestRowProps) { : usesCompactTableLayout() ? 'N/A' : 'Unavailable', - message: getDiskStatusTooltip(), + message: read.message, valueLabelContext: retained ? ('last known' as const) : ('current' as const), // Most reasons are a setup gap (no guest agent, VM stopped) rather than a // fault, so they stay muted; only a broken read the user can fix is amber. - needsAction: DISK_READ_ACTIONABLE_REASONS.has(reason.replace(/^prev-/, '')), + needsAction: read.needsAction, }; }); const diskValueLabelContext = (): MetricMiniSparklineValueLabelContext => @@ -632,11 +630,7 @@ export function GuestRow(props: GuestRowProps) { aggregateDisk={props.guest.disk} anomaly={diskAnomaly()} thresholds={diskThresholds()} - statusMessage={ - isVM(props.guest) && props.guest.diskStatusReason - ? getDiskStatusTooltip() - : undefined - } + statusMessage={diskUsageRead().message ?? undefined} /> } > diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx index 36317abf96..384b5ce602 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx @@ -1076,6 +1076,61 @@ describe('GuestRow', () => { ); expect(container.querySelector('[data-workload-disk-read-status]')).toBeNull(); }); + + it("follows a linked agent's filesystems, not the Proxmox read reason", () => { + // Proxmox has no guest filesystems, so the agent's fill in while + // Proxmox still reports its own agent-not-running. + const agentGuest = (overrides: Partial = {}) => + makeGuest({ + diskStatusReason: 'agent-not-running', + disksFromAgent: true, + disks: [makeDisk({ mountpoint: '/' })], + ...overrides, + }); + const [guest, setGuest] = createSignal(agentGuest()); + const { container } = render(() => ( + + + + +
+ )); + expect(screen.getByTestId('disk-bar')).not.toHaveAttribute('data-status-message'); + expect(container.querySelector('[data-workload-disk-read-status]')).toBeNull(); + + const lastKnown = + 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.'; + setGuest(agentGuest({ agentStale: true })); + expect(screen.getByTestId('disk-bar')).toHaveAttribute('data-status-message', lastKnown); + let notice = container.querySelector('[data-workload-disk-read-status]'); + expect(notice?.querySelector('[aria-hidden="true"]')).toHaveTextContent('Last known'); + expect(notice).toHaveAttribute('title', lastKnown); + expect(notice?.className).toContain('text-muted'); + + setGuest(agentGuest({ agentStale: true, status: 'stopped', diskStatusReason: 'vm-stopped' })); + expect(screen.queryByTestId('disk-bar')).toBeNull(); + notice = container.querySelector('[data-workload-disk-read-status]'); + expect(notice?.querySelector('[aria-hidden="true"]')).toHaveTextContent('Unavailable'); + expect(notice).toHaveAttribute( + 'title', + 'Guest filesystem stats unavailable while the VM is stopped.', + ); + }); + + it("does not announce a silent linked agent's sparkline reading as current", () => { + renderGuestRow({ + guest: makeGuest({ + diskStatusReason: 'agent-not-running', + disksFromAgent: true, + agentStale: true, + }), + visibleColumnIds: ['name', 'disk'], + metricDisplayMode: 'sparklines', + }); + expect( + screen.getByRole('img', { name: 'test-vm disk usage history, last known 50%' }), + ).toBeInTheDocument(); + }); }); describe('lock label', () => { diff --git a/frontend-modern/src/components/Workloads/guestDrawerModel.ts b/frontend-modern/src/components/Workloads/guestDrawerModel.ts index 27fa776ad3..8f6c8c0aa5 100644 --- a/frontend-modern/src/components/Workloads/guestDrawerModel.ts +++ b/frontend-modern/src/components/Workloads/guestDrawerModel.ts @@ -13,6 +13,7 @@ import { } from '@/features/alerts/metricAlertPresentation'; import { formatBytes, formatPercent, getBackupInfo, type BackupThresholds } from '@/utils/format'; import { + getWorkloadGuestDiskRead, getWorkloadGuestDiskStatusMessage, getWorkloadsGuestBackupStatusPresentation, } from '@/utils/workloadGuestPresentation'; @@ -153,12 +154,13 @@ export const getGuestDrawerDeferredMetrics = ( guest: Guest, ): Record => { const metrics: Record = {}; - if (isGuestDrawerVM(guest) && guest.diskStatusReason) { + // History judges the aggregate disk metric by the same rule as the + // filesystem rows, so a linked agent's reading follows that agent. + const disk = getWorkloadGuestDiskRead(guest, isGuestDrawerVM(guest)); + if (disk.state !== 'current') { metrics.disk = { - lastKnownValue: guest.diskStatusReason.startsWith('prev-') - ? getGuestDrawerDiskUsage(guest) - : undefined, - message: getWorkloadGuestDiskStatusMessage(guest.diskStatusReason), + lastKnownValue: disk.state === 'last-known' ? getGuestDrawerDiskUsage(guest) : undefined, + message: disk.message, }; } const memory = getGuestDrawerMemoryReading(guest); @@ -188,7 +190,9 @@ export const getGuestDrawerCurrentMetrics = (guest: Guest): Record typeof value === 'number' && Number.isFinite(value) ? value : undefined; return { diff --git a/frontend-modern/src/components/Workloads/useGuestRowState.ts b/frontend-modern/src/components/Workloads/useGuestRowState.ts index 7b5d58bfc5..bdbc76c2b2 100644 --- a/frontend-modern/src/components/Workloads/useGuestRowState.ts +++ b/frontend-modern/src/components/Workloads/useGuestRowState.ts @@ -9,6 +9,7 @@ import { buildMetricKey } from '@/utils/metricsKeys'; import { getGuestHealthIndicator, isGuestRunning } from '@/utils/status'; import { formatBytes } from '@/utils/format'; import { getContainerRuntimeBadgeForRuntime } from '@/utils/resourceBadgePresentation'; +import { getWorkloadGuestDiskRead } from '@/utils/workloadGuestPresentation'; import { getCanonicalWorkloadId, getWorkloadPlatformScopes, @@ -225,21 +226,21 @@ export function useGuestRowState(props: GuestRowProps) { return ((disk.used ?? 0) / total) * 100; }); + // The cell's filesystem bars and aggregate follow the drawer's rule: a + // linked agent's filesystems follow that agent, Proxmox's its read reason. + const diskUsageRead = createMemo(() => + getWorkloadGuestDiskRead(props.guest, workloadType() === 'vm'), + ); + const hasDiskUsage = createMemo(() => { const guest = props.guest; if (!guest.disk || guest.telemetryAvailability?.disk === false) return false; const total = guest.disk.total ?? 0; if (!Number.isFinite(total) || total <= 0) return false; // A generic canonical merge can retain the previous numeric metric while - // the provider explicitly reports no usable guest reading. Only prev- - // reasons allow that number to remain visible as last-known evidence. - if ( - workloadType() === 'vm' && - 'diskStatusReason' in guest && - guest.diskStatusReason && - !guest.diskStatusReason.startsWith('prev-') - ) - return false; + // its source reports no usable guest reading. Only a last known reading + // may keep that number visible. + if (diskUsageRead().state === 'unavailable') return false; const percent = diskPercent(); return Number.isFinite(percent) && percent >= 0; }); @@ -315,6 +316,7 @@ export function useGuestRowState(props: GuestRowProps) { discoveryReadinessPresentation, diskRead, diskThresholds, + diskUsageRead, diskWrite, displayId, dockerHostId, diff --git a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts index 9673cfd24b..9d9502f7b8 100644 --- a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts @@ -1706,6 +1706,82 @@ describe('fast merge path for metrics-only delta patches', () => { } }); + it("lets a linked agent's filesystems and freshness replace withdrawn ones", () => { + const raw = createPveGuestRaw(); + raw.sources = ['proxmox-pve', 'agent'] as Resource['sources']; + raw.platformData = { ...raw.platformData, sources: ['proxmox-pve', 'agent'] }; + raw.proxmox = { + ...raw.proxmox, + runtimeStatus: 'running', + guestAgentStatus: 'available', + disks: [{ mountpoint: '/', total: 2048, used: 512 }], + }; + raw.agent = { + agentId: 'agent-in-vm', + stale: true, + disks: [{ mountpoint: '/', total: 4096, used: 1024 }], + } as Resource['agent']; + raw.platformData = { ...raw.platformData, agent: raw.agent }; + const previous = seedDisplayRows([raw]); + // The guest agent stopped, so Proxmox withdraws its filesystems, and the + // linked Pulse agent reports again with an explicit stale=false. + const incoming = structuredClone(raw); + incoming.proxmox = { + vmid: 100, + nodeName: 'pve-node-1', + runtimeStatus: 'running', + guestAgentStatus: 'expected-unreachable', + diskStatusReason: 'agent-not-running', + }; + incoming.agent = { ...raw.agent, stale: false } as Resource['agent']; + incoming.platformData = { ...incoming.platformData, agent: incoming.agent }; + const changed = new Set([raw.id]); + const full = mergeCanonicalResourceSnapshot([incoming], previous); + const delta = mergeCanonicalResourceDeltaSnapshot([incoming], previous, changed); + const fast = mergeCanonicalResourceDeltaSnapshot( + [incoming], + previous, + changed, + new Map([[raw.id, ['proxmox', 'agent']]]), + ); + for (const [merged] of [full, delta, fast]) { + expect(merged.proxmox?.disks).toBeUndefined(); + expect(merged.proxmox?.diskStatusReason).toBe('agent-not-running'); + expect(merged.agent?.stale).toBe(false); + expect(merged.agent?.disks).toEqual(raw.agent?.disks); + } + + // A partial facet without a native outcome keeps the prior filesystems. + const partial = { ...raw, proxmox: { vmid: 100, uptime: 1001 } } as Resource; + const [kept] = mergeCanonicalResourceSnapshot([partial], previous); + expect(kept.proxmox?.disks).toEqual(raw.proxmox?.disks); + + // A native agent report (it always carries stale) without disks has + // withdrawn them; a legacy facet without stale keeps the prior list. + const withdrawn = structuredClone(raw); + withdrawn.agent = { agentId: 'agent-in-vm', stale: false } as Resource['agent']; + withdrawn.platformData = { ...withdrawn.platformData, agent: withdrawn.agent }; + for (const [merged] of [ + mergeCanonicalResourceSnapshot([withdrawn], previous), + mergeCanonicalResourceDeltaSnapshot([withdrawn], previous, changed), + mergeCanonicalResourceDeltaSnapshot( + [withdrawn], + previous, + changed, + new Map([[raw.id, ['agent']]]), + ), + ]) { + expect(merged.agent?.disks).toBeUndefined(); + expect(merged.agent?.stale).toBe(false); + // The compatibility mirror that host threshold rows read drops them too. + const mirror = (merged.platformData as { agent?: { disks?: unknown } } | undefined)?.agent; + expect(mirror?.disks).toBeUndefined(); + } + const legacy = { ...raw, agent: { agentId: 'agent-in-vm' } } as Resource; + const [legacyKept] = mergeCanonicalResourceSnapshot([legacy], previous); + expect(legacyKept.agent?.disks).toEqual(raw.agent?.disks); + }); + it('keeps explicit lock and deferral evidence on a new outcome, including false expected', () => { const raw = createPveGuestRaw(); raw.proxmox = { diff --git a/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts b/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts index 7edf413296..8c4dff0eb3 100644 --- a/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts @@ -4,6 +4,7 @@ import { getWorkloadsGuestBackupStatusPresentation, getWorkloadsGuestBackupTooltip, getWorkloadsGuestProtectionPresentation, + getWorkloadGuestDiskRead, getWorkloadGuestDiskStatusMessage, getWorkloadsGuestNetworkEmptyState, } from '@/utils/workloadGuestPresentation'; @@ -104,4 +105,63 @@ describe('workloadGuestPresentation', () => { 'Using last known disk stats. No filesystems found. VM may be booting or using a Live ISO.', ); }); + + it("judges a linked agent's filesystems by that agent, not the Proxmox read reason", () => { + const agent = { diskStatusReason: 'agent-not-running', disksFromAgent: true }; + expect(getWorkloadGuestDiskRead({ ...agent, status: 'running' }, true)).toEqual({ + state: 'current', + message: null, + needsAction: false, + }); + expect( + getWorkloadGuestDiskRead({ ...agent, status: 'running', agentStale: true }, true), + ).toEqual({ + state: 'last-known', + message: 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.', + needsAction: false, + }); + // A stopped guest has no current filesystems from either source, with or + // without the Proxmox vm-stopped reason. + for (const guest of [ + { ...agent, status: 'stopped', agentStale: true }, + { ...agent, status: 'running', diskStatusReason: 'vm-stopped' }, + ]) { + expect(getWorkloadGuestDiskRead(guest, true)).toEqual({ + state: 'unavailable', + message: 'Guest filesystem stats unavailable while the VM is stopped.', + needsAction: false, + }); + } + expect(getWorkloadGuestDiskRead({ disksFromAgent: true, status: 'stopped' }, false)).toEqual({ + state: 'unavailable', + message: 'Filesystem usage is unavailable.', + needsAction: false, + }); + }); + + it('keeps Proxmox read reasons for its own guest filesystems', () => { + expect(getWorkloadGuestDiskRead({ status: 'running' }, true).state).toBe('current'); + expect(getWorkloadGuestDiskRead({ diskStatusReason: 'prev-vm-locked' }, true)).toEqual({ + state: 'last-known', + message: getWorkloadGuestDiskStatusMessage('prev-vm-locked'), + needsAction: false, + }); + expect(getWorkloadGuestDiskRead({ diskStatusReason: 'agent-not-running' }, true)).toEqual({ + state: 'unavailable', + message: getWorkloadGuestDiskStatusMessage('agent-not-running'), + needsAction: false, + }); + expect( + getWorkloadGuestDiskRead({ diskStatusReason: 'prev-agent-error' }, true).needsAction, + ).toBe(true); + expect( + getWorkloadGuestDiskRead({ diskStatusReason: 'permission-denied' }, true).needsAction, + ).toBe(true); + // Only a VM carries a Proxmox guest read reason. + expect(getWorkloadGuestDiskRead({ diskStatusReason: 'prev-vm-locked' }, false).state).toBe( + 'current', + ); + // An agent's staleness says nothing about Proxmox's own filesystems. + expect(getWorkloadGuestDiskRead({ agentStale: true }, true).state).toBe('current'); + }); }); diff --git a/frontend-modern/src/utils/resourceStateAdapters.ts b/frontend-modern/src/utils/resourceStateAdapters.ts index 72efec2fb5..fcd3bac486 100644 --- a/frontend-modern/src/utils/resourceStateAdapters.ts +++ b/frontend-modern/src/utils/resourceStateAdapters.ts @@ -124,8 +124,9 @@ const mergeRecord = (incoming?: T, existing?: T): T | unde }; // Native VM facets carry a runtime state and an explicit guest-agent outcome. -// Their optional read reason, lock and false expected flag are omitempty on -// wire. A new outcome supersedes those fields, not unrelated richer metadata. +// Their optional read reason, lock, false expected flag and guest filesystems +// are omitempty on wire. A new outcome supersedes those fields, not unrelated +// richer metadata; withdrawn filesystems must not hide a linked agent's. // Partial/legacy facets without that evidence must retain the prior facts. const mergeProxmoxFacet = (incoming?: T, existing?: T): T | undefined => { const merged = mergeRecord(incoming, existing); @@ -142,12 +143,30 @@ const mergeProxmoxFacet = (incoming?: T, existing?: T): T ) return merged; const next = { ...merged }; - for (const key of ['diskStatusReason', 'guestAgentExpected', 'lock']) { + for (const key of ['diskStatusReason', 'guestAgentExpected', 'lock', 'disks']) { if (!Object.prototype.hasOwnProperty.call(incoming, key)) delete next[key]; } return next; }; +// Native agent facets always carry `stale`; facets synthesized from legacy +// platform data never do. Their filesystem list is omitempty on wire, so a +// native report without `disks` has withdrawn them, while a partial or legacy +// facet keeps the prior list. +const mergeAgentFacet = (incoming?: T, existing?: T): T | undefined => { + const merged = mergeRecord(incoming, existing); + if ( + !incoming || + !merged || + typeof incoming.stale !== 'boolean' || + Object.prototype.hasOwnProperty.call(incoming, 'disks') + ) + return merged; + const next = { ...merged }; + delete next.disks; + return next; +}; + const mergePlatformData = ( incomingValue: Resource['platformData'], existingValue: Resource['platformData'], @@ -180,7 +199,9 @@ const mergePlatformData = ( const nested = key === 'proxmox' ? mergeProxmoxFacet(asRecord(incoming[key]), asRecord(existing[key])) - : mergeRecord(asRecord(incoming[key]), asRecord(existing[key])); + : key === 'agent' + ? mergeAgentFacet(asRecord(incoming[key]), asRecord(existing[key])) + : mergeRecord(asRecord(incoming[key]), asRecord(existing[key])); if (key === 'docker') { if (hasDockerFacetEvidence(nested)) { merged[key] = nested; @@ -895,7 +916,9 @@ const mergeCanonicalSourceFacet = ( shouldKeepSourceFacet(incomingSources, ...sourceCandidates) ? sourceCandidates.includes('proxmox-pve') ? mergeProxmoxFacet(incomingFacet, existingFacet) - : mergeRecord(incomingFacet, existingFacet) + : sourceCandidates.includes('agent') + ? mergeAgentFacet(incomingFacet, existingFacet) + : mergeRecord(incomingFacet, existingFacet) : incomingFacet; // A missing field alone can be a partial snapshot. Explicit unavailable raw diff --git a/frontend-modern/src/utils/workloadGuestPresentation.ts b/frontend-modern/src/utils/workloadGuestPresentation.ts index 36fbe7d28f..b6be31269c 100644 --- a/frontend-modern/src/utils/workloadGuestPresentation.ts +++ b/frontend-modern/src/utils/workloadGuestPresentation.ts @@ -146,3 +146,61 @@ export function getWorkloadGuestDiskStatusMessage(reason?: string): string { return carriedForward ? `Using last known disk stats. ${message}` : message; } + +export interface WorkloadGuestDiskReadSource { + status?: string; + /** Proxmox's reason for its own guest filesystem read. */ + diskStatusReason?: string; + /** `disks` came from the linked Pulse agent because Proxmox reported none. */ + disksFromAgent?: boolean; + agentStale?: boolean; +} + +export type WorkloadGuestDiskRead = + | { state: 'current'; message: null; needsAction: false } + | { + state: 'last-known' | 'unavailable'; + /** Why the reading is not current. */ + message: string; + /** A broken read the user can fix, rather than a setup gap. */ + needsAction: boolean; + }; + +// Disk-read reasons that point at something broken the user can fix. +const DISK_READ_ACTIONABLE_REASONS = new Set(['permission-denied', 'agent-error']); + +// One freshness rule for a guest's filesystem rows and aggregate disk metric, +// shared by the workload table cell, the drawer and its History. Proxmox +// records no guest disk usage without guest filesystems, so when the linked +// Pulse agent's filesystems fill in, the registry keeps the agent's disk +// metric too and the reading follows that agent, not the Proxmox reason. +export function getWorkloadGuestDiskRead( + guest: WorkloadGuestDiskReadSource, + isVM: boolean, +): WorkloadGuestDiskRead { + const reason = isVM ? guest.diskStatusReason : undefined; + if (guest.disksFromAgent) { + if (guest.status === 'stopped' || reason === 'vm-stopped') { + return { + state: 'unavailable', + message: isVM + ? getWorkloadGuestDiskStatusMessage('vm-stopped') + : 'Filesystem usage is unavailable.', + needsAction: false, + }; + } + return guest.agentStale + ? { + state: 'last-known', + message: 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.', + needsAction: false, + } + : { state: 'current', message: null, needsAction: false }; + } + if (!reason) return { state: 'current', message: null, needsAction: false }; + return { + state: reason.startsWith('prev-') ? 'last-known' : 'unavailable', + message: getWorkloadGuestDiskStatusMessage(reason), + needsAction: DISK_READ_ACTIONABLE_REASONS.has(reason.replace(/^prev-/, '')), + }; +} diff --git a/internal/monitoring/monitor_host_agents_test.go b/internal/monitoring/monitor_host_agents_test.go index baef427792..2f99b2aada 100644 --- a/internal/monitoring/monitor_host_agents_test.go +++ b/internal/monitoring/monitor_host_agents_test.go @@ -7124,3 +7124,47 @@ func TestHostAgentSMARTRowForSwappedOutDiskDoesNotLendItsIdentity(t *testing.T) }) } } + +// A Pulse agent linked into a VM fills the guest disk metric when Proxmox has +// no guest filesystems. The next poll must not carry that agent reading +// forward as Proxmox's own last known guest read: with Proxmox outranking the +// agent on guests, the carried copy would freeze over the agent's live disk. +func TestPreviousVMFromViewKeepsLinkedAgentDiskOutOfProxmoxCarry(t *testing.T) { + now := time.Now() + view := func(source unifiedresources.DataSource) *unifiedresources.VMView { + used, total := int64(400), int64(1000) + v := unifiedresources.NewVMView(&unifiedresources.Resource{ + ID: "vm-pve-node1-101", + Type: unifiedresources.ResourceTypeVM, + Name: "app-101", + LastSeen: now, + Proxmox: &unifiedresources.ProxmoxData{ + Instance: "pve", + NodeName: "node1", + VMID: 101, + RuntimeStatus: "running", + DiskStatusReason: "agent-not-running", + }, + // The linked agent's addresses count as recent guest evidence. + Identity: unifiedresources.ResourceIdentity{IPAddresses: []string{"10.0.0.5"}}, + Metrics: &unifiedresources.ResourceMetrics{ + Disk: &unifiedresources.MetricValue{Used: &used, Total: &total, Percent: 40, Source: source}, + }, + }) + return &v + } + carry := func(source unifiedresources.DataSource) (float64, string) { + prev := previousVMFromView(view(source)) + _, _, _, usage, _, reason := stabilizeGuestLowTrustDisk( + &prev, "running", 32<<30, 0, 32<<30, -1, nil, "agent-not-running", false, now, + ) + return usage, reason + } + + if usage, reason := carry(unifiedresources.SourceAgent); usage != -1 || reason != "agent-not-running" { + t.Fatalf("linked agent disk carried as Proxmox's: usage=%v reason=%q", usage, reason) + } + if usage, reason := carry(unifiedresources.SourceProxmox); usage != 40 || reason != "prev-agent-not-running" { + t.Fatalf("Proxmox's own guest disk not carried: usage=%v reason=%q", usage, reason) + } +} diff --git a/internal/monitoring/monitor_previous_state.go b/internal/monitoring/monitor_previous_state.go index a186647fab..6f33e6e8f9 100644 --- a/internal/monitoring/monitor_previous_state.go +++ b/internal/monitoring/monitor_previous_state.go @@ -124,25 +124,32 @@ func previousVMFromView(vm *unifiedresources.VMView) models.VM { instance := vm.Instance() node := vm.Node() vmid := vm.VMID() - return models.VM{ - ID: makeGuestID(instance, node, vmid), - Instance: instance, - Node: node, - VMID: vmid, - Name: vm.Name(), - Type: "qemu", - Status: vm.RuntimeStatus(), - IPAddresses: vm.IPAddresses(), - OSName: vm.OSName(), - OSVersion: vm.OSVersion(), - AgentVersion: vm.AgentVersion(), - Lock: vm.Lock(), - Disk: models.Disk{ + // Only Proxmox's own guest disk reading may be carried forward as last + // known. A linked agent's disk that filled in stays the agent's, or the + // carry would relabel it as Proxmox's and freeze it over the agent's. + var disk models.Disk + if !vm.DiskFromLinkedAgent() { + disk = models.Disk{ Used: vm.DiskUsed(), Total: vm.DiskTotal(), Free: max(0, vm.DiskTotal()-vm.DiskUsed()), Usage: vm.DiskPercent(), - }, + } + } + return models.VM{ + ID: makeGuestID(instance, node, vmid), + Instance: instance, + Node: node, + VMID: vmid, + Name: vm.Name(), + Type: "qemu", + Status: vm.RuntimeStatus(), + IPAddresses: vm.IPAddresses(), + OSName: vm.OSName(), + OSVersion: vm.OSVersion(), + AgentVersion: vm.AgentVersion(), + Lock: vm.Lock(), + Disk: disk, NetworkInterfaces: guestNetworkInterfacesFromReadStateView(vm.NetworkInterfaces()), Disks: guestDisksFromReadStateView(vm.Disks()), DiskStatusReason: vm.DiskStatusReason(), diff --git a/internal/unifiedresources/canonical_ids_types_test.go b/internal/unifiedresources/canonical_ids_types_test.go index c8ed1bedb5..8dbb9b6580 100644 --- a/internal/unifiedresources/canonical_ids_types_test.go +++ b/internal/unifiedresources/canonical_ids_types_test.go @@ -1007,3 +1007,21 @@ func TestResourceIncidentNativeSeverityJSONContract(t *testing.T) { } } } + +// Browsers merge agent facets field by field and keep an omitted field, so a +// resumed agent must send stale=false or it stays marked as stopped reporting. +func TestAgentDataAlwaysSendsStale(t *testing.T) { + for _, stale := range []bool{false, true} { + raw, err := json.Marshal(AgentData{AgentID: "agent-1", Stale: stale}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got, ok := decoded["stale"]; !ok || got != stale { + t.Fatalf("stale = %v (present %v), want explicit %v in %s", got, ok, stale, raw) + } + } +} diff --git a/internal/unifiedresources/registry_test.go b/internal/unifiedresources/registry_test.go index 56a426e60c..879a4efea6 100644 --- a/internal/unifiedresources/registry_test.go +++ b/internal/unifiedresources/registry_test.go @@ -5138,6 +5138,118 @@ func TestResourceRegistry_ManualGuestAgentLinkUsesAgentCPUOnlyWhenPlatformHasNoC } } +// The workload table, guest drawer and History judge a guest's disk reading by +// the linked agent's freshness when the agent's filesystems fill in for +// Proxmox's (useWorkloads disksFromAgent). That needs the registry to keep the +// agent's disk metric while Proxmox has no guest disk usage, also once the +// agent is silent, and Proxmox's own fresh reading otherwise. +func TestResourceRegistry_ManualGuestAgentLinkDiskMetricFollowsProxmoxGuestFilesystems(t *testing.T) { + now := time.Now().UTC() + agentDisks := []models.Disk{{Total: 1000, Used: 400, Free: 600, Usage: 40, Mountpoint: "/"}} + cases := []struct { + name string + vmDisk models.Disk + vmDisks []models.Disk + reason string + agentStatus string + agentLastSeen time.Time + wantSource DataSource + wantPercent float64 + wantStale bool + }{ + { + name: "no proxmox filesystems, live agent", + vmDisk: models.Disk{Total: 32 << 30, Usage: -1}, + reason: "agent-not-running", + agentStatus: "online", + agentLastSeen: now, + wantSource: SourceAgent, + wantPercent: 40, + }, + { + name: "no proxmox filesystems, silent agent keeps its last disk", + vmDisk: models.Disk{Total: 32 << 30, Usage: -1}, + reason: "agent-not-running", + agentStatus: "offline", + agentLastSeen: now.Add(-10 * time.Minute), + wantSource: SourceAgent, + wantPercent: 40, + wantStale: true, + }, + { + name: "proxmox guest filesystems keep proxmox disk", + vmDisk: models.Disk{Total: 1000, Used: 700, Free: 300, Usage: 70}, + vmDisks: []models.Disk{{Total: 1000, Used: 700, Free: 300, Usage: 70, Mountpoint: "/"}}, + agentStatus: "online", + agentLastSeen: now, + wantSource: SourceProxmox, + wantPercent: 70, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + snapshot := models.StateSnapshot{ + VMs: []models.VM{{ + ID: "pve-a:node-1:402", + VMID: 402, + Name: "vm-402", + Node: "node-1", + Instance: "pve-a", + Type: "qemu", + Status: "running", + LastSeen: now, + Disk: tc.vmDisk, + Disks: tc.vmDisks, + DiskStatusReason: tc.reason, + }}, + Hosts: []models.Host{{ + ID: "agent-402", + Hostname: "guest-402", + Status: tc.agentStatus, + LastSeen: tc.agentLastSeen, + Disks: agentDisks, + }}, + } + store := NewMemoryStore() + unlinked := NewRegistry(store) + unlinked.IngestSnapshot(snapshot) + var vmID, agentID string + for _, resource := range unlinked.List() { + switch resource.Type { + case ResourceTypeVM: + vmID = resource.ID + case ResourceTypeAgent: + agentID = resource.ID + } + } + if vmID == "" || agentID == "" { + t.Fatalf("vm %q / agent %q not ingested", vmID, agentID) + } + if err := store.AddLink(ResourceLink{ResourceA: vmID, ResourceB: agentID, PrimaryID: vmID}); err != nil { + t.Fatalf("add link: %v", err) + } + + rr := NewRegistry(store) + rr.IngestSnapshot(snapshot) + got, ok := rr.Get(vmID) + if !ok || got.Metrics == nil || got.Metrics.Disk == nil { + t.Fatalf("linked guest lost its disk metric: %+v", got) + } + if got.Metrics.Disk.Source != tc.wantSource || got.Metrics.Disk.Percent != tc.wantPercent { + t.Fatalf("disk = %+v, want %s at %.0f%%", got.Metrics.Disk, tc.wantSource, tc.wantPercent) + } + agentFilesystemsFillIn := got.Proxmox != nil && len(got.Proxmox.Disks) == 0 && + got.Agent != nil && len(got.Agent.Disks) > 0 + if agentFilesystemsFillIn != (tc.wantSource == SourceAgent) { + t.Fatalf("agent filesystems fill in = %v, disk metric source %s", agentFilesystemsFillIn, tc.wantSource) + } + if got.Agent == nil || got.Agent.Stale != tc.wantStale { + t.Fatalf("agent stale = %+v, want %v", got.Agent, tc.wantStale) + } + }) + } +} + func TestResourceRegistry_ManualGuestAgentLinkReconnectRestoresFreshPlatformCPU(t *testing.T) { now := time.Now().UTC() const ( diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index d54c5e831b..1e54e94b23 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -967,8 +967,10 @@ type AgentData struct { // offline by the staleness evaluator) even though the row itself may stay // online via another source such as the Proxmox API poll. It lets the UI // present the agent and its version as not-reporting instead of a - // healthy-looking stale value. - Stale bool `json:"stale,omitempty"` + // healthy-looking stale value. It is always sent: browsers merge agent + // facets field by field, so an omitted false would keep a resumed agent + // marked as stopped reporting. + Stale bool `json:"stale"` // LastReportAt is the agent's own last successful report time. On a // multi-source row (for example a Proxmox node also polled over the PVE // API) this differs from the row's LastSeen, which reflects the freshest diff --git a/internal/unifiedresources/views.go b/internal/unifiedresources/views.go index 81667e27e1..7f9d4a890c 100644 --- a/internal/unifiedresources/views.go +++ b/internal/unifiedresources/views.go @@ -364,6 +364,14 @@ func (v VMView) DiskPercent() float64 { return viewMetricPercent(v.r.Metrics, selectMetricsDisk) } +// DiskFromLinkedAgent reports whether the selected disk metric is the linked +// Pulse agent's. It fills in when Proxmox has no guest filesystems of its own, +// so it is never Proxmox's guest reading. +func (v VMView) DiskFromLinkedAgent() bool { + return v.r != nil && v.r.Metrics != nil && v.r.Metrics.Disk != nil && + v.r.Metrics.Disk.Source == SourceAgent +} + func (v VMView) NetIn() float64 { if v.r == nil { return 0 diff --git a/internal/unifiedresources/views_test.go b/internal/unifiedresources/views_test.go index 8c8bcc98cc..7cf1ce2e93 100644 --- a/internal/unifiedresources/views_test.go +++ b/internal/unifiedresources/views_test.go @@ -2374,3 +2374,19 @@ func TestContainerViewLinkedAgentMemory(t *testing.T) { t.Fatal("nil view has memory") } } + +func TestVMViewDiskFromLinkedAgentFollowsSelectedMetricSource(t *testing.T) { + for _, tc := range []struct { + metrics *ResourceMetrics + want bool + }{ + {metrics: &ResourceMetrics{Disk: &MetricValue{Percent: 40, Source: SourceAgent}}, want: true}, + {metrics: &ResourceMetrics{Disk: &MetricValue{Percent: 40, Source: SourceProxmox}}}, + {metrics: &ResourceMetrics{Memory: &MetricValue{Percent: 40, Source: SourceAgent}}}, + {metrics: nil}, + } { + if got := NewVMView(&Resource{Type: ResourceTypeVM, Metrics: tc.metrics}).DiskFromLinkedAgent(); got != tc.want { + t.Fatalf("DiskFromLinkedAgent(%+v) = %v, want %v", tc.metrics, got, tc.want) + } + } +}