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

+106 -44
View File
@@ -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"
>