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 87517e7399..c7ea65f894 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 3815c3ddf8..862a3f245a 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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, 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/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.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..fa946df7c7 100644 --- a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts @@ -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', diff --git a/frontend-modern/src/utils/resourceStateAdapters.ts b/frontend-modern/src/utils/resourceStateAdapters.ts index fef157dbc0..72efec2fb5 100644 --- a/frontend-modern/src/utils/resourceStateAdapters.ts +++ b/frontend-modern/src/utils/resourceStateAdapters.ts @@ -1373,6 +1373,70 @@ 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, @@ -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 => 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, - }; - } - } + // 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),