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(null); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [pending, setPending] = useState(null); const [confirmApply, setConfirmApply] = useState(false); const [confirmReset, setConfirmReset] = useState(false); const [dragIdx, setDragIdx] = useState(null); // Fan selection: undefined = no pending change (follow server state), // null = all fans, list = specific fan indices. const [fanSel, setFanSel] = useState(undefined); const svgRef = useRef(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) { 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) => { 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 (
); } return ( <>
{/* Header */}
Fan Curve {isDefaults && ( Defaults )} {hasPending && ( {activeCurve?.length ?? 0} pts )} {curveActive && !hasPending && ( Active )}
{/* Fan selection */}
Fans {fanState?.num_fans === 0 ? ( No fans available on this GPU ) : ( <> {fanList.map((f) => ( ))} )} {fansChanged && ( Fans: {fanLabel(fanTargets)} )}
{/* Error banner */} {error && (
{error}
)} {/* Chart area */}
{isDefaults && (
Default curve — edit points or apply as-is to enable curve control
)} {/* Background */} {/* Grid lines - horizontal (fan %) */} {fanTicks.map((f) => ( {f}% ))} {/* Grid lines - vertical (temp) */} {tempTicks.map((t) => ( {t}° ))} {/* Axis labels */} Temperature (°C) Fan Speed (%) {/* Curve line */} {curvePath && ( )} {/* Curve fill area */} {curvePath && activeCurve && activeCurve.length >= 2 && ( )} {/* Points */} {activeCurve?.map((p, i) => ( { e.stopPropagation(); handlePointerDown(i); }} /> {/* Remove button: faintly visible, brightens on hover */} {activeCurve && activeCurve.length > 2 && ( { e.stopPropagation(); removePoint(i); }} > Remove point {/* X glyph */} )} {/* Value label */} {p.fan_pct}% ))} {/* No-curve hint (only when curve is truly empty) */} {!activeCurve && ( Click to add points )}
{/* Point table below chart */} {activeCurve && activeCurve.length > 0 && (
Curve Points
{activeCurve.map((p, i) => ( ))}
Temp (°C) Fan (%)
{p.temp_c} {p.fan_pct} {activeCurve.length > 2 ? ( ) : ( — )}
)} {/* Info */}
{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."} 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.
{confirmApply && ( setConfirmApply(false)} /> )} {confirmReset && ( setConfirmReset(false)} /> )} ); }