From b17bef86e75ea17821b6961e687bfe4f9651581e Mon Sep 17 00:00:00 2001 From: Paolo Stagno Date: Fri, 31 Jul 2026 13:45:20 +0400 Subject: [PATCH] feat: CPU temperature for remote Sparks and in the CPU panel Remote Sparks always reported 0: _getRemoteCpu() hardcoded the value and never queried a sensor. The CPU panel had no temperature row at all, so the field went unused for local Sparks too. Collect it within the existing SSH round trip by appending sensor candidates to the same command, in the priority order the local path already uses (hwmon coretemp/k10temp/zenpower/acpitz, then thermal zones). _parseSensorTemp takes the first plausible reading using the same accept range as _getCPUTemperature, and returns 0 when nothing is readable. Display it in CpuPanel between Usage and Power, mirroring GpuPanel: honors the Celsius/Fahrenheit setting and draws a sparkline from a new cpu.temp history series. Thresholds follow DGX_SPARK.THERMAL_THRESHOLDS.junction (warn 85, crit 95) instead of the GPU's 65/85, which would sit amber permanently at a GB10's normal ~70C load. The row is hidden when the reading is 0, so a host with no readable sensor shows no bogus 0C. Verified on two GB10 nodes: local and remote paths both select hwmon0/acpitz/temp1_input (identical to thermal_zone0), agreeing within 0.2C of a direct sysfs read. --- server/collectors/SystemCollector.js | 33 +++++++++++- .../__tests__/SystemCollector.cpuTemp.test.js | 50 +++++++++++++++++++ src/components/SparkPage/CpuPanel.tsx | 30 ++++++++++- src/components/SparkPage/SparkPage.tsx | 8 ++- src/hooks/metricsStore.ts | 2 + 5 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 server/collectors/__tests__/SystemCollector.cpuTemp.test.js diff --git a/server/collectors/SystemCollector.js b/server/collectors/SystemCollector.js index b884fe3..d502755 100644 --- a/server/collectors/SystemCollector.js +++ b/server/collectors/SystemCollector.js @@ -892,12 +892,19 @@ export class SystemCollector { "cat /proc/stat | head -1", "echo '---'", "cat /proc/cpuinfo | grep -E 'CPU architecture|aarch64' | head -1", + "echo '---'", + // Temperature candidates in the same priority order the local path uses + // (hwmon first, then thermal zones) — emitted as raw millidegrees, one + // per line, so the parser can take the first plausible reading. + 'for h in /sys/class/hwmon/*; do n=$(cat "$h/name" 2>/dev/null); case "$n" in coretemp|k10temp|zenpower|acpitz) for t in "$h"/temp*_input; do cat "$t" 2>/dev/null; break; done;; esac; done', + "cat /sys/class/thermal/thermal_zone*/temp 2>/dev/null", ].join("; "); const output = await sshExec(this.spark, cmd); const sections = output.split("---"); const statOut = sections[0]?.trim() || ""; const cpuinfoOut = sections[1]?.trim() || ""; + const tempOut = sections[2] || ""; const cpuStat = this._parseCPUUsage(statOut); const totalDiff = cpuStat.total - (this.lastCpuStat?.total || cpuStat.total); @@ -911,13 +918,37 @@ export class SystemCollector { const idleWatts = tdp * 0.08; const draw = idleWatts + (tdp - idleWatts) * Math.min(usage / 100, 1); - return { usage, temperature: 0, draw: Math.round(draw * 10) / 10, tdp: Math.round(tdp) }; + return { + usage, + temperature: this._parseSensorTemp(tempOut), + draw: Math.round(draw * 10) / 10, + tdp: Math.round(tdp), + }; } catch (err) { console.error(`[SystemCollector] Remote CPU error for ${this.spark.id}:`, err.message); return this._defaultCpu(); } } + /** + * First plausible temperature from a remote sensor dump (raw millidegrees, + * one per line, highest priority first). Uses the same accept range as the + * local `_getCPUTemperature()`; returns 0 when no sensor reports a usable + * value, matching `_defaultCpu()`. + * + * @param {string} raw + * @returns {number} degrees Celsius, or 0 + */ + _parseSensorTemp(raw) { + for (const line of String(raw).split("\n")) { + const millidegrees = parseInt(line.trim(), 10); + if (Number.isFinite(millidegrees) && millidegrees > 0 && millidegrees < 200000) { + return Math.round((millidegrees / 1000) * 10) / 10; + } + } + return 0; + } + async _getRemoteRam() { try { const cmd = "grep -E 'MemTotal|MemAvailable' /proc/meminfo 2>/dev/null"; diff --git a/server/collectors/__tests__/SystemCollector.cpuTemp.test.js b/server/collectors/__tests__/SystemCollector.cpuTemp.test.js new file mode 100644 index 0000000..0e49ea9 --- /dev/null +++ b/server/collectors/__tests__/SystemCollector.cpuTemp.test.js @@ -0,0 +1,50 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { SystemCollector } from "../SystemCollector.js"; + +// The constructor resolves nvidia-smi and seeds rate baselines; _parseSensorTemp is +// pure, so bind it to a bare prototype instead (same approach as role-normalize). +const c = Object.create(SystemCollector.prototype); +const parse = (raw) => c._parseSensorTemp(raw); + +test("converts millidegrees to Celsius", () => { + assert.equal(parse("70900"), 70.9); + assert.equal(parse("69200"), 69.2); +}); + +test("takes the first plausible reading, not the highest", () => { + // Remote command emits hwmon candidates before thermal zones; the first one wins + // so remote hosts pick the same sensor the local sysfs path does. + assert.equal(parse("70900\n80000\n62200"), 70.9); +}); + +test("skips the blank line left by the section split", () => { + // sshExec output is split on '---', so the temperature section starts with \n. + assert.equal(parse("\n69200\n66200\n"), 69.2); +}); + +test("skips unreadable sensors", () => { + // e.g. mt7925_phy0 exposes temp1_input but reads empty. + assert.equal(parse("\n\n64500"), 64.5); + assert.equal(parse("not-a-number\n64500"), 64.5); +}); + +test("rejects out-of-range values", () => { + assert.equal(parse("0"), 0); + assert.equal(parse("-5000"), 0); + assert.equal(parse("200000"), 0); + assert.equal(parse("250000"), 0); + // Out-of-range entries are skipped rather than aborting the scan. + assert.equal(parse("0\n250000\n70900"), 70.9); +}); + +test("returns 0 when nothing is reported", () => { + assert.equal(parse(""), 0); + assert.equal(parse("\n\n"), 0); + assert.equal(parse(undefined), 0); +}); + +test("rounds to one decimal", () => { + assert.equal(parse("69250"), 69.3); + assert.equal(parse("69240"), 69.2); +}); diff --git a/src/components/SparkPage/CpuPanel.tsx b/src/components/SparkPage/CpuPanel.tsx index 9eb2922..d083cda 100644 --- a/src/components/SparkPage/CpuPanel.tsx +++ b/src/components/SparkPage/CpuPanel.tsx @@ -10,6 +10,11 @@ interface CpuPanelProps { ram: RamMetrics | null; sparkId: string; unifiedMemory: UnifiedMemoryMetrics | null; + temperatureUnit: "celsius" | "fahrenheit"; +} + +function celsiusToFahrenheit(c: number): number { + return Math.round((c * 9) / 5 + 32); } function formatMb(mb: number): string { @@ -39,13 +44,28 @@ function MetricRow({ ); } -export function CpuPanel({ cpu, ram, sparkId, unifiedMemory }: CpuPanelProps) { +export function CpuPanel({ cpu, ram, sparkId, unifiedMemory, temperatureUnit }: CpuPanelProps) { const usageHistory = useMetricsHistoryTail(sparkId, "cpu.usage"); + const tempHistory = useMetricsHistoryTail(sparkId, "cpu.temp"); const usage = cpu?.usage ?? 0; const draw = cpu?.draw ?? 0; const tdp = cpu?.tdp ?? 0; + // 0 means no sensor was readable on this host (see SystemCollector._parseSensorTemp). + const temperature = cpu?.temperature ?? 0; + const displayTemp = + temperatureUnit === "fahrenheit" ? celsiusToFahrenheit(temperature) : temperature; + const tempLabel = temperatureUnit === "fahrenheit" ? `${displayTemp}°F` : `${displayTemp}°C`; + // GB10 CPU thresholds follow DGX_SPARK.THERMAL_THRESHOLDS.junction (warn 85, crit 95). + // The GPU panel's 65°C warning would sit amber permanently here under normal load. + const tempColor = + temperature > 95 + ? "var(--color-danger)" + : temperature > 85 + ? "var(--color-warning)" + : "var(--color-accent)"; + const ramUsed = ram?.used ?? 0; const ramTotal = ram?.total ?? 0; const ramPct = ram?.percentage ?? 0; @@ -59,6 +79,14 @@ export function CpuPanel({ cpu, ram, sparkId, unifiedMemory }: CpuPanelProps) { spark={} value={{usage}%} /> + {temperature > 0 && ( + } + value={{tempLabel}} + /> + )}
Power diff --git a/src/components/SparkPage/SparkPage.tsx b/src/components/SparkPage/SparkPage.tsx index 720fbc3..8638876 100644 --- a/src/components/SparkPage/SparkPage.tsx +++ b/src/components/SparkPage/SparkPage.tsx @@ -96,7 +96,13 @@ export function SparkPage({ spark, temperatureUnit, onEdit }: SparkPageProps) {
- + 0) pushHistory(`${s.id}:cpu.temp`, m.cpu.temperature); } if (Array.isArray(m.llm)) { // Zip with snapshot.llmPorts so multi-port LLM series key distinctly.