Files
nvcurve/frontend/src/components/Fans/FanCurveEditor.tsx
T
ARIA 6cb33187d3 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.
2026-09-10 15:48:28 +02:00

692 lines
24 KiB
TypeScript

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 { FanInfo, FanPoint, FanState } from "../../types.js";
import { toast } from "sonner";
import { ConfirmDialog } from "../common/ConfirmDialog.js";
function defaultCurve(): FanPoint[] {
return [
{ temp_c: 30, fan_pct: 0 },
{ temp_c: 45, fan_pct: 40 },
{ temp_c: 60, fan_pct: 80 },
{ temp_c: 70, fan_pct: 100 },
];
}
const CHART_W = 500;
const CHART_H = 260;
const PAD = { top: 10, right: 10, bottom: 30, left: 40 };
const PLOT_W = CHART_W - PAD.left - PAD.right;
const PLOT_H = CHART_H - PAD.top - PAD.bottom;
const TEMP_MIN = 20;
const TEMP_MAX = 100;
const FAN_MIN = 0;
const FAN_MAX = 100;
function tempToX(t: number) {
return PAD.left + ((t - TEMP_MIN) / (TEMP_MAX - TEMP_MIN)) * PLOT_W;
}
function fanToY(f: number) {
return PAD.top + PLOT_H - ((f - FAN_MIN) / (FAN_MAX - FAN_MIN)) * PLOT_H;
}
function xToTemp(x: number) {
return Math.round(
TEMP_MIN + ((x - PAD.left) / PLOT_W) * (TEMP_MAX - TEMP_MIN),
);
}
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);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState<FanPoint[] | null>(null);
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() {
try {
setLoading(true);
const data = await api.fans(selectedGpuIndex);
setFanState(data);
setFanSel(undefined);
if (data.curve && data.curve.length > 0) {
setPending(null);
}
} catch {
toast.error("Failed to load fan state");
} finally {
setLoading(false);
}
}
// Data fetch on GPU change — setState calls happen after the await, not
// synchronously in the effect body (rule false-positive on async fetch).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchFans();
}, [selectedGpuIndex]);
const activeCurve = pending ?? fanState?.curve ?? defaultCurve();
const hasPending = pending !== null;
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, fanTargets);
setPending(null);
setFanSel(undefined);
setConfirmApply(false);
await fetchFans();
onChanged?.();
toast.success("Fan curve applied");
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
setConfirmApply(false);
} finally {
setBusy(false);
}
}
async function handleReset() {
setBusy(true);
setError(null);
try {
await api.resetFans(selectedGpuIndex);
setPending(null);
setFanSel(undefined);
setConfirmReset(false);
await fetchFans();
onChanged?.();
toast.success("Fan control reset to automatic");
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
setConfirmReset(false);
} finally {
setBusy(false);
}
}
function handleCanvasClick(e: React.MouseEvent<SVGSVGElement>) {
if (dragIdx !== null) return;
const svg = svgRef.current;
if (!svg) return;
const rect = svg.getBoundingClientRect();
const scaleX = CHART_W / rect.width;
const scaleY = CHART_H / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
const temp = Math.max(TEMP_MIN, Math.min(TEMP_MAX, xToTemp(x)));
const fan = Math.max(FAN_MIN, Math.min(FAN_MAX, yToFan(y)));
const baseCurve = pending ?? fanState?.curve ?? defaultCurve();
const existing = baseCurve.findIndex((p) => p.temp_c === temp);
if (existing >= 0) return;
const updated = [...baseCurve, { temp_c: temp, fan_pct: fan }].sort(
(a, b) => a.temp_c - b.temp_c,
);
setPending(updated);
}
function removePoint(idx: number) {
const curve = pending ?? fanState?.curve;
if (!curve || curve.length <= 2) return;
const updated = curve.filter((_, i) => i !== idx);
setPending(updated);
}
const handlePointerDown = useCallback((idx: number) => {
setDragIdx(idx);
}, []);
const handlePointerMove = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
if (dragIdx === null) return;
e.preventDefault();
const svg = svgRef.current;
if (!svg) return;
const rect = svg.getBoundingClientRect();
const scaleX = CHART_W / rect.width;
const scaleY = CHART_H / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
const temp = Math.max(TEMP_MIN, Math.min(TEMP_MAX, xToTemp(x)));
const fan = Math.max(FAN_MIN, Math.min(FAN_MAX, yToFan(y)));
const curve = pending ?? fanState?.curve ?? defaultCurve();
const updated = [...curve];
updated[dragIdx] = { temp_c: temp, fan_pct: fan };
setPending(updated);
},
[dragIdx, pending, fanState],
);
const handlePointerUp = useCallback(() => {
setDragIdx(null);
setPending((p) => (p ? [...p].sort((a, b) => a.temp_c - b.temp_c) : p));
}, []);
useEffect(() => {
if (dragIdx === null) return;
window.addEventListener("pointerup", handlePointerUp);
return () => window.removeEventListener("pointerup", handlePointerUp);
}, [dragIdx, handlePointerUp]);
// Build polyline path from curve points
const curvePath =
activeCurve && activeCurve.length >= 2
? activeCurve
.map((p) => `${tempToX(p.temp_c)},${fanToY(p.fan_pct)}`)
.join(" ")
: null;
// Grid lines
const tempTicks = [30, 40, 50, 60, 70, 80, 90];
const fanTicks = [0, 25, 50, 75, 100];
if (loading && !fanState) {
return (
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col animate-pulse">
<div className="px-3 py-2 border-b border-zinc-800 h-9" />
<div className="p-4 flex flex-col gap-6">
<div className="h-64 bg-zinc-800 rounded" />
</div>
</div>
);
}
return (
<>
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 shrink-0">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
Fan Curve
</span>
{isDefaults && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-orange-500/15 border border-orange-500/30 text-orange-400 text-xs">
Defaults
</span>
)}
{hasPending && (
<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">
{activeCurve?.length ?? 0} pts
</span>
)}
{curveActive && !hasPending && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500/15 border border-emerald-500/30 text-emerald-400 text-xs">
Active
</span>
)}
<div className="ml-auto flex items-center gap-2">
<button
onClick={() => setConfirmApply(true)}
disabled={busy}
className="flex items-center gap-1.5 px-2 py-1 rounded bg-emerald-700 hover:bg-emerald-600 text-white text-xs font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<Check size={12} />
Apply
</button>
<button
onClick={() => {
setPending(null);
setFanSel(undefined);
setError(null);
}}
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} />
Discard
</button>
<button
onClick={() => setConfirmReset(true)}
disabled={busy || !curveActive}
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-red-900 text-zinc-500 hover:text-red-300 text-xs transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<RotateCcw size={11} />
Reset
</button>
</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">
<span>{error}</span>
<button
onClick={() => setError(null)}
className="ml-2 text-red-400 hover:text-red-200"
>
x
</button>
</div>
)}
{/* Chart area */}
<div className="p-4 flex justify-center relative">
{isDefaults && (
<div className="absolute inset-x-4 top-4 z-10 pointer-events-none text-center">
<span className="inline-block bg-zinc-900/90 backdrop-blur-sm border border-orange-500/30 rounded-lg px-4 py-2 text-xs text-orange-400/90 font-medium">
Default curve — edit points or apply as-is to enable curve
control
</span>
</div>
)}
<svg
ref={svgRef}
width="100%"
viewBox={`0 0 ${CHART_W} ${CHART_H}`}
className="max-w-full cursor-crosshair select-none touch-none"
onClick={handleCanvasClick}
onPointerMove={handlePointerMove}
>
{/* Background */}
<rect
x={PAD.left}
y={PAD.top}
width={PLOT_W}
height={PLOT_H}
fill="#09090b"
rx="4"
/>
{/* Grid lines - horizontal (fan %) */}
{fanTicks.map((f) => (
<g key={`fy-${f}`}>
<line
x1={PAD.left}
y1={fanToY(f)}
x2={PAD.left + PLOT_W}
y2={fanToY(f)}
stroke="#27272a"
strokeWidth="0.5"
/>
<text
x={PAD.left - 6}
y={fanToY(f) + 3}
textAnchor="end"
fill="#71717a"
fontSize="9"
fontFamily="monospace"
>
{f}%
</text>
</g>
))}
{/* Grid lines - vertical (temp) */}
{tempTicks.map((t) => (
<g key={`tx-${t}`}>
<line
x1={tempToX(t)}
y1={PAD.top}
x2={tempToX(t)}
y2={PAD.top + PLOT_H}
stroke="#27272a"
strokeWidth="0.5"
/>
<text
x={tempToX(t)}
y={PAD.top + PLOT_H + 16}
textAnchor="middle"
fill="#71717a"
fontSize="9"
fontFamily="monospace"
>
{t}°
</text>
</g>
))}
{/* Axis labels */}
<text
x={PAD.left + PLOT_W / 2}
y={CHART_H - 2}
textAnchor="middle"
fill="#52525b"
fontSize="9"
>
Temperature (°C)
</text>
<text
x={8}
y={PAD.top + PLOT_H / 2}
textAnchor="middle"
fill="#52525b"
fontSize="9"
transform={`rotate(-90, 8, ${PAD.top + PLOT_H / 2})`}
>
Fan Speed (%)
</text>
{/* Curve line */}
{curvePath && (
<polyline
points={curvePath}
fill="none"
stroke={hasPending ? "#22d3ee" : "#fb923c"}
strokeWidth="2"
strokeLinejoin="round"
strokeLinecap="round"
opacity="0.9"
/>
)}
{/* Curve fill area */}
{curvePath && activeCurve && activeCurve.length >= 2 && (
<polygon
points={`${tempToX(activeCurve[0].temp_c)},${PAD.top + PLOT_H} ${curvePath} ${tempToX(activeCurve[activeCurve.length - 1].temp_c)},${PAD.top + PLOT_H}`}
fill={hasPending ? "#22d3ee" : "#fb923c"}
opacity="0.07"
/>
)}
{/* Points */}
{activeCurve?.map((p, i) => (
<g key={i}>
<circle
cx={tempToX(p.temp_c)}
cy={fanToY(p.fan_pct)}
r="6"
fill={hasPending ? "#22d3ee" : "#fb923c"}
stroke="#09090b"
strokeWidth="2"
className="cursor-grab active:cursor-grabbing touch-none"
onPointerDown={(e) => {
e.stopPropagation();
handlePointerDown(i);
}}
/>
{/* Remove button: faintly visible, brightens on hover */}
{activeCurve && activeCurve.length > 2 && (
<g
className="cursor-pointer group/remove"
onClick={(e) => {
e.stopPropagation();
removePoint(i);
}}
>
<title>Remove point</title>
<circle
cx={tempToX(p.temp_c) + 9}
cy={fanToY(p.fan_pct) - 9}
r="7"
fill="#27272a"
stroke="#3f3f46"
strokeWidth="1"
className="opacity-50 group-hover/remove:opacity-100 group-hover/remove:stroke-red-400 transition-opacity"
/>
{/* X glyph */}
<line
x1={tempToX(p.temp_c) + 6.5}
y1={fanToY(p.fan_pct) - 11.5}
x2={tempToX(p.temp_c) + 11.5}
y2={fanToY(p.fan_pct) - 6.5}
stroke="#a1a1aa"
strokeWidth="1.2"
className="group-hover/remove:stroke-red-400 transition-colors"
/>
<line
x1={tempToX(p.temp_c) + 11.5}
y1={fanToY(p.fan_pct) - 11.5}
x2={tempToX(p.temp_c) + 6.5}
y2={fanToY(p.fan_pct) - 6.5}
stroke="#a1a1aa"
strokeWidth="1.2"
className="group-hover/remove:stroke-red-400 transition-colors"
/>
</g>
)}
{/* Value label */}
<text
x={tempToX(p.temp_c)}
y={fanToY(p.fan_pct) - 12}
textAnchor="middle"
fill={hasPending ? "#22d3ee" : "#fb923c"}
fontSize="8"
fontFamily="monospace"
fontWeight="600"
pointerEvents="none"
>
{p.fan_pct}%
</text>
</g>
))}
{/* No-curve hint (only when curve is truly empty) */}
{!activeCurve && (
<text
x={CHART_W / 2}
y={CHART_H / 2}
textAnchor="middle"
fill="#52525b"
fontSize="11"
>
Click to add points
</text>
)}
</svg>
</div>
{/* Point table below chart */}
{activeCurve && activeCurve.length > 0 && (
<div className="px-4 pb-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-zinc-500 uppercase tracking-wider">
Curve Points
</span>
<button
onClick={() => {
const curve = pending ?? fanState?.curve ?? defaultCurve();
const newCurve = [...curve, { temp_c: 50, fan_pct: 50 }];
newCurve.sort((a, b) => a.temp_c - b.temp_c);
setPending(newCurve);
}}
className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-400 text-[10px] transition-colors"
>
<Plus size={10} />
Add Point
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-zinc-500">
<th className="text-left pb-1 font-normal">Temp (°C)</th>
<th className="text-left pb-1 font-normal">Fan (%)</th>
<th className="text-left pb-1 font-normal"></th>
</tr>
</thead>
<tbody>
{activeCurve.map((p, i) => (
<tr key={i} className="border-t border-zinc-800/50">
<td className="py-1 font-mono text-zinc-300">
{p.temp_c}
</td>
<td className="py-1 font-mono text-zinc-300">
{p.fan_pct}
</td>
<td className="py-1 text-right">
{activeCurve.length > 2 ? (
<button
onClick={() => removePoint(i)}
title="Remove point"
aria-label="Remove point"
className="p-1 text-zinc-600 hover:text-red-400 hover:bg-red-900/30 rounded transition-colors"
>
<X size={12} />
</button>
) : (
<span
className="text-zinc-700"
title="A curve needs at least 2 points"
>
—
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Info */}
<div className="px-4 pb-3 text-[10px] text-zinc-600">
{curveActive
? `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 · pick which fans the curve controls
above.
</span>
</div>
</div>
{confirmApply && (
<ConfirmDialog
message="Apply fan curve?"
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)}
/>
)}
{confirmReset && (
<ConfirmDialog
message="Reset fan control to automatic?"
detail="This will deactivate the fan curve and restore the GPU driver's automatic fan control."
confirmLabel="Reset"
isDestructive
onConfirm={handleReset}
onCancel={() => setConfirmReset(false)}
/>
)}
</>
);
}