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.
692 lines
24 KiB
TypeScript
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)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|