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.