feat: full fan control — all fans or individual fans

The fan curve previously only controlled fan index 0; secondary fans
stayed on driver control. The curve can now target all fans (new
default) or any individual fan(s).

Backend:
- hal/fans.py: get_num_fans() via nvmlDeviceGetNumFans; get_fan_info()
  returns per-fan speeds; set_fan_speed() accepts a fan index list
  (None = all fans; all-fans mode is lenient toward driver-locked
  fans, explicit lists are strict); reset_fan() restores all fans.
- server.py: per-GPU fan_targets state; the poller applies the curve to
  all target fans and logs write failures (once per distinct error);
  activation validates targets against the hardware (stale indices fall
  back to all fans); POST /api/fans accepts fans, POST /api/fans/speed
  accepts a fan index, GET /api/fans returns num_fans/fans/fan_targets.
- Persistence format is now {"curve": ..., "fans": ...}; legacy
  bare-curve entries migrate to "all fans" at startup.
- Profiles save/apply fan_targets alongside fan_curve.
- MonitoringSample carries per-fan speeds for live gauges.

Frontend:
- Fans tab: All / Fan 1 / Fan 2 / ... selector with live per-fan %;
  the selection is applied together with the curve.
- Live monitor: per-fan gauges with sparklines for multi-fan GPUs.
This commit is contained in:
ARIA committed 2026-09-10 15:48:28 +02:00
1 parent 34a9bc6d6e
commit 6cb33187d3
12 files changed
+382 -77

No files matched your search

+22 -7
View File
@@ -47,6 +47,8 @@ export function FanMonitor({
}: Props) {
const fanHistory = pluck(history, "fan_pct");
const tempHistory = pluck(history, "temp_c");
const fans = monitor?.fans ?? null;
const multiFan = fans !== null && fans.length > 1;
const currentTemp = monitor?.temp_c ?? null;
const targetFan = computeTargetFan(fanCurve, currentTemp);
@@ -68,13 +70,26 @@ export function FanMonitor({
)}
</div>
<div className="flex flex-col gap-2 mt-1">
<GaugeCard
label="Fan Speed"
value={fmt.pct(monitor?.fan_pct)}
history={fanHistory}
color="#fb923c"
max={100}
/>
{multiFan ? (
fans!.map((_, i) => (
<GaugeCard
key={i}
label={`Fan ${i + 1}`}
value={fmt.pct(fans![i])}
history={history.map((s) => s.fans?.[i] ?? s.fan_pct ?? 0)}
color="#fb923c"
max={100}
/>
))
) : (
<GaugeCard
label="Fan Speed"
value={fmt.pct(monitor?.fan_pct)}
history={fanHistory}
color="#fb923c"
max={100}
/>
)}
<GaugeCard
label="GPU Temp"
value={fmt.celsius(monitor?.temp_c)}