Skip to content

feat: dedicated CPU panel with auto-aligned two-column Resources layout - #91

Open
PhilipEriksson wants to merge 10 commits into
MiaAI-Lab:mainfrom
PhilipEriksson:feat/cpu-panel
Open

PhilipEriksson wants to merge 10 commits into
MiaAI-Lab:mainfrom
PhilipEriksson:feat/cpu-panel

Conversation

@PhilipEriksson

@PhilipEriksson PhilipEriksson commented Sep 9, 2026

Copy link
Copy Markdown

Summary

Adds a dedicated CPU panel to the device page, restructures the Resources section into two auto-aligned columns, and adds editable clock caps for both the CPU and GPU so you can dial the clocks down to keep the box cool.

On GB10 devices (DGX Spark / GX10) the CPU and GPU share one package and one power envelope, and the CPU is often the part that runs hot first. Previously CPU temperature was only a small row tucked inside the GPU panel (Sparks) or RAM panel (hosts), easy to miss. This makes CPU a first-class section.

CPU panel

Shows, with sparklines where it helps:

  • Usage %
  • Temperature (color-banded: amber >85°C, red >95°C; the GB10 SoC derates in the mid-80s)
  • CPU Power (draw / TDP)
  • Model · cores (e.g. "GB10 · 20 cores")

Layout

The Resources section is now two independent columns:

left:  GPU + CPU            right: [RAM] + Storage + Network [+ Tailnet]
  • CPU sits directly beneath GPU in the left column (previously it was top-right, leaving dead space under the GPU's Processes list).
  • Each column is a flex stack, so panels take their natural height, no more row-stretch gap under the GPU panel.
  • The bottom panel of each column gets flex-grow, so whichever side is shorter stretches its lowest panel (CPU on the left; Network, or Tailnet when it's last, on the right) to match. Both columns always end at the same height regardless of content or which panels are present.

Clock caps (editable)

A Clock Cap row on both the CPU and GPU panels opens a dialog to set a frequency ceiling, live and/or persisted to the boot unit. This is the easy way to keep a GB10 box cool: cap the GPU graphics clock and the CPU clusters a few hundred MHz below their hardware max and the temperature drops without meaningfully touching throughput.

  • Per-Spark opt-in — clock control is off by default. Enable it per Spark (Edit Spark → "Allow clock control") so a box you don't want to touch stays read-only.
  • GPU — set the graphics clock within the hardware band (0.30–0.80 of the Max Clocks → Graphics ceiling, which is 3003 MHz on GB10, not the 2418 "Applications Clocks" default). The driver quantizes the request against its own table (ask 2000, get 1976), so the dialog reports both the requested and the applied value and never snaps the number you typed.
  • CPU — one row per frequency cluster (X925 big / A725 little on GB10), each capped within its own sysfs bounds.
  • Apply vs Save — "Apply" lasts until reboot; "Save" rewrites the boot unit so the cap survives a reboot. Removing the cap restores the hardware maximum.
  • Two ways to set a value — a 200 MHz grid slider and a free-entry field (never snapped client-side); moving either updates the other.
  • Remote Sparks — a one-time helper install (scripts/install-clock-helper.sh) provisions a scoped NOPASSWD sudoers grant; the dialog itself never asks for a password.

Why bother: cooler with almost no throughput cost

Capping the clocks is the cheapest thermal lever on a GB10 box. In practice the decode and prefill throughput loss is negligible: on the GLM-5.3-Flash-EXL3 box the caps cost 0% on both prefill and decode (the architecture doesn't bottleneck on the clock headroom being given back), while the sustained temperature comes down enough to stop the thermal derating that would otherwise eat more performance than the cap does.

Changes

  • src/components/SparkPage/CpuPanel.tsx — new panel + per-cluster Clock Cap rows
  • src/components/SparkPage/GpuPanel.tsx — Clock Cap row + cap-scaled SM-clock bar; drop the CPU-temp row (now in CpuPanel)
  • src/components/SparkPage/ClockCapControl.tsx — the editable cap dialog (slider + free entry, candidates, presets, requested-vs-applied honesty)
  • src/components/SparkPage/SparkPage.tsx — two-column Resources layout + grow alignment
  • src/components/SparkPage/RamPanel.tsx — drop the CPU-temp row (now in CpuPanel)
  • src/components/SparkPage/NetworkPanel.tsx, TailscalePanel.tsx — optional className prop for the grow alignment
  • src/components/EditSparkDialog.tsx — per-Spark "Allow clock control" opt-in
  • src/App.tsx — carry clockControlEnabled through the snapshot/WS merge
  • src/api/client.ts, src/api/types.ts — bounds + set-cap client, ClockCap* types
  • src/components/ui/icons.tsx — new CpuIcon
  • src/index.css — clock dialog width class
  • server/collectors/clockControl.js — bounds computation, GPU -lgc reply parser, apply/remove (helper + container paths)
  • server/collectors/SystemCollector.js — clock-cap collection + apply wiring
  • server/sparks/SparkMonitor.js, SparkRegistry.jsclockControlEnabled snapshot key + persistence
  • server/index.jsPOST /api/sparks/:id/clocks (rate-limited) + bounds endpoint
  • server/config.jsGPU_CLOCK_LOCK_UNIT env knob
  • scripts/install-clock-helper.sh, scripts/sparkdash-set-clock — remote helper + scoped sudoers grant
  • README.md — clock-control provisioning + usage
  • tests: clockControl.test.js, SystemCollector.clockControl.test.js, SystemCollector.clockCaps.test.js, clockHelperScript.test.js, monitor-lifecycle.test.js, ClockCapControl.test.tsx

Verified

Built and running on a live 3x GB10 fleet (2x DGX Spark + 1x GX10). CPU temps render correctly per device, the two columns align at the bottom across boxes with different panel counts, and the clock caps read and apply correctly on all three boxes, local and remote-over-SSH.

Tests: server suite 419/419, frontend 41/41, tsc --noEmit clean, production build green.

Add a CPU panel (usage, temperature, power, model/cores with sparklines)
as the single home for CPU stats on the device page, and restructure the
Resources section into two independent columns:

  left:  GPU + CPU (CPU directly beneath GPU)
  right: [RAM] + Storage + Network [+ Tailnet]

On GB10 devices (DGX Spark / GX10) the CPU and GPU share one package and
power envelope, and the CPU is often the part that runs hot first — this
makes that visible at the device level.

Layout: each column is a flex stack so panels take natural heights (no
row-stretch dead space under the GPU panel). The bottom panel of each
column gets flex-grow, so whichever side is shorter stretches its lowest
panel to match — both columns always end at the same height regardless of
content.

CPU stats previously lived as a temp row inside the GPU panel (Sparks) and
RAM panel (hosts); those rows are removed in favor of the dedicated panel
so CPU is shown in one place. NetworkPanel and TailscalePanel gain an
optional className prop to support the grow alignment.
@PhilipEriksson

Copy link
Copy Markdown
Author

Hi @MikeGibbsOnyx @MiaAI-Lab — first-time contributor, small nudge. To be clear on scope: CPU temperature on the summary page is already covered upstream (1.8.6) — this PR is about the device page, where there was no CPU section at all. It adds a dedicated CPU panel (usage, temperature, power, model/cores) and reflows Resources into two auto-aligned columns with CPU directly under GPU. The one thing I'd value your take on: I removed the CPU-temp rows from the GPU/RAM panels in favor of the dedicated panel (single home for CPU stats) — on GB10 the CPU is often the hotter half of the package, which motivated it, but happy to keep the rows if you'd rather not change that. Otherwise self-contained frontend, no backend impact.

@MikeGibbsOnyx

MikeGibbsOnyx commented Sep 10, 2026 via email

Copy link
Copy Markdown
Collaborator

@PhilipEriksson

Copy link
Copy Markdown
Author

Appreciate it Mike! I'll leave the merge to you🫱🏻‍🫲🏼

Add a 'Clock Cap' row to the CPU and GPU panels showing the active
frequency ceilings, so a capped device is visible at a glance.

- CPU: per-frequency-domain max_perf read from sysfs (local) or a single
  SSH command (remote). Domains are grouped by cpuinfo_max_freq and
  labelled X925/A725 on GB10; a domain is 'capped' when its max_perf
  ceiling is below the hardware max.
- GPU: nvidia-smi does not expose the active -lgc range, so the lock is
  read from the gpu-clock-lock.service unit (path configurable via
  GPU_CLOCK_LOCK_UNIT, defaulting to the common convention).
- GPU SM-clock bar now scales to the cap when one is set, so it reflects
  the headroom that actually exists (2177/2200 reads ~full, not 72%).
- Caps are cached 60s with an in-flight guard so the parallel CPU/GPU
  polls share one read.

Verified on a 3x GB10 fleet (2x DGX Spark + 1x GX10): X925 2600/3900
capped, A725 2808/2808, GPU 2200 lock — local and remote-over-SSH.
…dware-bounded slider, live apply + boot-unit persist

- server: pure clockControl.js (domain enum, bounds parsing from cpuinfo_min/
  max_freq and nvidia-smi -q -d CLOCK Default Applications Clock, clamping,
  validation, helper argv, exit-code interpretation), SystemCollector
  applyClockCap with SSH root helper (primary) and local container-root
  fallback (secondary), volatile override merge so live-only applies are
  shown truthfully for the cache TTL
- API: GET /api/sparks/:id/clocks/bounds, POST /api/sparks/:id/clocks
  (opt-in per Spark via clockControlEnabled, 403 when off, server-side
  clamping against re-read hardware bounds, rate-limited)
- scripts: sparkdash-set-clock (POSIX sh root helper) + one-time scoped
  sudoers installer
- client: ClockCapControl dialog (slider + number input bounded by
  discovered range, presets, Apply this-boot vs Save, honest warnings),
  wired into GPU and CPU panels, Allow clock control toggle in Edit Spark
- tests: clockControl.test.js (pure logic), SystemCollector.clockControl
  (apply paths, override merge, bounds), ClockCapControl.test.tsx (vitest)
…-domain unit preservation, systemd $-safe ExecStart, README

- _dropConvergedOverrides: gpu override drops only when a fresh read
  actually reports the applied value (unit file is blind to a live-only
  -lgc), so the UI stays honest past the cache TTL
- _persistClockUnitLocal: both CPU domains share cpu-clock-cap.service,
  so a one-domain Save now carries the sibling domain's effective cap
  (volatile override, else live max_perf) instead of clobbering it;
  explicit per-core commands (no shell loop variables — systemd expands
  $VAR in ExecStart); unreadable sibling cores are omitted, not guessed
- SparkMonitor.updateConfig calls clearClockCapsOverride (D5 hot config)
- helper cpu_unit_body: same systemd $-safe per-core ExecStart
- README: Clock control section, TOC/Features/REST API/Scripts rows
- tests: gpu override survival/convergence + 5 sibling-persist regressions
…llback warning

- parseCpuCoreMaxKhz / expandCpuToken / parseCpuBootUnitDefaults: derive
  the Boot default preset per CPU domain from what cpu-clock-cap.service
  actually installs (systemd brace ranges and explicit per-core echoes;
  unknown cores skipped, never guessed). GPU boot default = the -lgc value
  parsed from gpu-clock-lock.service. Nothing hardcoded.
- _getClockBounds now also returns cpuBootDefaults + gpuBootDefaultMHz
  (local: read through the host-root bind; remote: the bounds command
  dumps both boot units as parts[2]/[3])
- bounds route reports the D8 GPU fallback use in warnings; the dialog
  surfaces bounds warnings next to the range
- tests: brace-range expansion, measured-unit parsing (both ExecStart
  styles), preset exposure; fixture corrected to the real spark-1
  topology (cores 0-4 and 10-14 = X925, 5-9 and 15-19 = A725)
…ibling-preserving helper persist

Gate 1: SparkMonitor.snapshot() now emits clockControlEnabled (beside
comfyMonitoring), and src/App.tsx threads the flag through
placeholderSnapshot's roleFields + the WS-down merge block, so the opt-in
toggle actually reaches the browser. Pinned by snapshot-contract tests in
monitor-lifecycle.test.js.

Gate 2: the helper argv chain and the availability probe no longer gate on
"sudo -n true" (impossible under the shipped argumentless scoped sudoers
grant). The probe exercises exactly the granted command: test -x (missing ->
127/not-installed), then an argumentless "sudo -n <helper>" whose usage print
exits 1 = installed-and-allowed; sudo refusals hit the
sparkdash-probe-sentinel -> 126/passwordless-sudo-hint. interpretHelperExit
anchors on the argv sentinels instead of bare 126/127 digits in the message;
the bounds API passes the probe's precise reason through to the domain rows.
README + installer verify hint document the encoding.

Gate 3: scripts/sparkdash-set-clock cpu_unit_body regenerates BOTH CPU
domains (sibling values read from each core's current max_perf, unreadable
cores omitted) so a single-domain Save can no longer wipe the sibling's boot
cap. The container persist path (_persistClockUnitLocal) now wraps the CPU
ExecStart in /bin/sh -c and matches the helper's Description/After/blank-line
unit body, so both paths produce byte-identical units for the same request.
New clockHelperScript.test.js drives the real script (sh) against a stubbed
sysfs tree.
…oks, normalized exec-list shape

cpu-big = the >=3 MHz group per the parseCpuClockCaps convention (fixtures
had the groups swapped); SPARKDASH_SMI hook shadows the absolute nvidia-smi
path; write_core now honors SPARKDASH_CPU_SYS; the helper's per-core command
list is built as 'cmd; cmd' with no leading/trailing separators so the
generated unit is byte-identical to the container persist path.
…-applied honesty

Per the card + orchestrator amendments, measured on the live box:

- GPU ceiling is now parsed from "Max Clocks → Graphics" (3003 on this
  silicon), with the fallback chain Max Clocks → Applications Clocks →
  Default Applications Clocks → GPU_CLOCK_MAX_MHZ. "Default Applications
  Clocks" is the boot-default boost point (2418), NOT the hardware limit —
  using it made 2600/2800/3000 unreachable. The section parser anchors on
  the exact header line (indent-4, no trailing colon; keys indent-8 with a
  padded fixed-column colon) and scans only strictly-deeper children, so the
  live transient "Clocks" sample (2184) can never win. Accepts the singular
  header spelling some drivers print. The deprecated reader is kept as the
  reported boot-default datapoint only.
- POST /api/sparks/:id/clocks responses carry additive requestedMHz /
  appliedMHz / snapped (src/api/types.ts mirrors, response shape stays
  backward compatible). The driver quantises -lgc silently (a requested
  2000 lands on 1976), so the apply paths now capture nvidia-smi's own
  "GPU clocks set to (min, max)" confirmation on BOTH the helper and the
  container path, and sysfs max_perf writes are read back; the strictest
  observed value is reported. When no observation is possible the response
  repeats the request and adds an explicit "could not be read back"
  warning — it never claims an unobserved confirmation. The D5 volatile
  override records the APPLIED value, never the requested one.
- buildClockCapDomains emits the wire-ready 200 MHz grid (grid.min/max/
  step/candidates/bandApplied) per domain: pure arithmetic band
  [0.30, 0.80]-of-ceiling (align up/down on 200), NO hardware detenting
  (--query-supported-clocks=graphics prints [N/A] on driver 580.173.02);
  a degenerate band falls back to the plain hard bounds with no candidate
  chips instead of crashing.
- Tests: the orchestrator-pinned -q -d CLOCK fixture parsed verbatim
  (ceiling=3003; 2184 and 2418 asserted non-ceiling), the three pinned
  per-domain candidate lists, band edges, degenerate-band fallback, the
  captured set-to stdout shapes for both apply paths, and the additive
  response fields. 90/90 green.
…ify button position

- Slider (200 MHz grid) and manual-entry field are now two distinct labelled
  elements (own <label> each), not one combined flex widget. They still share
  one value state, so moving either updates the other.
- Closed state: wrap [Modify][value chip] in one right-aligned flex group and
  give the chip a fixed width (w-28), so the Modify button sits at the same
  x-position on every row — the two CPU cluster rows (Big/Little) line up
  regardless of label or chip-text width.
- Tests: +2 (separate labelled controls; Modify+chip one fixed group).
@PhilipEriksson

Copy link
Copy Markdown
Author

I made some new changes that fits here too @MikeGibbsOnyx Basically added a UI/UX for setting MHz Caps on your GPU, and CPU, so that your device can run cooler, while only sacrificing a little throughput in both decode and prefill.

For my 2x spark setup, lowering some of my CPU cores was completely free due to the architecture of GLM5.3 Flash, 0% loss in both decode and prefill, whilst saving at least 10-15 degrees celsius worth of heat on my CPU.

I don't have write access so I can't merge, but leaving it for you here to have a look, basically these caps are just to simplify the process of capping your GPU and CPU in the UI itself

@MikeGibbsOnyx

MikeGibbsOnyx commented Sep 13, 2026 via email

Copy link
Copy Markdown
Collaborator

@MikeGibbsOnyx

Copy link
Copy Markdown
Collaborator

Splitting this for review (same author credit to @PhilipEriksson):

Leaving this PR open so you can close it when A/B look right. No merge from us.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants