From d9260007d1a9859c99b0fe13ea01a8169bbf9e44 Mon Sep 17 00:00:00 2001 From: ARIA Date: Wed, 2 Sep 2026 17:25:09 +0200 Subject: [PATCH] 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. --- .../src/components/Fans/FanCurveEditor.tsx | 276 +++++++++++++----- .../src/components/Profiles/ProfilePanel.tsx | 150 +++++++--- nvcurve/cli.py | 3 + nvcurve/config.py | 7 + nvcurve/server.py | 138 ++++++--- 5 files changed, 407 insertions(+), 167 deletions(-) diff --git a/frontend/src/components/Fans/FanCurveEditor.tsx b/frontend/src/components/Fans/FanCurveEditor.tsx index 1ddcf5b..2b99143 100644 --- a/frontend/src/components/Fans/FanCurveEditor.tsx +++ b/frontend/src/components/Fans/FanCurveEditor.tsx @@ -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) => { - 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) => { + 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 }) {
{/* Header */}
- Fan Curve + + Fan Curve + {isDefaults && ( @@ -232,7 +246,10 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) { Apply +
)} @@ -263,7 +285,8 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) { {isDefaults && (
- Default curve — edit points or apply as-is to enable curve control + Default curve — edit points or apply as-is to enable curve + control
)} @@ -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 */} - + {/* Grid lines - horizontal (fan %) */} - {fanTicks.map(f => ( + {fanTicks.map((f) => ( - + {f}% ))} {/* Grid lines - vertical (temp) */} - {tempTicks.map(t => ( + {tempTicks.map((t) => ( - + {t}° ))} {/* Axis labels */} - Temperature (°C) - Fan Speed (%) + + Temperature (°C) + + + Fan Speed (%) + {/* Curve line */} {curvePath && ( void }) { {curvePath && activeCurve && activeCurve.length >= 2 && ( )} @@ -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 && ( - { e.stopPropagation(); removePoint(i); }} > Remove point - + + {/* X glyph */} + + + )} {/* Value label */} void }) { {/* No-curve hint (only when curve is truly empty) */} {!activeCurve && ( - + Click to add points )} @@ -397,7 +492,9 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) { {activeCurve && activeCurve.length > 0 && (
- Curve Points + + Curve Points + + ) : ( + + — + )} @@ -446,10 +556,14 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) { {/* Info */}
{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."} + + Drag points to adjust · click the chart to add a point · click the ✕ + (chart or table) to remove one. +
diff --git a/frontend/src/components/Profiles/ProfilePanel.tsx b/frontend/src/components/Profiles/ProfilePanel.tsx index fc3df63..ed7f815 100644 --- a/frontend/src/components/Profiles/ProfilePanel.tsx +++ b/frontend/src/components/Profiles/ProfilePanel.tsx @@ -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([]); 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(null); @@ -28,7 +39,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr // Inline rename const [renamingName, setRenamingName] = useState(null); - const [renameValue, setRenameValue] = useState(''); + const [renameValue, setRenameValue] = useState(""); const [isRenaming, setIsRenaming] = useState(false); const renameInputRef = useRef(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" />
) : isRenaming_ ? ( -
handleRename(e, p.name)} className="flex items-center gap-2 px-3 py-2"> + handleRename(e, p.name)} + className="flex items-center gap-2 px-3 py-2" + > 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" />