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 55a9ba06fa..b339107a3e 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -2732,6 +2732,24 @@ must remain distinguishable without hover. Workloads supplies this row metadata, not local table CSS or a duplicate renderer; non-filesystem rows keep the compact default. Verification covers the opt-in at this composition boundary and actual long-path wrapping in the shared renderer's browser proof. +A guest's linked Pulse Agent can stop reporting while Proxmox keeps polling the +VM. `useWorkloads` copies `resource.agent.stale` onto the row as `agentStale`, +and sets `disksFromAgent` when Proxmox reported no guest filesystems and the +agent's own `disks` filled in. When that agent is stale, `GuestDrawerOverview.tsx` +reads the Guest RAID card as last known through the shared `RaidCard` +`lastKnownReason` prop with `HOST_AGENT_STOPPED_REPORTING_REASON`, puts a status +line over muted Network rows, reads agent-supplied Filesystems rows through the +existing `last-known` disk read state, and marks the Pulse Agent row as stopped +reporting. Agent-supplied filesystem rows follow the agent's freshness rather +than the Proxmox `diskStatusReason`, which describes Proxmox's own guest reads, +so a live agent's rows keep their usage and bars. Those rows read as +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. 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/frontend-modern/browser-verification/d4fbbd15ad939e02.json b/frontend-modern/browser-verification/d4fbbd15ad939e02.json new file mode 100644 index 0000000000..28d656c133 --- /dev/null +++ b/frontend-modern/browser-verification/d4fbbd15ad939e02.json @@ -0,0 +1,47 @@ +{ + "version": 1, + "base_sha": "43a80dafd91e92d3079a039cdb08929281812eef", + "verified_at": "2026-10-07T13:06:25Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx", + "frontend-modern/src/hooks/useWorkloads.ts", + "frontend-modern/src/types/workloads.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "f984547722c304cd3c80610a3e5ef743b3e6ad268493016ddc649c858e54e6de", + "frontend-modern/src/hooks/useWorkloads.ts": "1439f5bc51ad5e362eb21b65b9d95b870f84cdae823770f4ae41dd848905703d", + "frontend-modern/src/types/workloads.ts": "4eebb6b801a056900e7fc309679cdca891b48bdd2672e40dbb58eaffce4ca0c6" + }, + "routes": [ + "/proxmox?q=analytics-batch-310", + "/proxmox?q=analytics-batch-118", + "/proxmox?q=analytics-batch-58", + "/proxmox?q=analytics-batch-262" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Private mock stack built from origin/main 43a80dafd9 plus this change, with a local-only go build -overlay of internal/mock/generator.go: host agents apollo-114 and meridian-731 held past their reporting lease (offline, LastSeen frozen 14 minutes back, readings retained); all four standalone agents seeded with md0 raid1 clean and md1 raid5 'clean, degraded, recovering' at 42.4% / 118M/sec (sdd1 faulty, sde1 spare rebuilding); analytics-batch-310 and analytics-batch-58 forced to report no Proxmox guest filesystems with diskStatusReason agent-not-running", + "Agents linked into VMs through the real POST /api/resources/{vm}/link: apollo-114 -> analytics-batch-118, meridian-731 -> analytics-batch-310 (both silent), spectrum-709 -> analytics-batch-262 and meridian-848 -> analytics-batch-58 (both live). /api/resources serves agent.stale true with the agent's raid, disks and networkInterfaces on the two silent VMs and no stale flag on the two live ones", + "Before this change (same stack, PR 2652 head, real browser run): silent VMs showed Guest RAID with emerald 'clean', amber 'clean, degraded, recovering', 'Rebuild: 42% \u00b7 118M/sec' and coloured member badges, network rows and 'Pulse 0.2.0-alpha' as current; the live analytics-batch-58 showed its agent filesystems as 'Usage unavailable \u00b7 ?/328 GB' under 'Guest agent not running. Install and start qemu-guest-agent in the VM.'", + "After, silent analytics-batch-310 (agent filesystems) at 1440 and 390: Pulse Agent row 'Pulse 0.2.0-alpha \u00b7 stopped reporting' in amber with title 'Pulse Agent 0.2.0-alpha has stopped reporting. Its readings here are its last report.'; Filesystems status 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.' with 'Last known 75%' / 'Last known 42%' rows and no bars; Network status 'Using last known interface details. The Pulse Agent in this guest stopped reporting.' over a muted eth0 row; both RAID arrays data-raid-reading=last-known, '(last known)' states in muted text titled 'Last known reading, not current: host agent stopped reporting', 'Rebuild was at 42%', neutral badges 'sdd1 \u00b7 faulty' and 'sde1 \u00b7 spare rebuilding'", + "After, silent analytics-batch-118 with Proxmox guest filesystems (earlier run of the same change): RAID, network and agent row read as above while the Proxmox filesystem rows kept their usage and bars", + "After, live analytics-batch-58 (agent filesystems, Proxmox agent-not-running): '/ 65%' and '/data 28%' with progress bars, no status line, no qemu-guest-agent advice; RAID current with emerald/amber states and 'Rebuild: 42% \u00b7 118M/sec'", + "After, live control analytics-batch-262 (Proxmox filesystems): unchanged at both widths", + "No horizontal page overflow at either width; no page errors. Stopped guests were not exercised in the browser: their agent filesystems read 'Usage unavailable' under the stopped-VM message whether the guest status or the Proxmox vm-stopped reason says stopped, and the stale flip on an open drawer, are covered by GuestDrawer.test.tsx. The final stopped-status gate changes only stopped guests, so the running-guest states above were observed on this code path" + ], + "interactions": [ + "Opened each guest's inline drawer from the Proxmox workloads table filtered by name (name cell click at 1440, first visible cell tap at 390)", + "Scrolled the phone drawer to the Network section and Guest RAID card and captured both", + "Read computed text colour, titles and data-raid-reading of every Filesystems, Network, System and RAID row via Playwright" + ] +} diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx index 5f094a8215..2e4f080e38 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx @@ -1602,6 +1602,157 @@ describe('GuestDrawer', () => { }); }); + // ── In-guest Pulse Agent readings ── + + describe('In-guest Pulse Agent readings', () => { + // Proxmox read no guest filesystems, so these rows came from the agent. + const makeAgentGuest = (overrides: Partial = {}) => + makeGuest({ + agentVersion: '6.4.5', + agentKind: 'pulse', + disksFromAgent: true, + diskStatusReason: 'agent-not-running', + disks: [{ total: 100, used: 75, free: 25, usage: 75, mountpoint: '/' }], + networkInterfaces: [{ name: 'eth0', addresses: ['192.168.1.5'], rxBytes: 1024 }], + agentRaid: [ + { + device: '/dev/md1', + name: 'data', + level: 'raid5', + state: 'clean, degraded, recovering', + totalDevices: 3, + activeDevices: 2, + workingDevices: 3, + failedDevices: 1, + spareDevices: 0, + devices: [{ device: 'sdd1', state: 'faulty', slot: 1 }], + rebuildPercent: 42, + rebuildSpeed: '118M/sec', + }, + ], + ...overrides, + }); + + it("reads a silent in-guest agent's RAID, network and filesystems as last known", () => { + render(() => ); + + expect(technicalDetails().getByText('Pulse 6.4.5 · stopped reporting')).toHaveAttribute( + 'title', + 'Pulse Agent 6.4.5 has stopped reporting. Its readings here are its last report.', + ); + expect( + technicalDetails().getByText( + 'Using last known disk stats. The Pulse Agent in this guest stopped reporting.', + ), + ).toBeInTheDocument(); + expect(technicalDetails().getByText(/^Last known 75%/)).toBeInTheDocument(); + expect( + technicalDetails().queryByRole('progressbar', { name: 'Filesystem / utilization' }), + ).toBeNull(); + expect( + technicalDetails().getByText( + 'Using last known interface details. The Pulse Agent in this guest stopped reporting.', + ), + ).toBeInTheDocument(); + expect( + technicalDetails() + .getByText(/^192\.168\.1\.5/) + .closest('td'), + ).toHaveClass('text-muted'); + // Proxmox's guest-read reason does not describe rows the agent supplied. + expect(technicalDetails().queryByText(/qemu-guest-agent/)).toBeNull(); + + const array = screen.getByTestId('raid-card-array'); + expect(array).toHaveAttribute('data-raid-reading', 'last-known'); + expect(within(array).getByTestId('raid-card-state')).toHaveAttribute( + 'title', + 'Last known reading, not current: host agent stopped reporting', + ); + expect(within(array).getByTestId('raid-card-rebuild')).toHaveTextContent( + 'Rebuild was at 42%', + ); + }); + + it('keeps a live in-guest agent current when Proxmox has no guest reads', () => { + render(() => ); + + expect(technicalDetails().getByText('Pulse 6.4.5')).toBeInTheDocument(); + expect( + technicalDetails().getByRole('progressbar', { name: 'Filesystem / utilization' }), + ).toHaveAttribute('aria-valuenow', '75'); + expect(technicalDetails().queryByText(/Last known|qemu-guest-agent/)).toBeNull(); + expect(screen.getByTestId('raid-card-array')).toHaveAttribute('data-raid-reading', 'current'); + expect(screen.getByTestId('raid-card-rebuild')).toHaveTextContent('Rebuild: 42% · 118M/sec'); + }); + + it("keeps Proxmox's own guest filesystems current under a silent agent", () => { + render(() => ( + + )); + + expect( + technicalDetails().getByRole('progressbar', { name: 'Filesystem / utilization' }), + ).toHaveAttribute('aria-valuenow', '75'); + expect(technicalDetails().queryByText(/Using last known disk stats/)).toBeNull(); + expect(screen.getByTestId('raid-card-array')).toHaveAttribute( + 'data-raid-reading', + 'last-known', + ); + }); + + // Proxmox gives vm-stopped only for a guest with allocated disk, so the + // guest's own stopped status must hold the rows too. + it.each([ + ['with the Proxmox reason', 'vm-stopped'], + ['without a Proxmox reason', undefined], + ])( + "reads a stopped guest's agent filesystems as unavailable before the lease lapses, %s", + (_label, diskStatusReason) => { + render(() => ( + + )); + + expect( + technicalDetails().getByText( + 'Guest filesystem stats unavailable while the VM is stopped.', + ), + ).toBeInTheDocument(); + expect(technicalDetails().getByText(/^Usage unavailable/)).toBeInTheDocument(); + expect( + technicalDetails().queryByRole('progressbar', { name: 'Filesystem / utilization' }), + ).toBeNull(); + }, + ); + + it('follows the agent between silent and reporting while the drawer stays open', () => { + const [guest, setGuest] = createSignal(makeAgentGuest()); + render(() => ); + expect(screen.getByTestId('raid-card-array')).toHaveAttribute('data-raid-reading', 'current'); + + setGuest({ ...guest(), agentStale: true }); + expect(screen.getByTestId('raid-card-array')).toHaveAttribute( + 'data-raid-reading', + 'last-known', + ); + expect(technicalDetails().getByText('Pulse 6.4.5 · stopped reporting')).toBeInTheDocument(); + expect(technicalDetails().getByText(/^Last known 75%/)).toBeInTheDocument(); + + setGuest({ ...guest(), agentStale: undefined }); + expect(screen.getByTestId('raid-card-array')).toHaveAttribute('data-raid-reading', 'current'); + expect(technicalDetails().queryByText(/stopped reporting|Last known/)).toBeNull(); + }); + }); + // ── WebInterfaceUrlField ── describe('WebInterfaceUrlField', () => { diff --git a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx index 072c4c3e4a..7218755137 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx @@ -10,6 +10,7 @@ import { TechnicalDetailsSection } from '@/components/shared/TechnicalDetailsDis import { DrawerAttentionSection } from '@/components/shared/DrawerAttentionSection'; import type { Alert } from '@/types/api'; import { AvailabilityProbeStatusCards } from '@/components/Infrastructure/AvailabilityProbeStatusCard'; +import { HOST_AGENT_STOPPED_REPORTING_REASON } from '@/components/Infrastructure/resourceDetailMappers'; import type { DiscoveryIdentifiedSummary } from '@/utils/discoveryPresentation'; import { formatBytes } from '@/utils/format'; import type { MetricDisplayThresholds } from '@/utils/metricThresholds'; @@ -79,6 +80,10 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { ageClass: props.backupPresentation?.ageClass, }); const guestReadPresentation = () => getGuestDrawerGuestReadPresentation(props.guest); + // A linked Pulse agent past its reporting lease leaves its last report on + // the guest, so its RAID, network and filesystem rows read as last known. + const agentLastKnownReason = () => + props.guest.agentStale ? HOST_AGENT_STOPPED_REPORTING_REASON : undefined; const coverageLabel = () => { if (props.hasWorkloadActionAgent) return WORKLOAD_ACTION_AGENT_LABEL; if (props.hasAgentInfo) return `${props.agentHeading} observed`; @@ -147,9 +152,22 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { props.guest.vmware?.datacenterName || props.guest.vmware?.clusterName, ); - const diskReason = isGuestDrawerVM(props.guest) ? props.guest.diskStatusReason : undefined; - const diskReadState = - props.guest.telemetryAvailability?.disk === false + // 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 + ? 'unavailable' + : agentLastKnownReason() + ? 'last-known' + : 'current' + : props.guest.telemetryAvailability?.disk === false ? 'unavailable' : diskReason ? diskReason.startsWith('prev-') @@ -159,11 +177,19 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { const diskRows = (props.guest.disks ?? []).map((disk, index) => buildWorkloadsDiskPresentation(disk, index, props.diskThresholds, diskReadState), ); - const diskStatusMessage = diskReason - ? getWorkloadGuestDiskStatusMessage(diskReason) - : diskReadState === 'unavailable' - ? 'Filesystem usage is unavailable.' - : null; + 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; return compactDetailSections([ discovery @@ -186,7 +212,13 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { rows: compactDetailRows([ props.guest.cpus ? makeDetailRow('CPUs', `${props.guest.cpus}`) : null, props.hasAgentInfo - ? makeDetailRow(props.agentHeading, props.agentLabel, { title: props.agentTitle }) + ? agentLastKnownReason() + ? makeDetailRow(props.agentHeading, `${props.agentLabel} · stopped reporting`, { + title: `${props.agentTitle} has stopped reporting. Its readings here are its last report.`, + tone: 'warning', + wrap: true, + }) + : makeDetailRow(props.agentHeading, props.agentLabel, { title: props.agentTitle }) : null, ]), }, @@ -272,8 +304,16 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { props.hasNetworkInterfaces ? { label: 'Network', - rows: compactDetailRows( - props.networkInterfaces.slice(0, 4).map((iface, index) => { + rows: compactDetailRows([ + // Only the linked Pulse agent reports guest interfaces. + makeDetailRow( + 'Status', + agentLastKnownReason() + ? 'Using last known interface details. The Pulse Agent in this guest stopped reporting.' + : null, + { layout: 'stacked', wrap: true }, + ), + ...props.networkInterfaces.slice(0, 4).map((iface, index) => { const addresses = iface.addresses ?? []; const hasTraffic = (iface.rxBytes ?? 0) > 0 || (iface.txBytes ?? 0) > 0; const detail = [ @@ -291,11 +331,12 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { iface.name || `Interface ${index + 1}`, detail || 'No details', { + tone: agentLastKnownReason() ? 'muted' : undefined, wrap: true, }, ); }), - ), + ]), } : null, ]); @@ -338,7 +379,11 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { - +
diff --git a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts index 8a19338644..1549c6c930 100644 --- a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts +++ b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts @@ -142,6 +142,54 @@ describe('useWorkloads', () => { } }); + it('marks a silent linked agent and the guest filesystems it supplied', async () => { + const disk = (mountpoint: string) => ({ + mountpoint, + type: 'ext4', + total: 100, + used: 40, + free: 60, + usage: 40, + }); + const [snapshot] = createSignal([ + // Proxmox read no guest filesystems, so the agent's own report fills in. + { + ...sampleResource, + id: 'vm-agent-disks', + vmid: 102, + sources: ['proxmox', 'agent'], + agent: { agentVersion: '6.4.5', stale: true, disks: [disk('/agent')] }, + }, + // Proxmox guest filesystems win over a live agent's. + { + ...sampleResource, + id: 'vm-platform-disks', + vmid: 103, + sources: ['proxmox', 'agent'], + proxmox: { disks: [disk('/platform')] }, + agent: { agentVersion: '6.4.5', stale: false, disks: [disk('/agent')] }, + }, + ] as any); + let dispose = () => {}; + let result: ReturnType | undefined; + createRoot((d) => { + dispose = d; + result = useWorkloads(() => true, { resourceSnapshot: snapshot }); + }); + try { + await flushAsync(); + const byId = (id: string) => + result!.workloads().find((workload) => workload.canonicalResourceId === id); + expect(byId('vm-agent-disks')).toMatchObject({ agentStale: true, disksFromAgent: true }); + expect(byId('vm-agent-disks')?.disks?.map((d) => d.mountpoint)).toEqual(['/agent']); + expect(byId('vm-platform-disks')?.agentStale).toBeUndefined(); + expect(byId('vm-platform-disks')?.disksFromAgent).toBeUndefined(); + expect(byId('vm-platform-disks')?.disks?.map((d) => d.mountpoint)).toEqual(['/platform']); + } finally { + dispose(); + } + }); + afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); diff --git a/frontend-modern/src/hooks/useWorkloads.ts b/frontend-modern/src/hooks/useWorkloads.ts index 35f238e8cb..f8d45872eb 100644 --- a/frontend-modern/src/hooks/useWorkloads.ts +++ b/frontend-modern/src/hooks/useWorkloads.ts @@ -146,6 +146,7 @@ type APIResource = { networkInterfaces?: APINetworkInterface[]; disks?: APIDiskInfo[]; raid?: HostRAIDArray[]; + stale?: boolean; }; docker?: { containerId?: string; @@ -592,6 +593,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => { })(), disk: buildMetric(resource.metrics?.disk), disks: normalizeDiskArray(resource.proxmox?.disks ?? resource.agent?.disks), + disksFromAgent: (resource.proxmox?.disks == null && resource.agent?.disks != null) || undefined, // Both the API and owning platform snapshot pass through this mapper. // A retained numeric metric must not lose the provider's read deferral. diskStatusReason: resource.proxmox?.diskStatusReason, @@ -612,6 +614,9 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => { agentId: resource.agent?.agentId, agentRaid: resource.agent?.raid, networkInterfaces: mapNetworkInterfaces(resource.agent?.networkInterfaces), + // A linked agent past its reporting lease keeps its last report on the + // resource, so its RAID, network and filesystem rows are not current. + agentStale: resource.agent?.stale === true || undefined, networkIn: finiteMetricNumber(resource.metrics?.netIn?.value) ?? 0, networkOut: finiteMetricNumber(resource.metrics?.netOut?.value) ?? 0, diskRead: finiteMetricNumber(resource.metrics?.diskRead?.value) ?? 0, diff --git a/frontend-modern/src/types/workloads.ts b/frontend-modern/src/types/workloads.ts index 411d3c3777..b4bb88c54e 100644 --- a/frontend-modern/src/types/workloads.ts +++ b/frontend-modern/src/types/workloads.ts @@ -60,6 +60,16 @@ export type WorkloadGuest = (VM | Container) & { agentId?: string; /** RAID arrays reported by the linked Pulse agent, not Proxmox virtual disks. */ agentRaid?: HostRAIDArray[]; + /** + * Set when the linked Pulse agent is past its reporting lease. Its RAID, + * network and filesystem readings are then its last report, not current. + */ + agentStale?: boolean; + /** + * Set when `disks` came from the linked Pulse agent because the platform + * reported no guest filesystems, so the rows follow that agent's freshness. + */ + disksFromAgent?: boolean; updateStatus?: DockerContainerUpdateStatus; // Server-evaluated capability refusals from the unified resource. The // update button reads this to disable itself with the refusal reason