|
async _getRemoteGpu() { |
|
try { |
|
const cmd = [ |
|
"nvidia-smi --query-gpu=temperature.gpu,utilization.gpu,power.draw,power.limit,clocks.current.sm,clocks.max.sm,clocks_throttle_reasons.hw_thermal_slowdown,clocks_throttle_reasons.sw_thermal_slowdown,clocks_throttle_reasons.hw_slowdown,clocks_throttle_reasons.sw_power_cap --format=csv,noheader,nounits 2>/dev/null", |
|
"echo '---'", |
|
"nvidia-smi --query-gpu=memory.used,memory.total --format=csv,noheader,nounits 2>/dev/null", |
|
"echo '---'", |
|
"nvidia-smi --query-compute-apps=pid,process_name,used_gpu_memory --format=csv,noheader,nounits 2>/dev/null", |
|
"echo '---'", |
|
"grep -E 'MemTotal|MemAvailable' /proc/meminfo 2>/dev/null", |
|
].join("; "); |
|
|
|
const output = await sshExec(this.spark, cmd); |
|
const sections = output.split("---"); |
|
const gpuOut = sections[0]?.trim() || ""; |
|
const memFields = sections[1]?.trim() || ""; |
|
const computeOut = sections[2]?.trim() || ""; |
|
const meminfoOut = sections[3]?.trim() || ""; |
|
|
|
const gpu = this._parseGpuLine(gpuOut); |
|
|
|
// Parse memory.used / memory.total from nvidia-smi (may be [N/A] on GB10) |
|
let used = null; |
|
let total = null; |
|
const memLine = memFields.split("\n").filter(Boolean)[0] || ""; |
|
const memParts = memLine.split(",").map((s) => s.trim()); |
|
used = this._parseSmiNumber(memParts[0]); |
|
total = this._parseSmiNumber(memParts[1]); |
|
|
|
const apps = this._parseComputeApps(computeOut); |
|
this.nvidiaComputeAppsCache.clear(); |
|
let computeSum = 0; |
|
for (const app of apps) { |
|
this.nvidiaComputeAppsCache.set(app.pid, { name: app.name, vramMB: app.vramMB }); |
|
computeSum += app.vramMB; |
|
} |
|
if ((used == null || used === 0) && computeSum > 0) used = computeSum; |
|
|
|
// Unified-memory pool: prefer MemTotal (OS-visible) so VRAM and Unified |
|
// Memory panels share the same base. Available = MemAvailable (real free). |
|
const totalMatch = meminfoOut.match(/MemTotal:\s+(\d+)\s+kB/); |
|
const availMatch = meminfoOut.match(/MemAvailable:\s+(\d+)\s+kB/); |
|
const memTotalMB = totalMatch ? Math.round(parseInt(totalMatch[1]) / 1024) : 0; |
|
let availableMB = availMatch ? Math.round(parseInt(availMatch[1]) / 1024) : 0; |
|
|
|
const usedMB = Math.round(used || 0); |
|
let totalMB = Math.round(total || 0); |
|
if (this.spark.kind === "host") { |
|
// Discrete GPU VRAM: trust nvidia-smi's memory.total; free VRAM = total − used. |
|
if (totalMB <= 0 && memTotalMB > 0) totalMB = memTotalMB; |
|
else if (totalMB <= 0) totalMB = DGX_SPARK.MEMORY_HBM_SIZE_GB * 1024; // Convert to MB |
|
if (totalMB > 0 && usedMB > 0) availableMB = Math.max(0, totalMB - usedMB); |
|
} else { |
|
// GB10 shared HBM pool: prefer the OS-visible pool (MemTotal) as the total, |
|
// fall back to nvidia-smi, then the hardware spec (HBM) only if nothing known. |
|
if (memTotalMB > 0) totalMB = memTotalMB; |
|
else if (totalMB <= 0) totalMB = DGX_SPARK.MEMORY_HBM_SIZE_GB * 1024; // Convert to MB |
|
} |
|
const percentage = totalMB > 0 ? Math.round((usedMB / totalMB) * 100) : 0; |
|
|
|
// Rough system power estimate: GPU draw + 20W CX7/peripherals |
|
const systemDraw = Math.round(gpu.powerDraw + 20); |
|
|
|
// Top 5 GPU processes by VRAM usage |
|
const processes = Array.from(this.nvidiaComputeAppsCache.entries()) |
|
.map(([pid, info]) => ({ pid, name: info.name, vramMB: info.vramMB })) |
|
.sort((a, b) => b.vramMB - a.vramMB) |
|
.slice(0, 5); |
|
|
|
return { |
|
temperature: gpu.temperature, |
|
usage: gpu.usage, |
|
power: { draw: gpu.powerDraw, limit: gpu.powerLimit, systemDraw }, |
|
vram: { used: usedMB, total: totalMB, percentage, available: availableMB }, |
|
processes, |
|
throttle: gpu.throttle, |
|
}; |
|
} catch (err) { |
|
console.error(`[SystemCollector] Remote GPU error for ${this.spark.id}:`, err.message); |
|
return this._defaultGpu(); |
|
} |
|
} |
|
|
|
/** |
|
* One SSH round trip: /proc/stat, CPU arch, then the same hwmon-then-thermal |
|
* sensor dump local `_getCPUTemperature()` uses. `|| true` on the thermal |
|
* glob keeps a missing zone from failing the whole CPU poll (sshExec treats |
|
* any non-zero exit as a hard error). |
|
*/ |
|
_buildRemoteCpuCommand() { |
|
return [ |
|
"cat /proc/stat | head -1", |
|
"echo '---'", |
|
"cat /proc/cpuinfo | grep -E 'CPU architecture|aarch64' | head -1", |
|
"echo '---'", |
|
// GB10 also exposes nvme/mlx5 sensors; the name allowlist keeps those out. |
|
'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 || true", |
|
].join("; "); |
|
} |
|
|
|
async _getRemoteCpu(sshExecutor = sshExec) { |
|
try { |
|
const cmd = this._buildRemoteCpuCommand(); |
|
|
|
const output = await sshExecutor(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); |
|
const usedDiff = cpuStat.used - (this.lastCpuStat?.used || cpuStat.used); |
|
const usage = totalDiff > 0 ? Math.round((usedDiff / totalDiff) * 100) : 0; |
|
this.lastCpuStat = cpuStat; |
|
|
|
// ARM/Neoverse power estimation |
|
const isArm = /CPU architecture:\s*[89]|aarch64|ARMv[89]|armv[89]/i.test(cpuinfoOut); |
|
const tdp = isArm ? 65 : 185; |
|
const idleWatts = tdp * 0.08; |
|
const draw = idleWatts + (tdp - idleWatts) * Math.min(usage / 100, 1); |
|
|
|
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). Same accept range as local |
|
* `_getCPUTemperature()`; returns 0 when nothing is readable. |
|
* |
|
* @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"; |
|
const output = await sshExec(this.spark, cmd); |
|
const totalMatch = output.match(/MemTotal:\s+(\d+)\s+kB/); |
|
const availMatch = output.match(/MemAvailable:\s+(\d+)\s+kB/); |
|
const totalKB = totalMatch ? parseInt(totalMatch[1]) : 0; |
|
const availKB = availMatch ? parseInt(availMatch[1]) : 0; |
|
const usedKB = totalKB - availKB; |
|
return { |
|
used: Math.round(usedKB / 1024), |
|
total: Math.round(totalKB / 1024), |
|
percentage: totalKB > 0 ? Math.round((usedKB / totalKB) * 100) : 0, |
|
}; |
|
} catch (err) { |
|
console.error(`[SystemCollector] Remote RAM error for ${this.spark.id}:`, err.message); |
|
return this._defaultRam(); |
|
} |
|
} |
|
|
|
async _getRemoteStorage() { |
|
try { |
|
// Include root (/); exclude pseudo filesystems via -x and type filter |
|
const cmd = |
|
"df -l -B1 -T -x tmpfs -x devtmpfs -x squashfs -x overlay -x efivarfs -x proc -x sysfs -x devpts -x cgroup -x cgroup2 2>/dev/null"; |
|
const output = await sshExec(this.spark, cmd); |
|
const lines = output.trim().split("\n").slice(1); // Skip header |
|
const disks = []; |
|
const disabledDevices = this.spark.disabledDevices || []; |
|
const PSEUDO = new Set([ |
|
"tmpfs", |
|
"devtmpfs", |
|
"proc", |
|
"sysfs", |
|
"efivarfs", |
|
"squashfs", |
|
"overlay", |
|
"devpts", |
|
"cgroup", |
|
"cgroup2", |
|
]); |
|
|
|
for (const line of lines) { |
|
const parts = line.split(/\s+/); |
|
if (parts.length < 7) continue; |
|
const [fsys, type, size, used, avail, pct, mount] = parts; |
|
|
|
if (mount === "/boot/efi" || mount.includes("/snap")) continue; |
|
if (PSEUDO.has((type || "").toLowerCase())) continue; |
|
|
|
const device = fsys.split("/").pop() || fsys; |
|
const isDisabled = |
|
disabledDevices.includes(device) || disabledDevices.includes(mount); |
|
|
|
disks.push({ |
|
device, |
|
label: mount, |
|
used: Math.round(parseInt(used) / 1024 / 1024), |
|
total: Math.round(parseInt(size) / 1024 / 1024), |
|
available: Math.round(parseInt(avail) / 1024 / 1024), |
|
percentage: parseInt(pct) || 0, |
|
readSpeed: 0, |
|
writeSpeed: 0, |
|
disabled: isDisabled, |
|
}); |
|
} |
|
|
|
return disks; |
|
} catch (err) { |
|
console.error(`[SystemCollector] Remote Storage error for ${this.spark.id}:`, err.message); |
|
return []; |
|
} |
|
} |
|
|
|
async _getRemoteNetwork() { |
|
try { |
|
const cmd = [ |
|
"cat /proc/net/dev 2>/dev/null", |
|
"echo '---'", |
|
"cat /proc/net/route 2>/dev/null", |
|
"echo '---'", |
|
"ip -4 addr show 2>/dev/null", |
|
"echo '---'", |
|
// Collect operstate for all non-virtual interfaces in one go |
|
"for d in /sys/class/net/*/operstate; do echo \"$(basename $(dirname $d)):$(cat $d)\"; done", |
|
"echo '---'", |
|
// WoL MAC for the primary LAN NIC on DGX Spark |
|
`cat /sys/class/net/${WOL_INTERFACE}/address 2>/dev/null || true`, |
|
].join("; "); |
|
|
|
const output = await sshExec(this.spark, cmd); |
|
const sections = output.split("---"); |
|
const devOut = sections[0]?.trim() || ""; |
|
const routeOut = sections[1]?.trim() || ""; |
|
const ipOut = sections[2]?.trim() || ""; |
|
const operstateOut = sections[3]?.trim() || ""; |
|
const wolMac = normalizeMac(sections[4]?.trim() || ""); |
|
|
|
// Parse operstate lines ("enP7s7:up") |
|
const operstateMap = new Map(); |
|
for (const line of operstateOut.split("\n")) { |
|
const idx = line.indexOf(":"); |
|
if (idx > 0) { |
|
operstateMap.set(line.slice(0, idx), line.slice(idx + 1).trim().toLowerCase()); |
|
} |
|
} |
|
|
|
// Parse IP addresses |
|
const ipMap = new Map(); |
|
const ipBlocks = ipOut.split(/\n(?=\d+:\s+)/); |
|
for (const block of ipBlocks) { |
|
const first = block.split("\n")[0]; |
|
const m = first.match(/^\d+:\s+(\S+):/); |
|
if (!m) continue; |
|
const iface = m[1]; |
|
const ipMatch = block.match(/inet\s+([\d.]+)/); |
|
if (ipMatch) { |
|
ipMap.set(iface, ipMatch[1]); |
|
} |
|
} |
|
|
|
// Parse /proc/net/dev |
|
const lines = devOut.split("\n").slice(2); |
|
const now = Date.now(); |
|
const interfaces = []; |
|
|
|
for (const line of lines) { |
|
const parts = line.trim().split(/[\s:]+/); |
|
if (parts.length < 17) continue; |
|
const iface = parts[0]; |
|
if (this._isVirtualNetworkInterface(iface)) continue; |
|
const rxBytes = parseInt(parts[1]) || 0; |
|
const txBytes = parseInt(parts[9]) || 0; |
|
const last = this.lastNetworkStats.get(iface) || { rxBytes, txBytes, time: now }; |
|
const dtSec = (now - last.time) / 1000; |
|
const rxSpeed = dtSec > 0 ? (rxBytes - last.rxBytes) / dtSec : 0; |
|
const txSpeed = dtSec > 0 ? (txBytes - last.txBytes) / dtSec : 0; |
|
this.lastNetworkStats.set(iface, { rxBytes, txBytes, time: now }); |
|
interfaces.push({ |
|
name: iface, |
|
rxSpeed: Math.max(0, Math.round(rxSpeed)), |
|
txSpeed: Math.max(0, Math.round(txSpeed)), |
|
ip: ipMap.get(iface) || null, |
|
operstate: operstateMap.get(iface) || "unknown", |
|
disabled: false, |
|
}); |
|
} |
|
|
|
const tagged = this._tagDisabledInterfaces(interfaces); |
|
|
|
// Parse /proc/net/route for default interface |
|
let primaryInterface = null; |
|
const routeLines = routeOut.split("\n"); |
|
for (const line of routeLines) { |
|
const parts = line.trim().split(/\s+/); |
|
if (parts.length >= 11 && parts[1] === "00000000" && (parseInt(parts[3], 16) & 1)) { |
|
primaryInterface = parts[0]; |
|
break; |
|
} |
|
} |
|
|
|
if (primaryInterface && (this.spark.disabledInterfaces || []).includes(primaryInterface)) { |
|
const alt = tagged.find((i) => !i.disabled); |
|
primaryInterface = alt?.name ?? primaryInterface; |
|
} |
|
|
|
let linkSpeedMbps = null; |
|
if (primaryInterface) { |
|
try { |
|
// Interface name is from the kernel; still keep it to safe chars |
|
if (/^[a-zA-Z0-9._-]+$/.test(primaryInterface)) { |
|
const speedRaw = await sshExec( |
|
this.spark, |
|
`cat /sys/class/net/${primaryInterface}/speed 2>/dev/null || true` |
|
); |
|
const n = parseInt(String(speedRaw).trim(), 10); |
|
if (Number.isFinite(n) && n > 0) linkSpeedMbps = n; |
|
} |
|
} catch { |
|
/* link speed optional */ |
|
} |
|
} |
|
|
|
return { primaryInterface, linkSpeedMbps, interfaces: tagged, wolMac }; |
|
} catch (err) { |
|
console.error(`[SystemCollector] Remote Network error for ${this.spark.id}:`, err.message); |
|
return this._defaultNetwork(); |
|
} |
|
} |
|
|
|
async _getRemoteUnifiedMemory() { |
|
try { |
|
const cmd = [ |
|
"grep -E 'MemTotal|MemAvailable' /proc/meminfo 2>/dev/null", |
|
"echo '---'", |
|
"nvidia-smi --query-compute-apps=pid,process_name,used_gpu_memory --format=csv,noheader,nounits 2>/dev/null", |
|
].join("; "); |
|
|
|
const output = await sshExec(this.spark, cmd); |
Summary
In the documented/default Docker setup, remote monitoring invokes a new OpenSSH client for every
sshExec()call. Because sparkDash does not configure connection multiplexing, each call normally establishes a new authenticated SSH connection and PAM session. With the default collector intervals on a healthy remote Spark, this produces a nominal steady-state rate of approximately 3.6 SSH authentications and PAM session opens per second (about 311,000 of each per day), even when no WebSocket clients are connected.This creates avoidable
sshd, PAM, systemd-logind, journal, and downstream session-monitor churn on every remote node. On one continuously monitored Ubuntu 24.04 DGX Spark, the rate calculated from the call sites matched the observed SSH authentication and PAM session-open counts during the measurement window.The user-facing UI and deployment model do not need to change. The existing OpenSSH client could reuse one authenticated transport per effective remote connection via multiplexing, with the current one-shot behavior retained as a compatibility fallback.
Environment
mainat7b47cd11d09adeee9e293d6f221430314d9e1ac6The remote-node polling path is independent of browser access or any client-side SSH tunnel. These connections originate from the sparkDash container on the head node using its configured monitoring key.
Observed behavior
Over a 120-second window on the remote node:
That is approximately 3.6 new authenticated sessions per second, matching the rate derived from the source.
The monitor continues polling in the background with no WebSocket clients, as documented in the README.
Correlated downstream observation
During the same workload,
polkitdanonymous RSS increased while the live logind-session count returned to baseline. One snapshot after about 66 minutes showed:This does not establish that sparkDash causes a
polkitdmemory leak. The observation is included only as a possible downstream effect of sustained session-lifecycle activity. The independently reproducible issue reported here is the repeated SSH authentication and PAM session creation.Root cause
server/collectors/ssh.jsstarts a new OpenSSH client invocation for eachsshExec()call viaexecFile()(sshfor key authentication, orsshpasswrappingsshfor password authentication):sparkDash/server/collectors/ssh.js
Lines 65 to 148 in 7b47cd1
There is no
ControlMaster,ControlPersist, connection pool, or other persistent SSH transport.SparkMonitor.start()schedules metric domains independently:sparkDash/server/sparks/SparkMonitor.js
Lines 341 to 359 in 7b47cd1
For a healthy target, with storage auto-polling enabled, a primary network interface detected, and the optional Hermes and Tailnet SSH probes disabled, the nominal steady-state rate is:
sshExec()calls; link-speed read is conditionalsshExec()callsRemote collector call sites:
sparkDash/server/collectors/SystemCollector.js
Lines 921 to 1272 in 7b47cd1
The Settings
pollIntervalMsvalue is only the WebSocket snapshot broadcast interval; changing it does not change these collector intervals.Reproduction
On distributions using
sshd.service, replace-u sshwith-u sshd.Expected behavior
sshd_config, or modify their Compose deployment.Proposed direction
Keep the existing
sshExec(spark, cmd, options)API and make connection reuse an internal implementation detail:ControlMaster,ControlPersist, and a hashedControlPathunder a private (0700) runtime directory inside the container.This requires no additional runtime package: the production image already installs
openssh-client, and the supported OpenSSH client implements these options.As a complementary optimization, the frequent system domains could later be combined into one remote collection command and liveness inferred from collection success. That would reduce channel/process overhead further, but it is not required to eliminate repeated authentication/PAM sessions.
Acceptance criteria
Accepted publickeyand PAM session-open events fall from ~3.6/s to approximately one at initial connection and one per actual reconnect.Workarounds
Changing the Settings
pollIntervalMsvalue does not reduce collector SSH activity because it controls only WebSocket snapshot delivery. Stopping sparkDash stops the observed churn; no confirmed workaround currently preserves remote monitoring while avoiding repeated authentication.