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:
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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user