Merge pull request 'fix: fan settings persistence, profile indicator, and point removal' (#6) from fix/fan-settings into main
Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
commit
06db52e8ac
5 files changed
+407
-167
No files matched your search
@@ -1,10 +1,10 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { Check, X, RotateCcw, Plus } from 'lucide-react';
|
import { Check, X, RotateCcw, Plus } from "lucide-react";
|
||||||
import { api } from '../../api/client';
|
import { api } from "../../api/client";
|
||||||
import { useCurveStore } from '../../store/curveStore';
|
import { useCurveStore } from "../../store/curveStore";
|
||||||
import type { FanPoint, FanState } from '../../types';
|
import type { FanPoint, FanState } from "../../types";
|
||||||
import { toast } from 'sonner';
|
import { toast } from "sonner";
|
||||||
import { ConfirmDialog } from '../common/ConfirmDialog';
|
import { ConfirmDialog } from "../common/ConfirmDialog";
|
||||||
|
|
||||||
function defaultCurve(): FanPoint[] {
|
function defaultCurve(): FanPoint[] {
|
||||||
return [
|
return [
|
||||||
@@ -35,7 +35,9 @@ function fanToY(f: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function xToTemp(x: 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) {
|
function yToFan(y: number) {
|
||||||
@@ -63,13 +65,15 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
setPending(null);
|
setPending(null);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to load fan state');
|
toast.error("Failed to load fan state");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { fetchFans(); }, [selectedGpuIndex]);
|
useEffect(() => {
|
||||||
|
fetchFans();
|
||||||
|
}, [selectedGpuIndex]);
|
||||||
|
|
||||||
const activeCurve = pending ?? fanState?.curve ?? defaultCurve();
|
const activeCurve = pending ?? fanState?.curve ?? defaultCurve();
|
||||||
const hasPending = pending !== null;
|
const hasPending = pending !== null;
|
||||||
@@ -87,7 +91,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
setConfirmApply(false);
|
setConfirmApply(false);
|
||||||
await fetchFans();
|
await fetchFans();
|
||||||
onChanged?.();
|
onChanged?.();
|
||||||
toast.success('Fan curve applied');
|
toast.success("Fan curve applied");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message ?? String(e));
|
setError(e.message ?? String(e));
|
||||||
setConfirmApply(false);
|
setConfirmApply(false);
|
||||||
@@ -105,7 +109,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
setConfirmReset(false);
|
setConfirmReset(false);
|
||||||
await fetchFans();
|
await fetchFans();
|
||||||
onChanged?.();
|
onChanged?.();
|
||||||
toast.success('Fan control reset to automatic');
|
toast.success("Fan control reset to automatic");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message ?? String(e));
|
setError(e.message ?? String(e));
|
||||||
setConfirmReset(false);
|
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 fan = Math.max(FAN_MIN, Math.min(FAN_MAX, yToFan(y)));
|
||||||
|
|
||||||
const baseCurve = pending ?? fanState?.curve ?? defaultCurve();
|
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;
|
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);
|
setPending(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,41 +152,47 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
setDragIdx(idx);
|
setDragIdx(idx);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePointerMove = useCallback((e: React.PointerEvent<SVGSVGElement>) => {
|
const handlePointerMove = useCallback(
|
||||||
if (dragIdx === null) return;
|
(e: React.PointerEvent<SVGSVGElement>) => {
|
||||||
e.preventDefault();
|
if (dragIdx === null) return;
|
||||||
const svg = svgRef.current;
|
e.preventDefault();
|
||||||
if (!svg) return;
|
const svg = svgRef.current;
|
||||||
const rect = svg.getBoundingClientRect();
|
if (!svg) return;
|
||||||
const scaleX = CHART_W / rect.width;
|
const rect = svg.getBoundingClientRect();
|
||||||
const scaleY = CHART_H / rect.height;
|
const scaleX = CHART_W / rect.width;
|
||||||
const x = (e.clientX - rect.left) * scaleX;
|
const scaleY = CHART_H / rect.height;
|
||||||
const y = (e.clientY - rect.top) * scaleY;
|
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 temp = Math.max(TEMP_MIN, Math.min(TEMP_MAX, xToTemp(x)));
|
||||||
const fan = Math.max(FAN_MIN, Math.min(FAN_MAX, yToFan(y)));
|
const fan = Math.max(FAN_MIN, Math.min(FAN_MAX, yToFan(y)));
|
||||||
|
|
||||||
const curve = pending ?? fanState?.curve ?? defaultCurve();
|
const curve = pending ?? fanState?.curve ?? defaultCurve();
|
||||||
const updated = [...curve];
|
const updated = [...curve];
|
||||||
updated[dragIdx] = { temp_c: temp, fan_pct: fan };
|
updated[dragIdx] = { temp_c: temp, fan_pct: fan };
|
||||||
setPending(updated);
|
setPending(updated);
|
||||||
}, [dragIdx, pending, fanState]);
|
},
|
||||||
|
[dragIdx, pending, fanState],
|
||||||
|
);
|
||||||
|
|
||||||
const handlePointerUp = useCallback(() => {
|
const handlePointerUp = useCallback(() => {
|
||||||
setDragIdx(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (dragIdx === null) return;
|
if (dragIdx === null) return;
|
||||||
window.addEventListener('pointerup', handlePointerUp);
|
window.addEventListener("pointerup", handlePointerUp);
|
||||||
return () => window.removeEventListener('pointerup', handlePointerUp);
|
return () => window.removeEventListener("pointerup", handlePointerUp);
|
||||||
}, [dragIdx, handlePointerUp]);
|
}, [dragIdx, handlePointerUp]);
|
||||||
|
|
||||||
// Build polyline path from curve points
|
// Build polyline path from curve points
|
||||||
const curvePath = activeCurve && activeCurve.length >= 2
|
const curvePath =
|
||||||
? activeCurve.map((p) => `${tempToX(p.temp_c)},${fanToY(p.fan_pct)}`).join(' ')
|
activeCurve && activeCurve.length >= 2
|
||||||
: null;
|
? activeCurve
|
||||||
|
.map((p) => `${tempToX(p.temp_c)},${fanToY(p.fan_pct)}`)
|
||||||
|
.join(" ")
|
||||||
|
: null;
|
||||||
|
|
||||||
// Grid lines
|
// Grid lines
|
||||||
const tempTicks = [30, 40, 50, 60, 70, 80, 90];
|
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">
|
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 shrink-0">
|
<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 && (
|
{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">
|
<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
|
Apply
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setPending(null); setError(null); }}
|
onClick={() => {
|
||||||
|
setPending(null);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
disabled={!hasPending || busy}
|
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"
|
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 && (
|
{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">
|
<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>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -263,7 +285,8 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
{isDefaults && (
|
{isDefaults && (
|
||||||
<div className="absolute inset-x-4 top-4 z-10 pointer-events-none text-center">
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -272,51 +295,95 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
width="100%"
|
width="100%"
|
||||||
viewBox={`0 0 ${CHART_W} ${CHART_H}`}
|
viewBox={`0 0 ${CHART_W} ${CHART_H}`}
|
||||||
className="max-w-full cursor-crosshair select-none"
|
className="max-w-full cursor-crosshair select-none"
|
||||||
style={{ touchAction: 'none' }}
|
style={{ touchAction: "none" }}
|
||||||
onClick={handleCanvasClick}
|
onClick={handleCanvasClick}
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
>
|
>
|
||||||
{/* Background */}
|
{/* 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 %) */}
|
{/* Grid lines - horizontal (fan %) */}
|
||||||
{fanTicks.map(f => (
|
{fanTicks.map((f) => (
|
||||||
<g key={`fy-${f}`}>
|
<g key={`fy-${f}`}>
|
||||||
<line
|
<line
|
||||||
x1={PAD.left} y1={fanToY(f)}
|
x1={PAD.left}
|
||||||
x2={PAD.left + PLOT_W} y2={fanToY(f)}
|
y1={fanToY(f)}
|
||||||
stroke="#27272a" strokeWidth="0.5"
|
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}%
|
{f}%
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Grid lines - vertical (temp) */}
|
{/* Grid lines - vertical (temp) */}
|
||||||
{tempTicks.map(t => (
|
{tempTicks.map((t) => (
|
||||||
<g key={`tx-${t}`}>
|
<g key={`tx-${t}`}>
|
||||||
<line
|
<line
|
||||||
x1={tempToX(t)} y1={PAD.top}
|
x1={tempToX(t)}
|
||||||
x2={tempToX(t)} y2={PAD.top + PLOT_H}
|
y1={PAD.top}
|
||||||
stroke="#27272a" strokeWidth="0.5"
|
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}°
|
{t}°
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Axis labels */}
|
{/* Axis labels */}
|
||||||
<text x={PAD.left + PLOT_W / 2} y={CHART_H - 2} textAnchor="middle" fill="#52525b" fontSize="9">Temperature (°C)</text>
|
<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>
|
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 */}
|
{/* Curve line */}
|
||||||
{curvePath && (
|
{curvePath && (
|
||||||
<polyline
|
<polyline
|
||||||
points={curvePath}
|
points={curvePath}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={hasPending ? '#22d3ee' : '#fb923c'}
|
stroke={hasPending ? "#22d3ee" : "#fb923c"}
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@@ -328,7 +395,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
{curvePath && activeCurve && activeCurve.length >= 2 && (
|
{curvePath && activeCurve && activeCurve.length >= 2 && (
|
||||||
<polygon
|
<polygon
|
||||||
points={`${tempToX(activeCurve[0].temp_c)},${PAD.top + PLOT_H} ${curvePath} ${tempToX(activeCurve[activeCurve.length - 1].temp_c)},${PAD.top + PLOT_H}`}
|
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"
|
opacity="0.07"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -340,40 +407,62 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
cx={tempToX(p.temp_c)}
|
cx={tempToX(p.temp_c)}
|
||||||
cy={fanToY(p.fan_pct)}
|
cy={fanToY(p.fan_pct)}
|
||||||
r="6"
|
r="6"
|
||||||
fill={hasPending ? '#22d3ee' : '#fb923c'}
|
fill={hasPending ? "#22d3ee" : "#fb923c"}
|
||||||
stroke="#09090b"
|
stroke="#09090b"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
className="cursor-grab active:cursor-grabbing"
|
className="cursor-grab active:cursor-grabbing"
|
||||||
style={{ touchAction: 'none' }}
|
style={{ touchAction: "none" }}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handlePointerDown(i);
|
handlePointerDown(i);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Delete button on hover */}
|
{/* Remove button: faintly visible, brightens on hover */}
|
||||||
{activeCurve && activeCurve.length > 2 && (
|
{activeCurve && activeCurve.length > 2 && (
|
||||||
<circle
|
<g
|
||||||
cx={tempToX(p.temp_c) + 8}
|
className="cursor-pointer group/remove"
|
||||||
cy={fanToY(p.fan_pct) - 8}
|
|
||||||
r="7"
|
|
||||||
fill="#27272a"
|
|
||||||
stroke="#3f3f46"
|
|
||||||
strokeWidth="1"
|
|
||||||
className="cursor-pointer opacity-0 hover:opacity-100 transition-opacity"
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
removePoint(i);
|
removePoint(i);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<title>Remove point</title>
|
<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 */}
|
{/* Value label */}
|
||||||
<text
|
<text
|
||||||
x={tempToX(p.temp_c)}
|
x={tempToX(p.temp_c)}
|
||||||
y={fanToY(p.fan_pct) - 12}
|
y={fanToY(p.fan_pct) - 12}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
fill={hasPending ? '#22d3ee' : '#fb923c'}
|
fill={hasPending ? "#22d3ee" : "#fb923c"}
|
||||||
fontSize="8"
|
fontSize="8"
|
||||||
fontFamily="monospace"
|
fontFamily="monospace"
|
||||||
fontWeight="600"
|
fontWeight="600"
|
||||||
@@ -386,7 +475,13 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
|
|
||||||
{/* No-curve hint (only when curve is truly empty) */}
|
{/* No-curve hint (only when curve is truly empty) */}
|
||||||
{!activeCurve && (
|
{!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
|
Click to add points
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
@@ -397,7 +492,9 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
{activeCurve && activeCurve.length > 0 && (
|
{activeCurve && activeCurve.length > 0 && (
|
||||||
<div className="px-4 pb-3">
|
<div className="px-4 pb-3">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const curve = pending ?? fanState?.curve ?? defaultCurve();
|
const curve = pending ?? fanState?.curve ?? defaultCurve();
|
||||||
@@ -423,16 +520,29 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{activeCurve.map((p, i) => (
|
{activeCurve.map((p, i) => (
|
||||||
<tr key={i} className="border-t border-zinc-800/50">
|
<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">
|
||||||
<td className="py-1 font-mono text-zinc-300">{p.fan_pct}</td>
|
{p.temp_c}
|
||||||
<td className="py-1">
|
</td>
|
||||||
{activeCurve.length > 2 && (
|
<td className="py-1 font-mono text-zinc-300">
|
||||||
|
{p.fan_pct}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 text-right">
|
||||||
|
{activeCurve.length > 2 ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => removePoint(i)}
|
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>
|
</button>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="text-zinc-700"
|
||||||
|
title="A curve needs at least 2 points"
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -446,10 +556,14 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="px-4 pb-3 text-[10px] text-zinc-600">
|
<div className="px-4 pb-3 text-[10px] text-zinc-600">
|
||||||
{curveActive
|
{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
|
: isDefaults
|
||||||
? 'These are default values. Click Apply to enable curve control, or edit points first.'
|
? "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.'}
|
: "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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Save, Trash2, Check, ChevronRight, Pencil, Star } from 'lucide-react';
|
import {
|
||||||
import { api } from '../../api/client';
|
Save,
|
||||||
import type { ProfileData } from '../../types';
|
Trash2,
|
||||||
import { toast } from 'sonner';
|
Check,
|
||||||
import { useCurveStore } from '../../store/curveStore';
|
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 {
|
interface ProfilePanelProps {
|
||||||
activeProfile: string | null;
|
activeProfile: string | null;
|
||||||
onProfileApplied: (name: string | null) => void;
|
onProfileApplied: (name: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelProps) {
|
export function ProfilePanel({
|
||||||
|
activeProfile,
|
||||||
|
onProfileApplied,
|
||||||
|
}: ProfilePanelProps) {
|
||||||
const { selectedGpuIndex, gpuInfo } = useCurveStore();
|
const { selectedGpuIndex, gpuInfo } = useCurveStore();
|
||||||
const [profiles, setProfiles] = useState<ProfileData[]>([]);
|
const [profiles, setProfiles] = useState<ProfileData[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -18,7 +29,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
|
|
||||||
// Save form
|
// Save form
|
||||||
const [isSaveOpen, setIsSaveOpen] = useState(false);
|
const [isSaveOpen, setIsSaveOpen] = useState(false);
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState("");
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const saveInputRef = useRef<HTMLInputElement>(null);
|
const saveInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -28,7 +39,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
|
|
||||||
// Inline rename
|
// Inline rename
|
||||||
const [renamingName, setRenamingName] = useState<string | null>(null);
|
const [renamingName, setRenamingName] = useState<string | null>(null);
|
||||||
const [renameValue, setRenameValue] = useState('');
|
const [renameValue, setRenameValue] = useState("");
|
||||||
const [isRenaming, setIsRenaming] = useState(false);
|
const [isRenaming, setIsRenaming] = useState(false);
|
||||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -42,7 +53,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
onProfileApplied(data.active);
|
onProfileApplied(data.active);
|
||||||
setAutoLoadProfile(data.auto_load);
|
setAutoLoadProfile(data.auto_load);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to load profiles');
|
toast.error("Failed to load profiles");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -53,17 +64,21 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
await api.setAutoLoadProfile(name, selectedGpuIndex);
|
await api.setAutoLoadProfile(name, selectedGpuIndex);
|
||||||
setAutoLoadProfile(name);
|
setAutoLoadProfile(name);
|
||||||
if (name) toast.success(`"${name}" will load on server start`);
|
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) {
|
} 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(() => {
|
useEffect(() => {
|
||||||
if (isSaveOpen) saveInputRef.current?.focus();
|
if (isSaveOpen) saveInputRef.current?.focus();
|
||||||
else setNewName('');
|
else setNewName("");
|
||||||
}, [isSaveOpen]);
|
}, [isSaveOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -80,7 +95,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
setIsSaveOpen(false);
|
setIsSaveOpen(false);
|
||||||
await fetchProfiles();
|
await fetchProfiles();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error('Failed to save: ' + (e.message || String(e)));
|
toast.error("Failed to save: " + (e.message || String(e)));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -109,7 +124,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
setDeletingName(null);
|
setDeletingName(null);
|
||||||
await fetchProfiles();
|
await fetchProfiles();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error('Failed to delete: ' + (e.message || String(e)));
|
toast.error("Failed to delete: " + (e.message || String(e)));
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
@@ -130,7 +145,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
setRenamingName(null);
|
setRenamingName(null);
|
||||||
await fetchProfiles();
|
await fetchProfiles();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error('Failed to rename: ' + (e.message || String(e)));
|
toast.error("Failed to rename: " + (e.message || String(e)));
|
||||||
} finally {
|
} finally {
|
||||||
setIsRenaming(false);
|
setIsRenaming(false);
|
||||||
}
|
}
|
||||||
@@ -162,9 +177,9 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Profile name..."
|
placeholder="Profile name..."
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={e => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
disabled={isSaving}
|
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"
|
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
|
<button
|
||||||
@@ -201,13 +216,17 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-0.5 px-2">
|
<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 pts = Object.keys(p.curve_deltas).length;
|
||||||
const badges = [
|
const badges = [
|
||||||
pts > 0 ? `${pts} pts` : null,
|
pts > 0 ? `${pts} pts` : null,
|
||||||
p.power_limit_w != null ? `${p.power_limit_w}W` : 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,
|
p.mem_offset_mhz != null
|
||||||
].filter(Boolean).join(' · ');
|
? `${p.mem_offset_mhz > 0 ? "+" : ""}${p.mem_offset_mhz} MHz mem`
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
|
||||||
const isActive = activeProfile === p.name;
|
const isActive = activeProfile === p.name;
|
||||||
const isAutoLoad = autoLoadProfile === p.name;
|
const isAutoLoad = autoLoadProfile === p.name;
|
||||||
@@ -218,11 +237,13 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={p.name}
|
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 ? (
|
{isConfirmingDelete ? (
|
||||||
<div className="flex items-center justify-between px-3 py-2 gap-2">
|
<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">
|
<div className="flex gap-1.5 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteConfirm(p.name)}
|
onClick={() => handleDeleteConfirm(p.name)}
|
||||||
@@ -240,14 +261,19 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : isRenaming_ ? (
|
) : 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
|
<input
|
||||||
ref={renameInputRef}
|
ref={renameInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={renameValue}
|
value={renameValue}
|
||||||
onChange={e => setRenameValue(e.target.value)}
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
disabled={isRenaming}
|
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"
|
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
|
<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 justify-between px-3 py-2">
|
||||||
<div className="flex items-center gap-2 min-w-0 pr-2">
|
<div className="flex items-center gap-2 min-w-0 pr-2">
|
||||||
{isActive
|
{isActive ? (
|
||||||
? <Check size={13} className="text-emerald-400 shrink-0" />
|
<Check
|
||||||
: <span className="w-[13px] shrink-0" />
|
size={13}
|
||||||
}
|
className="text-emerald-400 shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="w-[13px] shrink-0" />
|
||||||
|
)}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<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.name}
|
||||||
</p>
|
</p>
|
||||||
{isAutoLoad && (
|
{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 && (
|
{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}
|
{p.gpu_name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{badges && <p className="text-xs text-zinc-500">{badges}</p>}
|
{badges && (
|
||||||
|
<p className="text-xs text-zinc-500">{badges}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -296,21 +345,34 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
disabled={isApplying}
|
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"
|
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
|
{isApplying ? (
|
||||||
? <span className="w-3 h-3 border border-zinc-500 border-t-emerald-400 rounded-full animate-spin" />
|
<span className="w-3 h-3 border border-zinc-500 border-t-emerald-400 rounded-full animate-spin" />
|
||||||
: <ChevronRight size={13} />
|
) : (
|
||||||
}
|
<ChevronRight size={13} />
|
||||||
|
)}
|
||||||
Apply
|
Apply
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSetAutoLoad(isAutoLoad ? null : p.name)}
|
onClick={() =>
|
||||||
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'}`}
|
handleSetAutoLoad(isAutoLoad ? null : p.name)
|
||||||
title={isAutoLoad ? 'Clear default profile' : 'Set as default profile'}
|
}
|
||||||
|
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>
|
||||||
<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"
|
className="p-1.5 text-zinc-600 hover:text-zinc-300 hover:bg-zinc-700 rounded transition"
|
||||||
title="Rename"
|
title="Rename"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2092,6 +2092,9 @@ def main():
|
|||||||
elif "auto_load_profile" in data:
|
elif "auto_load_profile" in data:
|
||||||
# Migrate old single-string format — GPU 0, no UUID known at this point
|
# Migrate old single-string format — GPU 0, no UUID known at this point
|
||||||
cfg.auto_load_profiles = {"idx:0": data["auto_load_profile"]}
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ class Config:
|
|||||||
# Value = profile name (str).
|
# Value = profile name (str).
|
||||||
auto_load_profiles: dict[str, str] = field(default_factory=dict)
|
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.
|
# Module-level default config instance.
|
||||||
default_config = Config()
|
default_config = Config()
|
||||||
+96
-42
@@ -237,6 +237,58 @@ async def _fan_poller(gpu_index: int) -> None:
|
|||||||
await asyncio.sleep(2.0)
|
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 ──────────────────────────────────────────────────────────────────
|
# ── 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
|
yield # server is running
|
||||||
|
|
||||||
for task in poller_tasks:
|
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)
|
_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")
|
@app.get("/api/profiles")
|
||||||
async def api_profiles(gpu_index: int = 0):
|
async def api_profiles(gpu_index: int = 0):
|
||||||
"""List saved native profiles, the active profile name, and the auto-load profile name."""
|
"""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)
|
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:
|
if profile.fan_curve:
|
||||||
ok, msg = validate_curve(profile.fan_curve)
|
ok, msg = validate_curve(profile.fan_curve)
|
||||||
if not ok:
|
if not ok:
|
||||||
errs.append(f"Fan curve: {msg}")
|
errs.append(f"Fan curve: {msg}")
|
||||||
else:
|
else:
|
||||||
# Stop existing fan poller if running
|
await _activate_fan_curve(gpu_index, profile.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_curve"] = profile.fan_curve
|
|
||||||
g_state["fan_curve_active"] = True
|
|
||||||
g_state["fan_poller_task"] = asyncio.create_task(_fan_poller(gpu_index))
|
|
||||||
elif g_state.get("fan_curve_active"):
|
elif g_state.get("fan_curve_active"):
|
||||||
# Profile has no fan curve, deactivate any active fan curve
|
await _deactivate_fan_curve(gpu_index, reset_hardware=True)
|
||||||
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)
|
|
||||||
|
|
||||||
if not errs:
|
if not errs:
|
||||||
g_state["active_profile"] = name
|
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}"
|
status_code=500, detail=f"Fan control not available: {fan_msg}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Stop existing poller if running
|
# Activate the curve (starts the poller) and persist it so it survives restarts.
|
||||||
if g_state.get("fan_poller_task"):
|
await _activate_fan_curve(gpu_index, curve_data)
|
||||||
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))
|
|
||||||
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@@ -1212,21 +1276,11 @@ async def api_fans_update(req: FanCurveRequest, gpu_index: int = 0):
|
|||||||
@app.post("/api/fans/reset")
|
@app.post("/api/fans/reset")
|
||||||
async def api_fans_reset(gpu_index: int = 0):
|
async def api_fans_reset(gpu_index: int = 0):
|
||||||
"""Deactivate fan curve control and restore automatic fan mode."""
|
"""Deactivate fan curve control and restore automatic fan mode."""
|
||||||
g_state = _get_gpu_state(gpu_index)
|
_get_gpu_state(gpu_index)
|
||||||
|
|
||||||
# Stop poller
|
# Stop the poller, clear state, restore automatic fan control, and clear
|
||||||
if g_state.get("fan_poller_task"):
|
# the persisted curve so it is not re-applied on the next server start.
|
||||||
g_state["fan_poller_task"].cancel()
|
await _deactivate_fan_curve(gpu_index, reset_hardware=True)
|
||||||
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)
|
|
||||||
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user