mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 12:42:49 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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 } }),
|
||||
|
||||
@@ -1373,77 +1373,90 @@ 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>,
|
||||
): 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<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,
|
||||
};
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user