Show real CPU low and record in the Proxmox node drawer (#2594)

The Proxmox node drawer's **CPU low** and **CPU record** rows repeated
the current temperature for every node without a host agent.

`nodeFromResource` never read `proxmox.temperatureDetails`, which is
where the resource API publishes the poller's full reading, including
its low and record values. For those nodes it fell back to the scalar
`proxmox.temperature` and copied that one value into both fields. On the
dev stack, `delly` and `minipc` showed CPU low = CPU record = current.

- The adapter now takes the first source with a usable reading.
`temperatureDetails` comes after the platform record and the agent
facet, so agent-linked nodes render exactly as before.
- The bare-reading fallback leaves low and record unset, so the drawer
omits those rows rather than inventing them.

The poller also reset those values to the current reading every poll.
That is a separate backend fix to the previous-node projection, landing
in its own PR. Until it lands, PVE-only nodes will still show low =
record = current, now because that is what the backend reports.

### Verification
- Unit tests pin the API's real payload shape (scalar
`proxmox.temperature` plus `proxmox.temperatureDetails`), the no-record
fallback, and agent-facet precedence. The new test fails against main's
adapter, which returns `cpuMin` 58 = current.
- Browser pass on a private mock stack at `/proxmox`, 1440x900 and
390x844, with REST and websocket payloads rewritten to make one node
PVE-only:
  - main's adapter showed CPU low 82°C / CPU record 82°C;
- this change shows no rows without a record, and 39°C / 91°C with one;
  - agent-linked nodes and the table TEMP column are unchanged.
- `vitest` over `src/utils`, `src/components/Workloads`,
`src/features/proxmox` and `src/features/platformPage` passes, apart
from `agentInstallCommand.test.ts`, which fails on macOS regardless of
this change.
This commit is contained in:
rcourtman
2026-10-06 23:21:38 +01:00
committed by GitHub
7 changed files with 254 additions and 57 deletions
@@ -166,6 +166,13 @@ clearing, false/zero values, unchanged peer-site rows and full/fast equivalence.
merges, row, Overview and History with synthetic inputs. This is no native
QGA/thaw, installed recovery, fleet-performance or release-availability claim.
Choosing a node's temperature source in `resourceStateAdapters.ts` reads one
more field of the facet already in the canonical payload
(`proxmox.temperatureDetails`), only when the existing record selection yields
no usable reading, and keeps it only when it holds a CPU reading. That is one
extra record mapping per such node per adapter call. It adds no resource read,
merge pass, timer or subscription.
### Unavailable filesystem values share the existing disk model
The filesystem list and guest Overview use the same nullable measured-byte ratio
@@ -3153,6 +3153,19 @@ footer after the visible rows. The footer may dim the final row edge and expose
the remaining-row count, but expanding or collapsing it only changes the
client-side visible slice; it must not refetch, reorder, or redefine the
canonical resource collection.
The shared `nodeFromResource` adapter selects a node's temperature record as
before: the platform temperature record, else the Proxmox temperature record,
else the agent facet. When that selection yields no usable reading, it uses
`proxmox.temperatureDetails` if that holds a current CPU reading, ahead of the
bare scalar fallback. That field is
the Proxmox poller's full reading for a node without a host agent, including
the CPU low and record values the poller supplies; `proxmox.temperature` is
only the scalar maximum. The adapter forwards those values; keeping them across
polls is the poller's job (`monitor_previous_state.go` and the temperature carry
in monitoring). A bare scalar reading carries no history, so the adapter leaves
the CPU low and record unset rather than repeating the current value, and the
node drawer omits those rows. `resourceStateAdapters.test.ts` pins these cases
with the API's payload shape.
The registry and presentation coalescer also own metric-source freshness. When
two source facets contribute the same metric, source priority decides only if
both sources have equivalent freshness. A stale source must not hold CPU,
@@ -0,0 +1,36 @@
{
"version": 1,
"base_sha": "3b290640a51ef0f488f9cd06b4aa644adbdcc6b3",
"verified_at": "2026-10-06T15:25:00Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/utils/resourceStateAdapters.ts"
],
"content_sha256": {
"frontend-modern/src/utils/resourceStateAdapters.ts": "5b170b1c0d735afc584b0784403cd3510a8ab12aeb3e387e9baec6b80fcb5853"
},
"routes": [
"/proxmox"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Private mock stack built from this tree (backend :7681, Vite :5199); /api/resources confirmed the real node payload shape: proxmox.temperature is the scalar maximum and proxmox.temperatureDetails the full reading",
"Proxmox node Analytics A (pve31) as a PVE-only node (agent facet removed from REST and websocket payloads), main's adapter swapped in: drawer Thermals showed CPU low 82\u00b0C and CPU record 82\u00b0C, the current reading repeated as history",
"Same PVE-only node with this change and no low/record in temperatureDetails: CPU low and CPU record rows absent, Temperature and NVMe still shown",
"Same PVE-only node with this change and temperatureDetails carrying cpuMin 38.5 and cpuMaxRecord 91.2: drawer showed CPU low 39\u00b0C and CPU record 91\u00b0C at 1440x900 and 390x844",
"Agent-linked mock node (agent facet present) with this change, with and without an injected record: drawer identical to main (Temperature from the agent facet, no CPU low/record rows), table TEMP column unchanged at 82\u00b0C"
],
"interactions": [
"Opened the node drawer from the row's detail toggle (aria-controls proxmox-host-drawer-<id>) on /proxmox at desktop and phone widths",
"Read the Thermals card rows and the row's TEMP cell, and took screenshots, for main's adapter and for this change, via Playwright with REST and websocket payload rewriting"
]
}
@@ -0,0 +1,37 @@
{
"version": 1,
"base_sha": "60fb6e842b702c35c93ce7c5e8397487c15aab6a",
"verified_at": "2026-10-06T16:05:00Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/utils/resourceStateAdapters.ts"
],
"content_sha256": {
"frontend-modern/src/utils/resourceStateAdapters.ts": "744fb071acc493f973c6064e7c7eaaac831c9cef2a88491a8b51e49a08f139be"
},
"routes": [
"/proxmox"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Private mock stack built from this tree (backend :7681, Vite :5199) after restoring the original record selection and gating temperatureDetails on a current CPU reading",
"Proxmox node Analytics A (pve31) made PVE-only (agent facet removed from REST and websocket payloads), compared on identical data: main's adapter showed CPU low 63\u00b0C and CPU record 63\u00b0C (the current reading repeated); this change showed CPU low 39\u00b0C and CPU record 91\u00b0C from an injected temperatureDetails record (cpuMin 38.5, cpuMaxRecord 91.2) at 1440x900 and 390x844",
"Same PVE-only node without a record in temperatureDetails: CPU low and CPU record rows absent; NVMe row and table TEMP unchanged",
"Operator-context Temperature row: hidden on main and on this change at 63\u00b0C (it appears only in the warning band), so no difference",
"Agent-linked mock node with and without an injected record, desktop and phone width: no CPU low/record rows and Temperature from the agent facet, as on main",
"Unit tests cover the two cases this commit fixes: details with only NVMe do not displace a valid scalar CPU reading, and an unusable legacy record still falls back to the scalar"
],
"interactions": [
"Opened the node drawer from the row's detail toggle (aria-controls proxmox-host-drawer-<id>) on /proxmox at desktop and phone widths",
"Read the Thermals and Operator context rows and the row's TEMP cell, and took screenshots, for main's adapter and for this change via Playwright with REST and websocket payload rewriting"
]
}
@@ -981,7 +981,9 @@ describe('buildTemperature (via nodeFromResource)', () => {
);
expect(node?.temperature?.cpuPackage).toBe(42);
expect(node?.temperature?.cpuMax).toBe(42);
expect(node?.temperature?.cpuMin).toBe(42);
// A bare reading has no low or record history to report.
expect(node?.temperature?.cpuMin).toBeUndefined();
expect(node?.temperature?.cpuMaxRecord).toBeUndefined();
expect(node?.temperature?.available).toBe(true);
expect(node?.temperature?.hasCPU).toBe(true);
});
@@ -254,6 +254,89 @@ describe('resourceStateAdapters nodeFromResource', () => {
]);
});
it("reads a Proxmox node's CPU low and record from the poller's temperature details", () => {
// The unified resource API publishes the scalar maximum as
// proxmox.temperature and the full reading as proxmox.temperatureDetails.
const proxmox = {
nodeName: 'pve-node-2',
temperature: 58,
temperatureDetails: {
available: true,
hasCPU: true,
cpuPackage: 52,
cpuMax: 58,
cpuMin: 41,
cpuMaxRecord: 76,
minRecorded: '2026-10-06T08:00:00Z',
maxRecorded: '2026-10-06T12:00:00Z',
cores: [{ core: 0, temp: 58 }],
lastUpdate: '2026-10-06T15:00:00Z',
},
};
const node = nodeFromResource({
...createNodeResource({ proxmox }),
temperature: 58,
proxmox,
} as unknown as Resource);
expect(node?.temperature).toMatchObject({
available: true,
cpuPackage: 52,
cpuMax: 58,
cpuMin: 41,
cpuMaxRecord: 76,
lastUpdate: '2026-10-06T15:00:00Z',
});
expect(node?.temperature?.cores).toEqual([{ core: 0, temp: 58 }]);
// Without details there is no history: the current reading must not be
// reported as the node's low or record.
const bare = nodeFromResource({
...createNodeResource({ proxmox: { nodeName: 'pve-node-2', temperature: 58 } }),
temperature: 58,
} as unknown as Resource);
expect(bare?.temperature?.cpuPackage).toBe(58);
expect(bare?.temperature?.cpuMin).toBeUndefined();
expect(bare?.temperature?.cpuMaxRecord).toBeUndefined();
// Details without a CPU reading do not displace a valid scalar CPU reading.
const sensorsOnly = nodeFromResource({
...createNodeResource({
proxmox: {
nodeName: 'pve-node-2',
temperatureDetails: {
available: true,
hasCPU: false,
nvme: [{ device: 'nvme0', temp: 44 }],
},
},
}),
temperature: 58,
} as unknown as Resource);
expect(sensorsOnly?.temperature?.cpuPackage).toBe(58);
expect(sensorsOnly?.temperature?.available).toBe(true);
// An unusable earlier record still falls back to the scalar reading.
const legacyUnavailable = nodeFromResource({
...createNodeResource({
temperature: { available: false },
proxmox: { nodeName: 'pve-node-2', temperature: { available: false, cpuPackage: 58 } },
}),
temperature: 58,
} as unknown as Resource);
expect(legacyUnavailable?.temperature?.cpuPackage).toBe(58);
expect(legacyUnavailable?.temperature?.available).toBe(true);
// A node with a host agent keeps reading the agent facet first.
const agentLinked = nodeFromResource({
...createNodeResource({ proxmox, agent: { agentId: 'pve-agent', temperature: 61 } }),
temperature: 61,
proxmox,
} as unknown as Resource);
expect(agentLinked?.temperature?.cpuPackage).toBe(61);
expect(agentLinked?.temperature?.cpuMin).toBeUndefined();
});
it('preserves linked-agent GPU telemetry for the Proxmox node drawer', () => {
const gpu = {
id: '0',
@@ -1373,6 +1373,70 @@ const buildDisk = (metric: Resource['disk'], fallback?: Record<string, unknown>)
};
};
const temperatureFromRecord = (
raw: Record<string, unknown>,
resource: Resource,
): Temperature | undefined => {
const available = asBoolean(raw.available);
const cpuPackage = asNumber(raw.cpuPackage) ?? asNumber(raw.temperature) ?? asNumber(raw.cpu);
const lastUpdate = toISOTime(raw.lastUpdate, resource.lastSeen);
if (available || typeof cpuPackage === 'number') {
return {
cpuPackage,
cpuMax: asNumber(raw.cpuMax),
cpuMin: asNumber(raw.cpuMin),
cpuMaxRecord: asNumber(raw.cpuMaxRecord),
minRecorded: asString(raw.minRecorded),
maxRecorded: asString(raw.maxRecorded),
cores: asArray(raw.cores)
.map((entry) => {
const rec = asRecord(entry);
if (!rec) return null;
const core = asNumber(rec.core);
const temp = asNumber(rec.temp);
if (typeof core !== 'number' || typeof temp !== 'number') return null;
return { core, temp };
})
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry)),
gpu: asArray(raw.gpu)
.map((entry) => {
const rec = asRecord(entry);
if (!rec) return null;
const device = asString(rec.device);
if (!device) return null;
return {
device,
edge: asNumber(rec.edge),
junction: asNumber(rec.junction),
mem: asNumber(rec.mem),
};
})
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry)),
nvme: asArray(raw.nvme)
.map((entry) => {
const rec = asRecord(entry);
if (!rec) return null;
const device = asString(rec.device);
const temp = asNumber(rec.temp);
if (!device || typeof temp !== 'number') return null;
return { device, temp };
})
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry)),
available: available ?? true,
hasCPU: asBoolean(raw.hasCPU) ?? (typeof cpuPackage === 'number' ? true : undefined),
hasGPU: asBoolean(raw.hasGPU),
hasNVMe: asBoolean(raw.hasNVMe),
lastUpdate,
};
}
return undefined;
};
const hasCPUTemperatureReading = (temperature: Temperature): boolean =>
(temperature.cpuPackage ?? 0) > 0 ||
(temperature.cpuMax ?? 0) > 0 ||
(temperature.cores ?? []).some((core) => core.temp > 0);
const buildTemperature = (
resource: Resource,
nodeMeta?: Record<string, unknown>,
@@ -1383,69 +1447,24 @@ const buildTemperature = (
asRecord(nodeMeta?.temperature) ||
asRecord(platform?.agent) ||
undefined;
const fromRecord = raw ? temperatureFromRecord(raw, resource) : undefined;
if (fromRecord) return fromRecord;
if (raw) {
const available = asBoolean(raw.available);
const cpuPackage = asNumber(raw.cpuPackage) ?? asNumber(raw.temperature) ?? asNumber(raw.cpu);
const lastUpdate = toISOTime(raw.lastUpdate, resource.lastSeen);
if (available || typeof cpuPackage === 'number') {
return {
cpuPackage,
cpuMax: asNumber(raw.cpuMax),
cpuMin: asNumber(raw.cpuMin),
cpuMaxRecord: asNumber(raw.cpuMaxRecord),
minRecorded: asString(raw.minRecorded),
maxRecorded: asString(raw.maxRecorded),
cores: asArray(raw.cores)
.map((entry) => {
const rec = asRecord(entry);
if (!rec) return null;
const core = asNumber(rec.core);
const temp = asNumber(rec.temp);
if (typeof core !== 'number' || typeof temp !== 'number') return null;
return { core, temp };
})
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry)),
gpu: asArray(raw.gpu)
.map((entry) => {
const rec = asRecord(entry);
if (!rec) return null;
const device = asString(rec.device);
if (!device) return null;
return {
device,
edge: asNumber(rec.edge),
junction: asNumber(rec.junction),
mem: asNumber(rec.mem),
};
})
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry)),
nvme: asArray(raw.nvme)
.map((entry) => {
const rec = asRecord(entry);
if (!rec) return null;
const device = asString(rec.device);
const temp = asNumber(rec.temp);
if (!device || typeof temp !== 'number') return null;
return { device, temp };
})
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry)),
available: available ?? true,
hasCPU: asBoolean(raw.hasCPU) ?? (typeof cpuPackage === 'number' ? true : undefined),
hasGPU: asBoolean(raw.hasGPU),
hasNVMe: asBoolean(raw.hasNVMe),
lastUpdate,
};
}
}
// A Proxmox node without a host agent publishes its full reading, including
// the CPU low and record values the poller tracks, only as
// proxmox.temperatureDetails (proxmox.temperature is the scalar maximum). Use
// it ahead of the bare-reading fallback when it holds a current CPU reading.
const details = asRecord(nodeMeta?.temperatureDetails);
const fromDetails = details ? temperatureFromRecord(details, resource) : undefined;
if (fromDetails?.available && hasCPUTemperatureReading(fromDetails)) return fromDetails;
if (typeof resource.temperature === 'number' && Number.isFinite(resource.temperature)) {
const temp = resource.temperature;
// A bare reading carries no low or record history. Leave them unset so the
// drawer omits those rows instead of repeating the current value as a record.
return {
cpuPackage: temp,
cpuMax: temp,
cpuMin: temp,
cpuMaxRecord: temp,
available: true,
hasCPU: true,
lastUpdate: toISOTime(undefined, resource.lastSeen),