fix: fan settings persistence, profile indicator, and point removal

Address three fan-settings issues:

1. Profile view: show a fan icon next to a profile's name when it has a
   saved fan curve, so it's clear which profiles carry custom fans.

2. Fan curve persistence: the active fan curve was in-memory only and lost
   on every server restart. It is now persisted per-GPU in
   /etc/nvcurve/config.json (fan_curves) and re-applied at server startup,
   so an applied curve survives restarts. All fan-curve state changes route
   through _activate_fan_curve/_deactivate_fan_curve helpers that keep the
   persisted state in sync (apply, reset, and profile apply).

3. Point removal: the fan-curve remove button was nearly invisible. The
   chart remove control is now always faintly visible with an X glyph, the
   table remove button is larger with a tooltip, and a hint line explains
   how to add/remove points.

Also includes a formatting pass over the two edited frontend files.
This commit is contained in:
ARIA committed 2026-09-02 17:25:09 +02:00
1 parent 9cf2009d2c
commit d9260007d1
5 files changed
+407 -167

No files matched your search

+195 -81
View File
@@ -1,10 +1,10 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from "react";
import { Check, X, RotateCcw, Plus } from 'lucide-react'; import { Check, X, RotateCcw, Plus } from "lucide-react";
import { api } from '../../api/client'; import { api } from "../../api/client";
import { useCurveStore } from '../../store/curveStore'; import { useCurveStore } from "../../store/curveStore";
import type { FanPoint, FanState } from '../../types'; import type { FanPoint, FanState } from "../../types";
import { toast } from 'sonner'; import { toast } from "sonner";
import { ConfirmDialog } from '../common/ConfirmDialog'; import { ConfirmDialog } from "../common/ConfirmDialog";
function defaultCurve(): FanPoint[] { function defaultCurve(): FanPoint[] {
return [ return [
@@ -35,7 +35,9 @@ function fanToY(f: number) {
} }
function xToTemp(x: number) { function xToTemp(x: number) {
return Math.round(TEMP_MIN + ((x - PAD.left) / PLOT_W) * (TEMP_MAX - TEMP_MIN)); return Math.round(
TEMP_MIN + ((x - PAD.left) / PLOT_W) * (TEMP_MAX - TEMP_MIN),
);
} }
function yToFan(y: number) { function yToFan(y: number) {
@@ -63,13 +65,15 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
setPending(null); setPending(null);
} }
} catch { } catch {
toast.error('Failed to load fan state'); toast.error("Failed to load fan state");
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
useEffect(() => { fetchFans(); }, [selectedGpuIndex]); useEffect(() => {
fetchFans();
}, [selectedGpuIndex]);
const activeCurve = pending ?? fanState?.curve ?? defaultCurve(); const activeCurve = pending ?? fanState?.curve ?? defaultCurve();
const hasPending = pending !== null; const hasPending = pending !== null;
@@ -87,7 +91,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
setConfirmApply(false); setConfirmApply(false);
await fetchFans(); await fetchFans();
onChanged?.(); onChanged?.();
toast.success('Fan curve applied'); toast.success("Fan curve applied");
} catch (e: any) { } catch (e: any) {
setError(e.message ?? String(e)); setError(e.message ?? String(e));
setConfirmApply(false); setConfirmApply(false);
@@ -105,7 +109,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
setConfirmReset(false); setConfirmReset(false);
await fetchFans(); await fetchFans();
onChanged?.(); onChanged?.();
toast.success('Fan control reset to automatic'); toast.success("Fan control reset to automatic");
} catch (e: any) { } catch (e: any) {
setError(e.message ?? String(e)); setError(e.message ?? String(e));
setConfirmReset(false); setConfirmReset(false);
@@ -128,10 +132,12 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
const fan = Math.max(FAN_MIN, Math.min(FAN_MAX, yToFan(y))); const fan = Math.max(FAN_MIN, Math.min(FAN_MAX, yToFan(y)));
const baseCurve = pending ?? fanState?.curve ?? defaultCurve(); const baseCurve = pending ?? fanState?.curve ?? defaultCurve();
const existing = baseCurve.findIndex(p => p.temp_c === temp); const existing = baseCurve.findIndex((p) => p.temp_c === temp);
if (existing >= 0) return; if (existing >= 0) return;
const updated = [...baseCurve, { temp_c: temp, fan_pct: fan }].sort((a, b) => a.temp_c - b.temp_c); const updated = [...baseCurve, { temp_c: temp, fan_pct: fan }].sort(
(a, b) => a.temp_c - b.temp_c,
);
setPending(updated); setPending(updated);
} }
@@ -146,41 +152,47 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
setDragIdx(idx); setDragIdx(idx);
}, []); }, []);
const handlePointerMove = useCallback((e: React.PointerEvent<SVGSVGElement>) => { const handlePointerMove = useCallback(
if (dragIdx === null) return; (e: React.PointerEvent<SVGSVGElement>) => {
e.preventDefault(); if (dragIdx === null) return;
const svg = svgRef.current; e.preventDefault();
if (!svg) return; const svg = svgRef.current;
const rect = svg.getBoundingClientRect(); if (!svg) return;
const scaleX = CHART_W / rect.width; const rect = svg.getBoundingClientRect();
const scaleY = CHART_H / rect.height; const scaleX = CHART_W / rect.width;
const x = (e.clientX - rect.left) * scaleX; const scaleY = CHART_H / rect.height;
const y = (e.clientY - rect.top) * scaleY; 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 temp = Math.max(TEMP_MIN, Math.min(TEMP_MAX, xToTemp(x)));
const fan = Math.max(FAN_MIN, Math.min(FAN_MAX, yToFan(y))); const fan = Math.max(FAN_MIN, Math.min(FAN_MAX, yToFan(y)));
const curve = pending ?? fanState?.curve ?? defaultCurve(); const curve = pending ?? fanState?.curve ?? defaultCurve();
const updated = [...curve]; const updated = [...curve];
updated[dragIdx] = { temp_c: temp, fan_pct: fan }; updated[dragIdx] = { temp_c: temp, fan_pct: fan };
setPending(updated); setPending(updated);
}, [dragIdx, pending, fanState]); },
[dragIdx, pending, fanState],
);
const handlePointerUp = useCallback(() => { const handlePointerUp = useCallback(() => {
setDragIdx(null); setDragIdx(null);
setPending((p) => p ? [...p].sort((a, b) => a.temp_c - b.temp_c) : p); setPending((p) => (p ? [...p].sort((a, b) => a.temp_c - b.temp_c) : p));
}, []); }, []);
useEffect(() => { useEffect(() => {
if (dragIdx === null) return; if (dragIdx === null) return;
window.addEventListener('pointerup', handlePointerUp); window.addEventListener("pointerup", handlePointerUp);
return () => window.removeEventListener('pointerup', handlePointerUp); return () => window.removeEventListener("pointerup", handlePointerUp);
}, [dragIdx, handlePointerUp]); }, [dragIdx, handlePointerUp]);
// Build polyline path from curve points // Build polyline path from curve points
const curvePath = activeCurve && activeCurve.length >= 2 const curvePath =
? activeCurve.map((p) => `${tempToX(p.temp_c)},${fanToY(p.fan_pct)}`).join(' ') activeCurve && activeCurve.length >= 2
: null; ? activeCurve
.map((p) => `${tempToX(p.temp_c)},${fanToY(p.fan_pct)}`)
.join(" ")
: null;
// Grid lines // Grid lines
const tempTicks = [30, 40, 50, 60, 70, 80, 90]; const tempTicks = [30, 40, 50, 60, 70, 80, 90];
@@ -202,7 +214,9 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col"> <div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
{/* Header */} {/* Header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 shrink-0"> <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> <span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
Fan Curve
</span>
{isDefaults && ( {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"> <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">
@@ -232,7 +246,10 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
Apply Apply
</button> </button>
<button <button
onClick={() => { setPending(null); setError(null); }} onClick={() => {
setPending(null);
setError(null);
}}
disabled={!hasPending || busy} 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" 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"
> >
@@ -254,7 +271,12 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
{error && ( {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"> <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> <span>{error}</span>
<button onClick={() => setError(null)} className="ml-2 text-red-400 hover:text-red-200">x</button> <button
onClick={() => setError(null)}
className="ml-2 text-red-400 hover:text-red-200"
>
x
</button>
</div> </div>
)} )}
@@ -263,7 +285,8 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
{isDefaults && ( {isDefaults && (
<div className="absolute inset-x-4 top-4 z-10 pointer-events-none text-center"> <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"> <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 Default curve — edit points or apply as-is to enable curve
control
</span> </span>
</div> </div>
)} )}
@@ -272,51 +295,95 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
width="100%" width="100%"
viewBox={`0 0 ${CHART_W} ${CHART_H}`} viewBox={`0 0 ${CHART_W} ${CHART_H}`}
className="max-w-full cursor-crosshair select-none" className="max-w-full cursor-crosshair select-none"
style={{ touchAction: 'none' }} style={{ touchAction: "none" }}
onClick={handleCanvasClick} onClick={handleCanvasClick}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
> >
{/* Background */} {/* Background */}
<rect x={PAD.left} y={PAD.top} width={PLOT_W} height={PLOT_H} fill="#09090b" rx="4" /> <rect
x={PAD.left}
y={PAD.top}
width={PLOT_W}
height={PLOT_H}
fill="#09090b"
rx="4"
/>
{/* Grid lines - horizontal (fan %) */} {/* Grid lines - horizontal (fan %) */}
{fanTicks.map(f => ( {fanTicks.map((f) => (
<g key={`fy-${f}`}> <g key={`fy-${f}`}>
<line <line
x1={PAD.left} y1={fanToY(f)} x1={PAD.left}
x2={PAD.left + PLOT_W} y2={fanToY(f)} y1={fanToY(f)}
stroke="#27272a" strokeWidth="0.5" 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"> <text
x={PAD.left - 6}
y={fanToY(f) + 3}
textAnchor="end"
fill="#71717a"
fontSize="9"
fontFamily="monospace"
>
{f}% {f}%
</text> </text>
</g> </g>
))} ))}
{/* Grid lines - vertical (temp) */} {/* Grid lines - vertical (temp) */}
{tempTicks.map(t => ( {tempTicks.map((t) => (
<g key={`tx-${t}`}> <g key={`tx-${t}`}>
<line <line
x1={tempToX(t)} y1={PAD.top} x1={tempToX(t)}
x2={tempToX(t)} y2={PAD.top + PLOT_H} y1={PAD.top}
stroke="#27272a" strokeWidth="0.5" 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"> <text
x={tempToX(t)}
y={PAD.top + PLOT_H + 16}
textAnchor="middle"
fill="#71717a"
fontSize="9"
fontFamily="monospace"
>
{t}° {t}°
</text> </text>
</g> </g>
))} ))}
{/* Axis labels */} {/* Axis labels */}
<text x={PAD.left + PLOT_W / 2} y={CHART_H - 2} textAnchor="middle" fill="#52525b" fontSize="9">Temperature (°C)</text> <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> 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 */} {/* Curve line */}
{curvePath && ( {curvePath && (
<polyline <polyline
points={curvePath} points={curvePath}
fill="none" fill="none"
stroke={hasPending ? '#22d3ee' : '#fb923c'} stroke={hasPending ? "#22d3ee" : "#fb923c"}
strokeWidth="2" strokeWidth="2"
strokeLinejoin="round" strokeLinejoin="round"
strokeLinecap="round" strokeLinecap="round"
@@ -328,7 +395,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
{curvePath && activeCurve && activeCurve.length >= 2 && ( {curvePath && activeCurve && activeCurve.length >= 2 && (
<polygon <polygon
points={`${tempToX(activeCurve[0].temp_c)},${PAD.top + PLOT_H} ${curvePath} ${tempToX(activeCurve[activeCurve.length - 1].temp_c)},${PAD.top + PLOT_H}`} 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'} fill={hasPending ? "#22d3ee" : "#fb923c"}
opacity="0.07" opacity="0.07"
/> />
)} )}
@@ -340,40 +407,62 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
cx={tempToX(p.temp_c)} cx={tempToX(p.temp_c)}
cy={fanToY(p.fan_pct)} cy={fanToY(p.fan_pct)}
r="6" r="6"
fill={hasPending ? '#22d3ee' : '#fb923c'} fill={hasPending ? "#22d3ee" : "#fb923c"}
stroke="#09090b" stroke="#09090b"
strokeWidth="2" strokeWidth="2"
className="cursor-grab active:cursor-grabbing" className="cursor-grab active:cursor-grabbing"
style={{ touchAction: 'none' }} style={{ touchAction: "none" }}
onPointerDown={(e) => { onPointerDown={(e) => {
e.stopPropagation(); e.stopPropagation();
handlePointerDown(i); handlePointerDown(i);
}} }}
/> />
{/* Delete button on hover */} {/* Remove button: faintly visible, brightens on hover */}
{activeCurve && activeCurve.length > 2 && ( {activeCurve && activeCurve.length > 2 && (
<circle <g
cx={tempToX(p.temp_c) + 8} className="cursor-pointer group/remove"
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) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
removePoint(i); removePoint(i);
}} }}
> >
<title>Remove point</title> <title>Remove point</title>
</circle> <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 */} {/* Value label */}
<text <text
x={tempToX(p.temp_c)} x={tempToX(p.temp_c)}
y={fanToY(p.fan_pct) - 12} y={fanToY(p.fan_pct) - 12}
textAnchor="middle" textAnchor="middle"
fill={hasPending ? '#22d3ee' : '#fb923c'} fill={hasPending ? "#22d3ee" : "#fb923c"}
fontSize="8" fontSize="8"
fontFamily="monospace" fontFamily="monospace"
fontWeight="600" fontWeight="600"
@@ -386,7 +475,13 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
{/* No-curve hint (only when curve is truly empty) */} {/* No-curve hint (only when curve is truly empty) */}
{!activeCurve && ( {!activeCurve && (
<text x={CHART_W / 2} y={CHART_H / 2} textAnchor="middle" fill="#52525b" fontSize="11"> <text
x={CHART_W / 2}
y={CHART_H / 2}
textAnchor="middle"
fill="#52525b"
fontSize="11"
>
Click to add points Click to add points
</text> </text>
)} )}
@@ -397,7 +492,9 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
{activeCurve && activeCurve.length > 0 && ( {activeCurve && activeCurve.length > 0 && (
<div className="px-4 pb-3"> <div className="px-4 pb-3">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs text-zinc-500 uppercase tracking-wider">Curve Points</span> <span className="text-xs text-zinc-500 uppercase tracking-wider">
Curve Points
</span>
<button <button
onClick={() => { onClick={() => {
const curve = pending ?? fanState?.curve ?? defaultCurve(); const curve = pending ?? fanState?.curve ?? defaultCurve();
@@ -423,16 +520,29 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
<tbody> <tbody>
{activeCurve.map((p, i) => ( {activeCurve.map((p, i) => (
<tr key={i} className="border-t border-zinc-800/50"> <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">
<td className="py-1 font-mono text-zinc-300">{p.fan_pct}</td> {p.temp_c}
<td className="py-1"> </td>
{activeCurve.length > 2 && ( <td className="py-1 font-mono text-zinc-300">
{p.fan_pct}
</td>
<td className="py-1 text-right">
{activeCurve.length > 2 ? (
<button <button
onClick={() => removePoint(i)} onClick={() => removePoint(i)}
className="text-zinc-600 hover:text-red-400 transition-colors" 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={10} /> <X size={12} />
</button> </button>
) : (
<span
className="text-zinc-700"
title="A curve needs at least 2 points"
>
—
</span>
)} )}
</td> </td>
</tr> </tr>
@@ -446,10 +556,14 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
{/* Info */} {/* Info */}
<div className="px-4 pb-3 text-[10px] text-zinc-600"> <div className="px-4 pb-3 text-[10px] text-zinc-600">
{curveActive {curveActive
? 'Fan curve is active. Server adjusts fan speed based on GPU temperature.' ? "Fan curve is active. Server adjusts fan speed based on GPU temperature."
: isDefaults : isDefaults
? 'These are default values. Click Apply to enable curve control, or edit points first.' ? "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.'} : "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.
</span>
</div> </div>
</div> </div>
+106 -44
View File
@@ -1,16 +1,27 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from "react";
import { Save, Trash2, Check, ChevronRight, Pencil, Star } from 'lucide-react'; import {
import { api } from '../../api/client'; Save,
import type { ProfileData } from '../../types'; Trash2,
import { toast } from 'sonner'; Check,
import { useCurveStore } from '../../store/curveStore'; ChevronRight,
Pencil,
Star,
Fan,
} from "lucide-react";
import { api } from "../../api/client";
import type { ProfileData } from "../../types";
import { toast } from "sonner";
import { useCurveStore } from "../../store/curveStore";
interface ProfilePanelProps { interface ProfilePanelProps {
activeProfile: string | null; activeProfile: string | null;
onProfileApplied: (name: string | null) => void; onProfileApplied: (name: string | null) => void;
} }
export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelProps) { export function ProfilePanel({
activeProfile,
onProfileApplied,
}: ProfilePanelProps) {
const { selectedGpuIndex, gpuInfo } = useCurveStore(); const { selectedGpuIndex, gpuInfo } = useCurveStore();
const [profiles, setProfiles] = useState<ProfileData[]>([]); const [profiles, setProfiles] = useState<ProfileData[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -18,7 +29,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
// Save form // Save form
const [isSaveOpen, setIsSaveOpen] = useState(false); const [isSaveOpen, setIsSaveOpen] = useState(false);
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState("");
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const saveInputRef = useRef<HTMLInputElement>(null); const saveInputRef = useRef<HTMLInputElement>(null);
@@ -28,7 +39,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
// Inline rename // Inline rename
const [renamingName, setRenamingName] = useState<string | null>(null); const [renamingName, setRenamingName] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState(''); const [renameValue, setRenameValue] = useState("");
const [isRenaming, setIsRenaming] = useState(false); const [isRenaming, setIsRenaming] = useState(false);
const renameInputRef = useRef<HTMLInputElement>(null); const renameInputRef = useRef<HTMLInputElement>(null);
@@ -42,7 +53,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
onProfileApplied(data.active); onProfileApplied(data.active);
setAutoLoadProfile(data.auto_load); setAutoLoadProfile(data.auto_load);
} catch { } catch {
toast.error('Failed to load profiles'); toast.error("Failed to load profiles");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -53,17 +64,21 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
await api.setAutoLoadProfile(name, selectedGpuIndex); await api.setAutoLoadProfile(name, selectedGpuIndex);
setAutoLoadProfile(name); setAutoLoadProfile(name);
if (name) toast.success(`"${name}" will load on server start`); if (name) toast.success(`"${name}" will load on server start`);
else toast.success('Auto-load cleared'); else toast.success("Auto-load cleared");
} catch (e: any) { } catch (e: any) {
toast.error('Failed to update default profile: ' + (e.message || String(e))); toast.error(
"Failed to update default profile: " + (e.message || String(e)),
);
} }
} }
useEffect(() => { fetchProfiles(); }, [selectedGpuIndex]); useEffect(() => {
fetchProfiles();
}, [selectedGpuIndex]);
useEffect(() => { useEffect(() => {
if (isSaveOpen) saveInputRef.current?.focus(); if (isSaveOpen) saveInputRef.current?.focus();
else setNewName(''); else setNewName("");
}, [isSaveOpen]); }, [isSaveOpen]);
useEffect(() => { useEffect(() => {
@@ -80,7 +95,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
setIsSaveOpen(false); setIsSaveOpen(false);
await fetchProfiles(); await fetchProfiles();
} catch (e: any) { } catch (e: any) {
toast.error('Failed to save: ' + (e.message || String(e))); toast.error("Failed to save: " + (e.message || String(e)));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -109,7 +124,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
setDeletingName(null); setDeletingName(null);
await fetchProfiles(); await fetchProfiles();
} catch (e: any) { } catch (e: any) {
toast.error('Failed to delete: ' + (e.message || String(e))); toast.error("Failed to delete: " + (e.message || String(e)));
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
} }
@@ -130,7 +145,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
setRenamingName(null); setRenamingName(null);
await fetchProfiles(); await fetchProfiles();
} catch (e: any) { } catch (e: any) {
toast.error('Failed to rename: ' + (e.message || String(e))); toast.error("Failed to rename: " + (e.message || String(e)));
} finally { } finally {
setIsRenaming(false); setIsRenaming(false);
} }
@@ -162,9 +177,9 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
type="text" type="text"
placeholder="Profile name..." placeholder="Profile name..."
value={newName} value={newName}
onChange={e => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
disabled={isSaving} disabled={isSaving}
onKeyDown={e => e.key === 'Escape' && setIsSaveOpen(false)} onKeyDown={(e) => e.key === "Escape" && setIsSaveOpen(false)}
className="flex-1 min-w-0 bg-zinc-950 border border-zinc-700 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 disabled:opacity-50" className="flex-1 min-w-0 bg-zinc-950 border border-zinc-700 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 disabled:opacity-50"
/> />
<button <button
@@ -201,13 +216,17 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
</p> </p>
) : ( ) : (
<div className="flex flex-col gap-0.5 px-2"> <div className="flex flex-col gap-0.5 px-2">
{profiles.map(p => { {profiles.map((p) => {
const pts = Object.keys(p.curve_deltas).length; const pts = Object.keys(p.curve_deltas).length;
const badges = [ const badges = [
pts > 0 ? `${pts} pts` : null, pts > 0 ? `${pts} pts` : null,
p.power_limit_w != null ? `${p.power_limit_w}W` : null, p.power_limit_w != null ? `${p.power_limit_w}W` : null,
p.mem_offset_mhz != null ? `${p.mem_offset_mhz > 0 ? '+' : ''}${p.mem_offset_mhz} MHz mem` : null, p.mem_offset_mhz != null
].filter(Boolean).join(' · '); ? `${p.mem_offset_mhz > 0 ? "+" : ""}${p.mem_offset_mhz} MHz mem`
: null,
]
.filter(Boolean)
.join(" · ");
const isActive = activeProfile === p.name; const isActive = activeProfile === p.name;
const isAutoLoad = autoLoadProfile === p.name; const isAutoLoad = autoLoadProfile === p.name;
@@ -218,11 +237,13 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
return ( return (
<div <div
key={p.name} key={p.name}
className={`group rounded-md transition ${isActive ? 'bg-zinc-800/60' : 'hover:bg-zinc-800/50'}`} className={`group rounded-md transition ${isActive ? "bg-zinc-800/60" : "hover:bg-zinc-800/50"}`}
> >
{isConfirmingDelete ? ( {isConfirmingDelete ? (
<div className="flex items-center justify-between px-3 py-2 gap-2"> <div className="flex items-center justify-between px-3 py-2 gap-2">
<span className="text-sm text-zinc-300 truncate min-w-0">Delete "{p.name}"?</span> <span className="text-sm text-zinc-300 truncate min-w-0">
Delete "{p.name}"?
</span>
<div className="flex gap-1.5 shrink-0"> <div className="flex gap-1.5 shrink-0">
<button <button
onClick={() => handleDeleteConfirm(p.name)} onClick={() => handleDeleteConfirm(p.name)}
@@ -240,14 +261,19 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
</div> </div>
</div> </div>
) : isRenaming_ ? ( ) : isRenaming_ ? (
<form onSubmit={e => handleRename(e, p.name)} className="flex items-center gap-2 px-3 py-2"> <form
onSubmit={(e) => handleRename(e, p.name)}
className="flex items-center gap-2 px-3 py-2"
>
<input <input
ref={renameInputRef} ref={renameInputRef}
type="text" type="text"
value={renameValue} value={renameValue}
onChange={e => setRenameValue(e.target.value)} onChange={(e) => setRenameValue(e.target.value)}
disabled={isRenaming} disabled={isRenaming}
onKeyDown={e => e.key === 'Escape' && setRenamingName(null)} onKeyDown={(e) =>
e.key === "Escape" && setRenamingName(null)
}
className="flex-1 min-w-0 bg-zinc-950 border border-zinc-600 rounded px-2 py-1 text-sm focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 disabled:opacity-50" className="flex-1 min-w-0 bg-zinc-950 border border-zinc-600 rounded px-2 py-1 text-sm focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 disabled:opacity-50"
/> />
<button <button
@@ -268,25 +294,48 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
) : ( ) : (
<div className="flex items-center justify-between px-3 py-2"> <div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2 min-w-0 pr-2"> <div className="flex items-center gap-2 min-w-0 pr-2">
{isActive {isActive ? (
? <Check size={13} className="text-emerald-400 shrink-0" /> <Check
: <span className="w-[13px] shrink-0" /> size={13}
} className="text-emerald-400 shrink-0"
/>
) : (
<span className="w-[13px] shrink-0" />
)}
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<p className={`text-sm font-medium truncate ${isActive ? 'text-zinc-100' : 'text-zinc-300'}`}> <p
className={`text-sm font-medium truncate ${isActive ? "text-zinc-100" : "text-zinc-300"}`}
>
{p.name} {p.name}
</p> </p>
{isAutoLoad && ( {isAutoLoad && (
<Star size={11} className="text-sky-400 shrink-0" fill="currentColor" /> <Star
size={11}
className="text-sky-400 shrink-0"
fill="currentColor"
/>
)}
{p.fan_curve && p.fan_curve.length > 0 && (
<span
className="shrink-0 text-cyan-400"
title="Custom fan curve saved"
>
<Fan size={11} />
</span>
)} )}
{gpuInfo && p.gpu_name !== gpuInfo.name && ( {gpuInfo && p.gpu_name !== gpuInfo.name && (
<span className="text-xs text-zinc-600 truncate shrink-0" title={`Saved from ${p.gpu_name}`}> <span
className="text-xs text-zinc-600 truncate shrink-0"
title={`Saved from ${p.gpu_name}`}
>
{p.gpu_name} {p.gpu_name}
</span> </span>
)} )}
</div> </div>
{badges && <p className="text-xs text-zinc-500">{badges}</p>} {badges && (
<p className="text-xs text-zinc-500">{badges}</p>
)}
</div> </div>
</div> </div>
@@ -296,21 +345,34 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
disabled={isApplying} disabled={isApplying}
className="flex items-center gap-1 px-2 py-1 text-xs rounded text-zinc-400 hover:text-emerald-400 hover:bg-zinc-700 transition disabled:opacity-50 font-medium" className="flex items-center gap-1 px-2 py-1 text-xs rounded text-zinc-400 hover:text-emerald-400 hover:bg-zinc-700 transition disabled:opacity-50 font-medium"
> >
{isApplying {isApplying ? (
? <span className="w-3 h-3 border border-zinc-500 border-t-emerald-400 rounded-full animate-spin" /> <span className="w-3 h-3 border border-zinc-500 border-t-emerald-400 rounded-full animate-spin" />
: <ChevronRight size={13} /> ) : (
} <ChevronRight size={13} />
)}
Apply Apply
</button> </button>
<button <button
onClick={() => handleSetAutoLoad(isAutoLoad ? null : p.name)} onClick={() =>
className={`p-1.5 rounded transition hover:bg-zinc-700 ${isAutoLoad ? 'text-sky-400 hover:text-sky-300' : 'text-zinc-600 hover:text-sky-400'}`} handleSetAutoLoad(isAutoLoad ? null : p.name)
title={isAutoLoad ? 'Clear default profile' : 'Set as default profile'} }
className={`p-1.5 rounded transition hover:bg-zinc-700 ${isAutoLoad ? "text-sky-400 hover:text-sky-300" : "text-zinc-600 hover:text-sky-400"}`}
title={
isAutoLoad
? "Clear default profile"
: "Set as default profile"
}
> >
<Star size={13} fill={isAutoLoad ? 'currentColor' : 'none'} /> <Star
size={13}
fill={isAutoLoad ? "currentColor" : "none"}
/>
</button> </button>
<button <button
onClick={() => { setRenamingName(p.name); setRenameValue(p.name); }} onClick={() => {
setRenamingName(p.name);
setRenameValue(p.name);
}}
className="p-1.5 text-zinc-600 hover:text-zinc-300 hover:bg-zinc-700 rounded transition" className="p-1.5 text-zinc-600 hover:text-zinc-300 hover:bg-zinc-700 rounded transition"
title="Rename" title="Rename"
> >
+3
View File
@@ -2092,6 +2092,9 @@ def main():
elif "auto_load_profile" in data: elif "auto_load_profile" in data:
# Migrate old single-string format — GPU 0, no UUID known at this point # Migrate old single-string format — GPU 0, no UUID known at this point
cfg.auto_load_profiles = {"idx:0": data["auto_load_profile"]} cfg.auto_load_profiles = {"idx:0": data["auto_load_profile"]}
if "fan_curves" in data:
# Per-GPU active fan curves, restored on server startup.
cfg.fan_curves = dict(data["fan_curves"])
except Exception: except Exception:
pass pass
+7
View File
@@ -30,6 +30,13 @@ class Config:
# Value = profile name (str). # Value = profile name (str).
auto_load_profiles: dict[str, str] = field(default_factory=dict) auto_load_profiles: dict[str, str] = field(default_factory=dict)
# Per-GPU active fan curves: restored automatically on server startup so a
# curve applied via the UI survives server restarts (fan control itself is
# volatile — the driver reverts to automatic mode on reboot).
# Key = stable GPU identifier (same as auto_load_profiles).
# Value = list of {"temp_c": int, "fan_pct": int} sorted by temp_c.
fan_curves: dict[str, list] = field(default_factory=dict)
# Module-level default config instance. # Module-level default config instance.
default_config = Config() default_config = Config()
+96 -42
View File
@@ -237,6 +237,58 @@ async def _fan_poller(gpu_index: int) -> None:
await asyncio.sleep(2.0) await asyncio.sleep(2.0)
async def _activate_fan_curve(gpu_index: int, curve: list) -> None:
"""Set the active fan curve, (re)start the poller, and persist it.
Persistence (config.json) is what makes the curve survive server restarts:
fan control is volatile, so the driver reverts to automatic mode on reboot
and the saved curve is re-applied at the next server start.
"""
g_state = _get_gpu_state(gpu_index)
# Stop existing poller if running
if g_state.get("fan_poller_task"):
g_state["fan_poller_task"].cancel()
with suppress(asyncio.CancelledError):
await g_state["fan_poller_task"]
g_state["fan_curve"] = curve
g_state["fan_curve_active"] = True
g_state["fan_poller_task"] = asyncio.create_task(_fan_poller(gpu_index))
cfg: Config = _state["config"]
cfg.fan_curves[_gpu_stable_key(gpu_index)] = curve
_persist_fan_curves(cfg.fan_curves)
async def _deactivate_fan_curve(gpu_index: int, reset_hardware: bool = True) -> None:
"""Clear the active fan curve, stop the poller, and clear its persistence.
When reset_hardware is True the GPU is returned to automatic fan control.
"""
g_state = _get_gpu_state(gpu_index)
if g_state.get("fan_poller_task"):
g_state["fan_poller_task"].cancel()
with suppress(asyncio.CancelledError):
await g_state["fan_poller_task"]
g_state["fan_poller_task"] = None
g_state["fan_curve"] = None
g_state["fan_curve_active"] = False
if reset_hardware:
ok, msg = await _run(reset_fan, gpu_index)
if not ok:
log.warning("Fan reset warning: %s", msg)
cfg: Config = _state["config"]
key = _gpu_stable_key(gpu_index)
if key in cfg.fan_curves:
del cfg.fan_curves[key]
_persist_fan_curves(cfg.fan_curves)
# ── Lifespan ────────────────────────────────────────────────────────────────── # ── Lifespan ──────────────────────────────────────────────────────────────────
@@ -326,6 +378,35 @@ async def lifespan(app: FastAPI):
) )
# ────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────
# ── Restore persisted fan curves ──────────────────────────────────────────
# Fan control is volatile: the driver reverts to automatic mode on reboot,
# so a curve applied via the UI is persisted in config.json and re-applied
# here at startup. Runs after the auto-load profile path so the user's
# explicit fan curve setting takes precedence.
for gpu_index, g_state in _state["gpus"].items():
if g_state["gpu"] is None:
continue
if g_state.get("fan_curve_active"):
continue # already activated by the auto-load profile path
key = _gpu_stable_key(gpu_index)
curve = cfg.fan_curves.get(key)
if not curve:
continue
ok, msg = validate_curve(curve)
if not ok:
log.warning("Skipping persisted fan curve for GPU %d: %s", gpu_index, msg)
continue
log.info("Restoring persisted fan curve on GPU %d (%s)", gpu_index, key)
try:
await _activate_fan_curve(gpu_index, curve)
except Exception as exc:
log.warning(
"Failed to restore persisted fan curve on GPU %d: %s",
gpu_index,
exc,
)
# ──────────────────────────────────────────────────────────────────────────
yield # server is running yield # server is running
for task in poller_tasks: for task in poller_tasks:
@@ -745,6 +826,11 @@ def _persist_auto_load_profiles(profiles: dict[str, str]) -> None:
_persist_config_field("auto_load_profiles", profiles if profiles else None) _persist_config_field("auto_load_profiles", profiles if profiles else None)
def _persist_fan_curves(fan_curves: dict) -> None:
"""Persist the per-GPU active fan curves dict to config.json."""
_persist_config_field("fan_curves", fan_curves if fan_curves else None)
@app.get("/api/profiles") @app.get("/api/profiles")
async def api_profiles(gpu_index: int = 0): async def api_profiles(gpu_index: int = 0):
"""List saved native profiles, the active profile name, and the auto-load profile name.""" """List saved native profiles, the active profile name, and the auto-load profile name."""
@@ -931,31 +1017,16 @@ async def _apply_profile(name: str, gpu_index: int = 0) -> list[str]:
await _update_offsets_and_broadcast(gpu_index) await _update_offsets_and_broadcast(gpu_index)
# Apply fan curve if present in profile # Apply fan curve if present in profile (persists it so it survives restarts);
# otherwise deactivate any active fan curve (and clear its persistence).
if profile.fan_curve: if profile.fan_curve:
ok, msg = validate_curve(profile.fan_curve) ok, msg = validate_curve(profile.fan_curve)
if not ok: if not ok:
errs.append(f"Fan curve: {msg}") errs.append(f"Fan curve: {msg}")
else: else:
# Stop existing fan poller if running await _activate_fan_curve(gpu_index, profile.fan_curve)
if g_state.get("fan_poller_task"):
g_state["fan_poller_task"].cancel()
with suppress(asyncio.CancelledError):
await g_state["fan_poller_task"]
g_state["fan_curve"] = profile.fan_curve
g_state["fan_curve_active"] = True
g_state["fan_poller_task"] = asyncio.create_task(_fan_poller(gpu_index))
elif g_state.get("fan_curve_active"): elif g_state.get("fan_curve_active"):
# Profile has no fan curve, deactivate any active fan curve await _deactivate_fan_curve(gpu_index, reset_hardware=True)
if g_state.get("fan_poller_task"):
g_state["fan_poller_task"].cancel()
with suppress(asyncio.CancelledError):
await g_state["fan_poller_task"]
g_state["fan_poller_task"] = None
g_state["fan_curve"] = None
g_state["fan_curve_active"] = False
await _run(reset_fan, gpu_index)
if not errs: if not errs:
g_state["active_profile"] = name g_state["active_profile"] = name
@@ -1196,15 +1267,8 @@ async def api_fans_update(req: FanCurveRequest, gpu_index: int = 0):
status_code=500, detail=f"Fan control not available: {fan_msg}" status_code=500, detail=f"Fan control not available: {fan_msg}"
) )
# Stop existing poller if running # Activate the curve (starts the poller) and persist it so it survives restarts.
if g_state.get("fan_poller_task"): await _activate_fan_curve(gpu_index, curve_data)
g_state["fan_poller_task"].cancel()
with suppress(asyncio.CancelledError):
await g_state["fan_poller_task"]
g_state["fan_curve"] = curve_data
g_state["fan_curve_active"] = True
g_state["fan_poller_task"] = asyncio.create_task(_fan_poller(gpu_index))
return {"ok": True} return {"ok": True}
@@ -1212,21 +1276,11 @@ async def api_fans_update(req: FanCurveRequest, gpu_index: int = 0):
@app.post("/api/fans/reset") @app.post("/api/fans/reset")
async def api_fans_reset(gpu_index: int = 0): async def api_fans_reset(gpu_index: int = 0):
"""Deactivate fan curve control and restore automatic fan mode.""" """Deactivate fan curve control and restore automatic fan mode."""
g_state = _get_gpu_state(gpu_index) _get_gpu_state(gpu_index)
# Stop poller # Stop the poller, clear state, restore automatic fan control, and clear
if g_state.get("fan_poller_task"): # the persisted curve so it is not re-applied on the next server start.
g_state["fan_poller_task"].cancel() await _deactivate_fan_curve(gpu_index, reset_hardware=True)
with suppress(asyncio.CancelledError):
await g_state["fan_poller_task"]
g_state["fan_poller_task"] = None
g_state["fan_curve"] = None
g_state["fan_curve_active"] = False
ok, msg = await _run(reset_fan, gpu_index)
if not ok:
log.warning("Fan reset warning: %s", msg)
return {"ok": True} return {"ok": True}