mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Judge a linked agent's guest disks by that agent everywhere
PR 2669 made the guest drawer's filesystem rows follow a linked Pulse agent's freshness when its filesystems fill in for a VM that has no Proxmox guest filesystems. The workload table's disk cell and the drawer History still judged the same reading only by the Proxmox read reason. So one guest read as current in the drawer and as "Unavailable: Guest agent not running" in the table and History, and a silent agent's reading could not show as last known there. getWorkloadGuestDiskRead now owns one rule for the table cell, the drawer rows and History: current while the agent reports, last known once it is stale, unavailable once the guest is stopped. Proxmox's own guest filesystems keep their read reason. The rule also covers the aggregate disk metric, because Proxmox records no guest disk usage without guest filesystems, so the manual link merge keeps the agent's metric. Three paths broke that: - The poller built its previous VM from the merged metric, so the next poll carried the agent's disk forward as a Proxmox prev- read, and Proxmox priority then froze it over the agent's live value. Only Proxmox's own disk reading is carried now. - agent.stale was omitempty, and the browser merges agent facets field by field, so a resumed agent stayed "stopped reporting" until a reload. It is always sent now. - The same merge kept filesystems a native Proxmox VM facet or a native agent facet had withdrawn, so the rows did not switch source.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=<guest> 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"
|
||||
]
|
||||
}
|
||||
@@ -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(() => <GuestDrawer guest={guest()} onClose={vi.fn()} />);
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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<WorkloadGuest> = {}) =>
|
||||
makeGuest({
|
||||
diskStatusReason: 'agent-not-running',
|
||||
disksFromAgent: true,
|
||||
disks: [makeDisk({ mountpoint: '/' })],
|
||||
...overrides,
|
||||
});
|
||||
const [guest, setGuest] = createSignal(agentGuest());
|
||||
const { container } = render(() => (
|
||||
<table>
|
||||
<tbody>
|
||||
<GuestRow guest={guest()} visibleColumnIds={['name', 'disk']} />
|
||||
</tbody>
|
||||
</table>
|
||||
));
|
||||
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', () => {
|
||||
|
||||
@@ -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<string, GuestDrawerHistoryDeferredMetric> => {
|
||||
const metrics: Record<string, GuestDrawerHistoryDeferredMetric> = {};
|
||||
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<string, numbe
|
||||
// A paused filesystem read is not current, even if the snapshot still
|
||||
// carries a numeric summary. Retained evidence has its own labelled path.
|
||||
const diskUsage =
|
||||
isGuestDrawerVM(guest) && guest.diskStatusReason ? undefined : getGuestDrawerDiskUsage(guest);
|
||||
getWorkloadGuestDiskRead(guest, isGuestDrawerVM(guest)).state === 'current'
|
||||
? getGuestDrawerDiskUsage(guest)
|
||||
: undefined;
|
||||
const finite = (value: number | undefined): number | undefined =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
return {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,8 +124,9 @@ const mergeRecord = <T extends JsonRecord>(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 = <T extends JsonRecord>(incoming?: T, existing?: T): T | undefined => {
|
||||
const merged = mergeRecord(incoming, existing);
|
||||
@@ -142,12 +143,30 @@ const mergeProxmoxFacet = <T extends JsonRecord>(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 = <T extends JsonRecord>(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 = <T extends JsonRecord>(
|
||||
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
|
||||
|
||||
@@ -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-/, '')),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user