From 60fb6e842b702c35c93ce7c5e8397487c15aab6a Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:37:40 +0100 Subject: [PATCH 1/2] Show real CPU low and record in the Proxmox node drawer The node drawer's CPU low and CPU record rows repeated the current reading for every Proxmox node without a host agent. The adapter never read proxmox.temperatureDetails, where the API publishes the poller's full reading including the low and record values, and its fallback to the scalar proxmox.temperature copied that one value into both fields. On the dev stack, delly and minipc showed CPU low = CPU record = the current temperature. nodeFromResource 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 the low and record unset, so the drawer omits those rows instead of inventing them. The poller also reset those values to the current reading every poll; that is fixed separately in the previous-node projection. With both, the rows show the low and record the poller tracks. --- .../subsystems/performance-and-scalability.md | 5 ++ .../internal/subsystems/unified-resources.md | 9 +++ .../541999f4ef0c4619.json | 36 ++++++++++++ .../resourceStateAdapters.coverage2.test.ts | 4 +- .../__tests__/resourceStateAdapters.test.ts | 55 +++++++++++++++++++ .../src/utils/resourceStateAdapters.ts | 24 +++++--- 6 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 frontend-modern/browser-verification/541999f4ef0c4619.json diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 50344121e9..9a0ad0a645 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -159,6 +159,11 @@ 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`) when earlier sources have no usable reading. 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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 9a954da020..40bf3e9036 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -3182,6 +3182,15 @@ 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 takes a node's temperature from the first +source with a usable reading: a platform or Proxmox temperature record, the +agent facet, then `proxmox.temperatureDetails`. That last field is the Proxmox +poller's full reading for a node without a host agent, including the CPU low +and record values it tracks; `proxmox.temperature` is only the scalar maximum. +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 both 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, diff --git a/frontend-modern/browser-verification/541999f4ef0c4619.json b/frontend-modern/browser-verification/541999f4ef0c4619.json new file mode 100644 index 0000000000..c648f4ae89 --- /dev/null +++ b/frontend-modern/browser-verification/541999f4ef0c4619.json @@ -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-) 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" + ] +} diff --git a/frontend-modern/src/utils/__tests__/resourceStateAdapters.coverage2.test.ts b/frontend-modern/src/utils/__tests__/resourceStateAdapters.coverage2.test.ts index 679c95f6a3..d78b7157dc 100644 --- a/frontend-modern/src/utils/__tests__/resourceStateAdapters.coverage2.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceStateAdapters.coverage2.test.ts @@ -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); }); diff --git a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts index bea2d130c6..dbb6aef82c 100644 --- a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts @@ -254,6 +254,61 @@ 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(); + + // 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', diff --git a/frontend-modern/src/utils/resourceStateAdapters.ts b/frontend-modern/src/utils/resourceStateAdapters.ts index fef157dbc0..f78e577757 100644 --- a/frontend-modern/src/utils/resourceStateAdapters.ts +++ b/frontend-modern/src/utils/resourceStateAdapters.ts @@ -1378,13 +1378,19 @@ const buildTemperature = ( nodeMeta?: Record, ): Temperature | undefined => { const platform = resourcePlatformData(resource); - const raw = - asRecord(platform?.temperature) || - asRecord(nodeMeta?.temperature) || - asRecord(platform?.agent) || - undefined; - - if (raw) { + // Take the first source with a usable reading. 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), so it comes before the bare-reading fallback. + const candidates = [ + platform?.temperature, + nodeMeta?.temperature, + platform?.agent, + nodeMeta?.temperatureDetails, + ]; + for (const candidate of candidates) { + const raw = asRecord(candidate); + if (!raw) continue; const available = asBoolean(raw.available); const cpuPackage = asNumber(raw.cpuPackage) ?? asNumber(raw.temperature) ?? asNumber(raw.cpu); const lastUpdate = toISOTime(raw.lastUpdate, resource.lastSeen); @@ -1441,11 +1447,11 @@ const buildTemperature = ( 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), From 267ffcc3afed2c062c40006a45feff860d226334 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:23:55 +0100 Subject: [PATCH 2/2] Keep node temperature selection when details lack a CPU reading The first version of this change walked every temperature source and took the first usable one, which changed two cases that worked before. Details holding only NVMe or GPU readings displaced a valid scalar CPU reading, so the node lost its CPU temperature. An unusable earlier record no longer fell back to the scalar; the walk went on to a later record that was marked unavailable and suppressed the display. The record selection is back to exactly what it was. Only when it yields nothing usable does the adapter use proxmox.temperatureDetails, and only if that holds a current CPU reading; otherwise it falls back to the scalar as before, still without inventing a low or record. The contract notes now say the adapter forwards the poller's low and record values and that keeping them across polls is the poller's job. --- .../subsystems/performance-and-scalability.md | 6 +- .../internal/subsystems/unified-resources.md | 22 +-- .../ae27640ea2ed301f.json | 37 +++++ .../__tests__/resourceStateAdapters.test.ts | 28 ++++ .../src/utils/resourceStateAdapters.ts | 145 ++++++++++-------- 5 files changed, 161 insertions(+), 77 deletions(-) create mode 100644 frontend-modern/browser-verification/ae27640ea2ed301f.json diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 9a0ad0a645..3b4a3751d0 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -161,8 +161,10 @@ 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`) when earlier sources have no usable reading. It -adds no resource read, merge pass, timer or subscription. +(`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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 40bf3e9036..aa5a022d7e 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -3182,15 +3182,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 takes a node's temperature from the first -source with a usable reading: a platform or Proxmox temperature record, the -agent facet, then `proxmox.temperatureDetails`. That last field is the Proxmox -poller's full reading for a node without a host agent, including the CPU low -and record values it tracks; `proxmox.temperature` is only the scalar maximum. -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 both with the API's payload -shape. +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, diff --git a/frontend-modern/browser-verification/ae27640ea2ed301f.json b/frontend-modern/browser-verification/ae27640ea2ed301f.json new file mode 100644 index 0000000000..8c18857784 --- /dev/null +++ b/frontend-modern/browser-verification/ae27640ea2ed301f.json @@ -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-) 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" + ] +} diff --git a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts index dbb6aef82c..fa946df7c7 100644 --- a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts @@ -299,6 +299,34 @@ describe('resourceStateAdapters nodeFromResource', () => { 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 } }), diff --git a/frontend-modern/src/utils/resourceStateAdapters.ts b/frontend-modern/src/utils/resourceStateAdapters.ts index f78e577757..72efec2fb5 100644 --- a/frontend-modern/src/utils/resourceStateAdapters.ts +++ b/frontend-modern/src/utils/resourceStateAdapters.ts @@ -1373,77 +1373,90 @@ const buildDisk = (metric: Resource['disk'], fallback?: Record) }; }; +const temperatureFromRecord = ( + raw: Record, + 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 => 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 => 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 => 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, ): Temperature | undefined => { const platform = resourcePlatformData(resource); - // Take the first source with a usable reading. 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), so it comes before the bare-reading fallback. - const candidates = [ - platform?.temperature, - nodeMeta?.temperature, - platform?.agent, - nodeMeta?.temperatureDetails, - ]; - for (const candidate of candidates) { - const raw = asRecord(candidate); - if (!raw) continue; - 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 => 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 => 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 => Boolean(entry)), - available: available ?? true, - hasCPU: asBoolean(raw.hasCPU) ?? (typeof cpuPackage === 'number' ? true : undefined), - hasGPU: asBoolean(raw.hasGPU), - hasNVMe: asBoolean(raw.hasNVMe), - lastUpdate, - }; - } - } + const raw = + asRecord(platform?.temperature) || + asRecord(nodeMeta?.temperature) || + asRecord(platform?.agent) || + undefined; + const fromRecord = raw ? temperatureFromRecord(raw, resource) : undefined; + if (fromRecord) return fromRecord; + + // 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;