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 { 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,41 +152,47 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
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 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 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 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);
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(' ')
: null;
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];
@@ -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>