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
+407
-167
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,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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user