feat: add fan curve control via temperature-based fan speed curves

- Backend HAL (hal/fans.py): NVML v2 fan read/set/reset with min/max queries
- Server endpoints: GET/POST /api/fans, POST /api/fans/reset, POST /api/fans/speed
- Background fan poller: reads GPU temp every 2s, interpolates fan speed from curve
- Profile integration: fan_curve field saved/applied, auto-restore on shutdown
- Frontend: FanCurveEditor (SVG chart with drag/add/delete points), FanMonitor sidebar
- App.tsx: three-tab layout (Curve, Performance, Fans)
- GaugeCard: optional history sparkline, Fan Mode card without sparkline
- fan_mode field populated as 'curve' or 'auto' in GET /api/fans
This commit is contained in:
ARIA committed 2026-08-01 15:59:39 +02:00
1 parent 7605d21e27
commit 477c4becae
11 files changed
+1288 -10

No files matched your search

@@ -0,0 +1,477 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Check, X, RotateCcw, Plus } from 'lucide-react';
import { api } from '../../api/client';
import { useCurveStore } from '../../store/curveStore';
import type { FanPoint, FanState } from '../../types';
import { toast } from 'sonner';
import { ConfirmDialog } from '../common/ConfirmDialog';
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));
}
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);
const svgRef = useRef<SVGSVGElement>(null);
async function fetchFans() {
try {
setLoading(true);
const data = await api.fans(selectedGpuIndex);
setFanState(data);
if (data.curve && data.curve.length > 0) {
setPending(null);
}
} catch {
toast.error('Failed to load fan state');
} finally {
setLoading(false);
}
}
useEffect(() => { fetchFans(); }, [selectedGpuIndex]);
const activeCurve = pending ?? fanState?.curve ?? defaultCurve();
const hasPending = pending !== null;
const curveActive = fanState?.curve_active ?? false;
const isDefaults = !pending && !fanState?.curve;
async function handleApply() {
const curveToApply = pending ?? fanState?.curve ?? defaultCurve();
if (curveToApply.length < 2) return;
setBusy(true);
setError(null);
try {
await api.updateFans(curveToApply, selectedGpuIndex);
setPending(null);
setConfirmApply(false);
await fetchFans();
onChanged?.();
toast.success('Fan curve applied');
} catch (e: any) {
setError(e.message ?? String(e));
setConfirmApply(false);
} finally {
setBusy(false);
}
}
async function handleReset() {
setBusy(true);
setError(null);
try {
await api.resetFans(selectedGpuIndex);
setPending(null);
setConfirmReset(false);
await fetchFans();
onChanged?.();
toast.success('Fan control reset to automatic');
} catch (e: any) {
setError(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); setError(null); }}
disabled={!hasPending || 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>
{/* 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"
style={{ touchAction: '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"
style={{ touchAction: 'none' }}
onPointerDown={(e) => {
e.stopPropagation();
handlePointerDown(i);
}}
/>
{/* Delete button on hover */}
{activeCurve && activeCurve.length > 2 && (
<circle
cx={tempToX(p.temp_c) + 8}
cy={fanToY(p.fan_pct) - 8}
r="7"
fill="#27272a"
stroke="#3f3f46"
strokeWidth="1"
className="cursor-pointer opacity-0 hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
removePoint(i);
}}
>
<title>Remove point</title>
</circle>
)}
{/* 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">
{activeCurve.length > 2 && (
<button
onClick={() => removePoint(i)}
className="text-zinc-600 hover:text-red-400 transition-colors"
>
<X size={10} />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* 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.'
: 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.'}
</div>
</div>
{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."
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)}
/>
)}
</>
);
}