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:
1 parent
9cf2009d2c
commit
d9260007d1
5 files changed
+391
-151
No files matched your search
@@ -1,10 +1,10 @@
|
||||
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';
|
||||
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 [
|
||||
@@ -35,7 +35,9 @@ function fanToY(f: 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) {
|
||||
@@ -63,13 +65,15 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
setPending(null);
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to load fan state');
|
||||
toast.error("Failed to load fan state");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchFans(); }, [selectedGpuIndex]);
|
||||
useEffect(() => {
|
||||
fetchFans();
|
||||
}, [selectedGpuIndex]);
|
||||
|
||||
const activeCurve = pending ?? fanState?.curve ?? defaultCurve();
|
||||
const hasPending = pending !== null;
|
||||
@@ -87,7 +91,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
setConfirmApply(false);
|
||||
await fetchFans();
|
||||
onChanged?.();
|
||||
toast.success('Fan curve applied');
|
||||
toast.success("Fan curve applied");
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? String(e));
|
||||
setConfirmApply(false);
|
||||
@@ -105,7 +109,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
setConfirmReset(false);
|
||||
await fetchFans();
|
||||
onChanged?.();
|
||||
toast.success('Fan control reset to automatic');
|
||||
toast.success("Fan control reset to automatic");
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? String(e));
|
||||
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 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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -146,7 +152,8 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
setDragIdx(idx);
|
||||
}, []);
|
||||
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent<SVGSVGElement>) => {
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent<SVGSVGElement>) => {
|
||||
if (dragIdx === null) return;
|
||||
e.preventDefault();
|
||||
const svg = svgRef.current;
|
||||
@@ -164,22 +171,27 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
const updated = [...curve];
|
||||
updated[dragIdx] = { temp_c: temp, fan_pct: fan };
|
||||
setPending(updated);
|
||||
}, [dragIdx, pending, fanState]);
|
||||
},
|
||||
[dragIdx, pending, fanState],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
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(() => {
|
||||
if (dragIdx === null) return;
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
return () => window.removeEventListener('pointerup', handlePointerUp);
|
||||
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(' ')
|
||||
const curvePath =
|
||||
activeCurve && activeCurve.length >= 2
|
||||
? activeCurve
|
||||
.map((p) => `${tempToX(p.temp_c)},${fanToY(p.fan_pct)}`)
|
||||
.join(" ")
|
||||
: null;
|
||||
|
||||
// Grid lines
|
||||
@@ -202,7 +214,9 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
<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>
|
||||
<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">
|
||||
@@ -232,7 +246,10 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setPending(null); setError(null); }}
|
||||
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"
|
||||
>
|
||||
@@ -254,7 +271,12 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
{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>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-2 text-red-400 hover:text-red-200"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -263,7 +285,8 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
{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
|
||||
Default curve — edit points or apply as-is to enable curve
|
||||
control
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -272,51 +295,95 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
width="100%"
|
||||
viewBox={`0 0 ${CHART_W} ${CHART_H}`}
|
||||
className="max-w-full cursor-crosshair select-none"
|
||||
style={{ touchAction: '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" />
|
||||
<rect
|
||||
x={PAD.left}
|
||||
y={PAD.top}
|
||||
width={PLOT_W}
|
||||
height={PLOT_H}
|
||||
fill="#09090b"
|
||||
rx="4"
|
||||
/>
|
||||
|
||||
{/* Grid lines - horizontal (fan %) */}
|
||||
{fanTicks.map(f => (
|
||||
{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"
|
||||
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">
|
||||
<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 => (
|
||||
{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"
|
||||
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">
|
||||
<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>
|
||||
<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'}
|
||||
stroke={hasPending ? "#22d3ee" : "#fb923c"}
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
@@ -328,7 +395,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
{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'}
|
||||
fill={hasPending ? "#22d3ee" : "#fb923c"}
|
||||
opacity="0.07"
|
||||
/>
|
||||
)}
|
||||
@@ -340,40 +407,62 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
cx={tempToX(p.temp_c)}
|
||||
cy={fanToY(p.fan_pct)}
|
||||
r="6"
|
||||
fill={hasPending ? '#22d3ee' : '#fb923c'}
|
||||
fill={hasPending ? "#22d3ee" : "#fb923c"}
|
||||
stroke="#09090b"
|
||||
strokeWidth="2"
|
||||
className="cursor-grab active:cursor-grabbing"
|
||||
style={{ touchAction: 'none' }}
|
||||
style={{ touchAction: "none" }}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePointerDown(i);
|
||||
}}
|
||||
/>
|
||||
{/* Delete button on hover */}
|
||||
{/* Remove button: faintly visible, brightens 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"
|
||||
<g
|
||||
className="cursor-pointer group/remove"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removePoint(i);
|
||||
}}
|
||||
>
|
||||
<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 */}
|
||||
<text
|
||||
x={tempToX(p.temp_c)}
|
||||
y={fanToY(p.fan_pct) - 12}
|
||||
textAnchor="middle"
|
||||
fill={hasPending ? '#22d3ee' : '#fb923c'}
|
||||
fill={hasPending ? "#22d3ee" : "#fb923c"}
|
||||
fontSize="8"
|
||||
fontFamily="monospace"
|
||||
fontWeight="600"
|
||||
@@ -386,7 +475,13 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
|
||||
{/* 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">
|
||||
<text
|
||||
x={CHART_W / 2}
|
||||
y={CHART_H / 2}
|
||||
textAnchor="middle"
|
||||
fill="#52525b"
|
||||
fontSize="11"
|
||||
>
|
||||
Click to add points
|
||||
</text>
|
||||
)}
|
||||
@@ -397,7 +492,9 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
{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>
|
||||
<span className="text-xs text-zinc-500 uppercase tracking-wider">
|
||||
Curve Points
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const curve = pending ?? fanState?.curve ?? defaultCurve();
|
||||
@@ -423,16 +520,29 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
<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 && (
|
||||
<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)}
|
||||
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>
|
||||
) : (
|
||||
<span
|
||||
className="text-zinc-700"
|
||||
title="A curve needs at least 2 points"
|
||||
>
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -446,10 +556,14 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||
{/* 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.'
|
||||
? "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.'}
|
||||
? "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.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Save, Trash2, Check, ChevronRight, Pencil, Star } from 'lucide-react';
|
||||
import { api } from '../../api/client';
|
||||
import type { ProfileData } from '../../types';
|
||||
import { toast } from 'sonner';
|
||||
import { useCurveStore } from '../../store/curveStore';
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
Save,
|
||||
Trash2,
|
||||
Check,
|
||||
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 {
|
||||
activeProfile: string | null;
|
||||
onProfileApplied: (name: string | null) => void;
|
||||
}
|
||||
|
||||
export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelProps) {
|
||||
export function ProfilePanel({
|
||||
activeProfile,
|
||||
onProfileApplied,
|
||||
}: ProfilePanelProps) {
|
||||
const { selectedGpuIndex, gpuInfo } = useCurveStore();
|
||||
const [profiles, setProfiles] = useState<ProfileData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -18,7 +29,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
||||
|
||||
// Save form
|
||||
const [isSaveOpen, setIsSaveOpen] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newName, setNewName] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const saveInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -28,7 +39,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
||||
|
||||
// Inline rename
|
||||
const [renamingName, setRenamingName] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -42,7 +53,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
||||
onProfileApplied(data.active);
|
||||
setAutoLoadProfile(data.auto_load);
|
||||
} catch {
|
||||
toast.error('Failed to load profiles');
|
||||
toast.error("Failed to load profiles");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -53,17 +64,21 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
||||
await api.setAutoLoadProfile(name, selectedGpuIndex);
|
||||
setAutoLoadProfile(name);
|
||||
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) {
|
||||
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(() => {
|
||||
if (isSaveOpen) saveInputRef.current?.focus();
|
||||
else setNewName('');
|
||||
else setNewName("");
|
||||
}, [isSaveOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -80,7 +95,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
||||
setIsSaveOpen(false);
|
||||
await fetchProfiles();
|
||||
} catch (e: any) {
|
||||
toast.error('Failed to save: ' + (e.message || String(e)));
|
||||
toast.error("Failed to save: " + (e.message || String(e)));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -109,7 +124,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
||||
setDeletingName(null);
|
||||
await fetchProfiles();
|
||||
} catch (e: any) {
|
||||
toast.error('Failed to delete: ' + (e.message || String(e)));
|
||||
toast.error("Failed to delete: " + (e.message || String(e)));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
@@ -130,7 +145,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
||||
setRenamingName(null);
|
||||
await fetchProfiles();
|
||||
} catch (e: any) {
|
||||
toast.error('Failed to rename: ' + (e.message || String(e)));
|
||||
toast.error("Failed to rename: " + (e.message || String(e)));
|
||||
} finally {
|
||||
setIsRenaming(false);
|
||||
}
|
||||
@@ -162,9 +177,9 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
||||
type="text"
|
||||
placeholder="Profile name..."
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
@@ -201,13 +216,17 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
||||
</p>
|
||||
) : (
|
||||
<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 badges = [
|
||||
pts > 0 ? `${pts} pts` : 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,
|
||||
].filter(Boolean).join(' · ');
|
||||
p.mem_offset_mhz != null
|
||||
? `${p.mem_offset_mhz > 0 ? "+" : ""}${p.mem_offset_mhz} MHz mem`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
const isActive = activeProfile === p.name;
|
||||
const isAutoLoad = autoLoadProfile === p.name;
|
||||
@@ -218,11 +237,13 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
||||
return (
|
||||
<div
|
||||
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 ? (
|
||||
<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">
|
||||
<button
|
||||
onClick={() => handleDeleteConfirm(p.name)}
|
||||
@@ -240,14 +261,19 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
||||
</div>
|
||||
</div>
|
||||
) : 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
|
||||
ref={renameInputRef}
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={e => setRenameValue(e.target.value)}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
<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 gap-2 min-w-0 pr-2">
|
||||
{isActive
|
||||
? <Check size={13} className="text-emerald-400 shrink-0" />
|
||||
: <span className="w-[13px] shrink-0" />
|
||||
}
|
||||
{isActive ? (
|
||||
<Check
|
||||
size={13}
|
||||
className="text-emerald-400 shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<span className="w-[13px] shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
{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 && (
|
||||
<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}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{badges && <p className="text-xs text-zinc-500">{badges}</p>}
|
||||
{badges && (
|
||||
<p className="text-xs text-zinc-500">{badges}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -296,21 +345,34 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
||||
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"
|
||||
>
|
||||
{isApplying
|
||||
? <span className="w-3 h-3 border border-zinc-500 border-t-emerald-400 rounded-full animate-spin" />
|
||||
: <ChevronRight size={13} />
|
||||
}
|
||||
{isApplying ? (
|
||||
<span className="w-3 h-3 border border-zinc-500 border-t-emerald-400 rounded-full animate-spin" />
|
||||
) : (
|
||||
<ChevronRight size={13} />
|
||||
)}
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSetAutoLoad(isAutoLoad ? null : p.name)}
|
||||
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'}
|
||||
onClick={() =>
|
||||
handleSetAutoLoad(isAutoLoad ? null : p.name)
|
||||
}
|
||||
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
|
||||
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"
|
||||
title="Rename"
|
||||
>
|
||||
|
||||
@@ -2092,6 +2092,9 @@ def main():
|
||||
elif "auto_load_profile" in data:
|
||||
# Migrate old single-string format — GPU 0, no UUID known at this point
|
||||
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:
|
||||
pass
|
||||
|
||||
|
||||
@@ -30,6 +30,13 @@ class Config:
|
||||
# Value = profile name (str).
|
||||
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.
|
||||
default_config = Config()
|
||||
+96
-42
@@ -237,6 +237,58 @@ async def _fan_poller(gpu_index: int) -> None:
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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")
|
||||
async def api_profiles(gpu_index: int = 0):
|
||||
"""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)
|
||||
|
||||
# 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:
|
||||
ok, msg = validate_curve(profile.fan_curve)
|
||||
if not ok:
|
||||
errs.append(f"Fan curve: {msg}")
|
||||
else:
|
||||
# Stop existing fan 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"] = profile.fan_curve
|
||||
g_state["fan_curve_active"] = True
|
||||
g_state["fan_poller_task"] = asyncio.create_task(_fan_poller(gpu_index))
|
||||
await _activate_fan_curve(gpu_index, profile.fan_curve)
|
||||
elif g_state.get("fan_curve_active"):
|
||||
# Profile has no fan curve, deactivate any active 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_poller_task"] = None
|
||||
g_state["fan_curve"] = None
|
||||
g_state["fan_curve_active"] = False
|
||||
await _run(reset_fan, gpu_index)
|
||||
await _deactivate_fan_curve(gpu_index, reset_hardware=True)
|
||||
|
||||
if not errs:
|
||||
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}"
|
||||
)
|
||||
|
||||
# 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_data
|
||||
g_state["fan_curve_active"] = True
|
||||
g_state["fan_poller_task"] = asyncio.create_task(_fan_poller(gpu_index))
|
||||
# Activate the curve (starts the poller) and persist it so it survives restarts.
|
||||
await _activate_fan_curve(gpu_index, curve_data)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
@@ -1212,21 +1276,11 @@ async def api_fans_update(req: FanCurveRequest, gpu_index: int = 0):
|
||||
@app.post("/api/fans/reset")
|
||||
async def api_fans_reset(gpu_index: int = 0):
|
||||
"""Deactivate fan curve control and restore automatic fan mode."""
|
||||
g_state = _get_gpu_state(gpu_index)
|
||||
_get_gpu_state(gpu_index)
|
||||
|
||||
# Stop poller
|
||||
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
|
||||
|
||||
ok, msg = await _run(reset_fan, gpu_index)
|
||||
if not ok:
|
||||
log.warning("Fan reset warning: %s", msg)
|
||||
# Stop the poller, clear state, restore automatic fan control, and clear
|
||||
# the persisted curve so it is not re-applied on the next server start.
|
||||
await _deactivate_fan_curve(gpu_index, reset_hardware=True)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
Reference in new issue
Block a user