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:
1 parent
34a9bc6d6e
commit
6cb33187d3
12 files changed
+382
-77
No files matched your search
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Check, X, RotateCcw, Plus } from "lucide-react";
|
||||
import { api } from "../../api/client.js";
|
||||
import { useCurveStore } from "../../store/curveStore.js";
|
||||
import type { FanPoint, FanState } from "../../types.js";
|
||||
import type { FanInfo, FanPoint, FanState } from "../../types.js";
|
||||
import { toast } from "sonner";
|
||||
import { ConfirmDialog } from "../common/ConfirmDialog.js";
|
||||
|
||||
@@ -44,6 +44,29 @@ function yToFan(y: number) {
|
||||
return Math.round(FAN_MAX - ((y - PAD.top) / PLOT_H) * (FAN_MAX - FAN_MIN));
|
||||
}
|
||||
|
||||
function sameTargets(
|
||||
a: number[] | null | undefined,
|
||||
b: number[] | null | undefined,
|
||||
) {
|
||||
if (a === null || a === undefined) return b === null || b === undefined;
|
||||
if (b === null || b === undefined) return false;
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((v, i) => v === b[i]);
|
||||
}
|
||||
|
||||
function fanLabel(targets: number[] | null): string {
|
||||
return targets === null
|
||||
? "all fans"
|
||||
: targets.map((i) => `Fan ${i + 1}`).join(", ");
|
||||
}
|
||||
|
||||
const chipCls = (selected: boolean) =>
|
||||
`px-2 py-0.5 rounded-full border text-xs font-medium transition-colors ${
|
||||
selected
|
||||
? "bg-cyan-500/15 border-cyan-500/40 text-cyan-300"
|
||||
: "bg-zinc-800 border-zinc-700 text-zinc-500 hover:text-zinc-300"
|
||||
}`;
|
||||
|
||||
export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
const { selectedGpuIndex } = useCurveStore();
|
||||
const [fanState, setFanState] = useState<FanState | null>(null);
|
||||
@@ -54,6 +77,9 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
const [confirmApply, setConfirmApply] = useState(false);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||
// Fan selection: undefined = no pending change (follow server state),
|
||||
// null = all fans, list = specific fan indices.
|
||||
const [fanSel, setFanSel] = useState<number[] | null | undefined>(undefined);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
async function fetchFans() {
|
||||
@@ -61,6 +87,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
setLoading(true);
|
||||
const data = await api.fans(selectedGpuIndex);
|
||||
setFanState(data);
|
||||
setFanSel(undefined);
|
||||
if (data.curve && data.curve.length > 0) {
|
||||
setPending(null);
|
||||
}
|
||||
@@ -83,14 +110,41 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
const curveActive = fanState?.curve_active ?? false;
|
||||
const isDefaults = !pending && !fanState?.curve;
|
||||
|
||||
const fanTargets =
|
||||
fanSel === undefined ? (fanState?.fan_targets ?? null) : fanSel;
|
||||
const fansChanged =
|
||||
fanSel !== undefined && !sameTargets(fanSel, fanState?.fan_targets ?? null);
|
||||
const fanList: FanInfo[] =
|
||||
fanState?.fans && fanState.fans.length > 0
|
||||
? fanState.fans
|
||||
: [{ index: 0, fan_pct: null }];
|
||||
|
||||
function toggleFan(idx: number) {
|
||||
const numFans = fanState?.num_fans ?? 1;
|
||||
let list: number[];
|
||||
if (fanTargets === null) {
|
||||
// Start from all fans, then drop the toggled one
|
||||
list = Array.from({ length: numFans }, (_, i) => i).filter(
|
||||
(i) => i !== idx,
|
||||
);
|
||||
} else {
|
||||
list = fanTargets.includes(idx)
|
||||
? fanTargets.filter((i) => i !== idx)
|
||||
: [...fanTargets, idx].sort((a, b) => a - b);
|
||||
}
|
||||
if (list.length === 0) return; // keep at least one fan selected
|
||||
setFanSel(list.length === numFans ? null : list);
|
||||
}
|
||||
|
||||
async function handleApply() {
|
||||
const curveToApply = pending ?? fanState?.curve ?? defaultCurve();
|
||||
if (curveToApply.length < 2) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.updateFans(curveToApply, selectedGpuIndex);
|
||||
await api.updateFans(curveToApply, selectedGpuIndex, fanTargets);
|
||||
setPending(null);
|
||||
setFanSel(undefined);
|
||||
setConfirmApply(false);
|
||||
await fetchFans();
|
||||
onChanged?.();
|
||||
@@ -109,6 +163,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
try {
|
||||
await api.resetFans(selectedGpuIndex);
|
||||
setPending(null);
|
||||
setFanSel(undefined);
|
||||
setConfirmReset(false);
|
||||
await fetchFans();
|
||||
onChanged?.();
|
||||
@@ -251,9 +306,10 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
<button
|
||||
onClick={() => {
|
||||
setPending(null);
|
||||
setFanSel(undefined);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={!hasPending || busy}
|
||||
disabled={(!hasPending && !fansChanged) || busy}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<X size={12} />
|
||||
@@ -270,6 +326,48 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fan selection */}
|
||||
<div className="px-4 py-2 border-b border-zinc-800/60 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
|
||||
Fans
|
||||
</span>
|
||||
{fanState?.num_fans === 0 ? (
|
||||
<span className="text-xs text-zinc-600">
|
||||
No fans available on this GPU
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setFanSel(null)}
|
||||
className={chipCls(fanTargets === null)}
|
||||
title="Control all fans"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{fanList.map((f) => (
|
||||
<button
|
||||
key={f.index}
|
||||
onClick={() => toggleFan(f.index)}
|
||||
className={chipCls(
|
||||
fanTargets === null || fanTargets.includes(f.index),
|
||||
)}
|
||||
title={`Control Fan ${f.index + 1} with the curve`}
|
||||
>
|
||||
Fan {f.index + 1}
|
||||
<span className="ml-1.5 font-mono text-[10px] opacity-80">
|
||||
{f.fan_pct !== null ? `${Math.round(f.fan_pct)}%` : "—"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{fansChanged && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs">
|
||||
Fans: {fanLabel(fanTargets)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="px-3 py-1.5 bg-red-900/40 border-b border-red-700 text-red-300 text-xs flex items-center justify-between">
|
||||
@@ -557,13 +655,14 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
{/* Info */}
|
||||
<div className="px-4 pb-3 text-[10px] text-zinc-600">
|
||||
{curveActive
|
||||
? "Fan curve is active. Server adjusts fan speed based on GPU temperature."
|
||||
? `Fan curve is active — controlling ${fanLabel(fanTargets)}. Server adjusts fan speed based on GPU temperature.`
|
||||
: isDefaults
|
||||
? "These are default values. Click Apply to enable curve control, or edit points first."
|
||||
: "Apply a curve to enable automatic fan control based on temperature."}
|
||||
<span className="block mt-1 text-zinc-700">
|
||||
Drag points to adjust · click the chart to add a point · click the ✕
|
||||
(chart or table) to remove one.
|
||||
(chart or table) to remove one · pick which fans the curve controls
|
||||
above.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -571,7 +670,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
{confirmApply && (
|
||||
<ConfirmDialog
|
||||
message="Apply fan curve?"
|
||||
detail="Fan control will switch to curve-based mode. The server will adjust fan speed based on GPU temperature."
|
||||
detail={`Fan control will switch to curve-based mode for ${fanLabel(fanTargets)}. The server will adjust fan speed based on GPU temperature.`}
|
||||
confirmLabel="Apply"
|
||||
onConfirm={handleApply}
|
||||
onCancel={() => setConfirmApply(false)}
|
||||
|
||||
Reference in new issue
Block a user