Read a silent in-guest agent's guest drawer readings as last known

A Pulse agent linked into a VM keeps its last report on the VM resource
after it stops reporting: the registry copies the agent's whole facet,
agent.stale included, onto the guest. The guest drawer never read that
flag, so a quiet agent's RAID kept its live colours and "Rebuild: 42% ·
118M/sec", its network rows and version read as current, and nothing
said the agent had gone.

- useWorkloads maps agent.stale onto the row as agentStale, and sets
  disksFromAgent when Proxmox reported no guest filesystems and the
  agent's own disks filled in.
- The guest drawer passes the stopped-reporting reason to RaidCard, puts
  a status line over muted network rows, marks the Pulse Agent row as
  stopped reporting, and reads agent-supplied filesystem rows as last
  known.
- Agent-supplied filesystem rows follow the agent's freshness instead of
  the Proxmox guest-read reason. A VM with a live linked agent but no
  qemu-guest-agent showed the agent's filesystems as "Usage unavailable"
  with advice to install qemu-guest-agent. A stopped VM keeps the Proxmox
  reason, and Proxmox's own guest filesystems keep their read state.

The workload table's disk cell, the drawer History, and the agent's OS,
addresses and physical disk health still carry no stale cue.
This commit is contained in:
rcourtman
2026-10-07 14:06:37 +01:00
parent 43a80dafd9
commit c9d4cdebf0
7 changed files with 337 additions and 13 deletions
@@ -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
@@ -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"
]
}
@@ -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<WorkloadGuest> = {}) =>
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(() => <GuestDrawer guest={makeAgentGuest({ agentStale: true })} onClose={vi.fn()} />);
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(() => <GuestDrawer guest={makeAgentGuest()} onClose={vi.fn()} />);
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(() => (
<GuestDrawer
guest={makeAgentGuest({
agentStale: true,
disksFromAgent: undefined,
diskStatusReason: undefined,
})}
onClose={vi.fn()}
/>
));
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(() => (
<GuestDrawer
guest={makeAgentGuest({ status: 'stopped', diskStatusReason })}
onClose={vi.fn()}
/>
));
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(() => <GuestDrawer guest={guest()} onClose={vi.fn()} />);
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', () => {
@@ -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) {
</Suspense>
</Show>
<Show when={props.guest.agentRaid?.length}>
<RaidCard arrays={props.guest.agentRaid} title="Guest RAID" />
<RaidCard
arrays={props.guest.agentRaid}
title="Guest RAID"
lastKnownReason={agentLastKnownReason()}
/>
</Show>
<div class="space-y-3">
@@ -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<UseWorkloadsModule['useWorkloads']> | 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();
@@ -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,
+10
View File
@@ -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