Merge pull request 'Clean up LSP diagnostics across backend and frontend' (#7) from cleanup/lint-modernization into main

Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
Pakobbix committed 2026-09-08 21:58:42 +00:00
commit 34a9bc6d6e
33 files changed
+1569 -802

No files matched your search

+2
View File
@@ -15,3 +15,5 @@ node_modules/
/build/ /build/
*.egg-info/ *.egg-info/
.claude .claude
# Local tool data
.codegraph/
+18 -18
View File
@@ -1,24 +1,24 @@
import { useGpu } from "./hooks/useGpu"; import { useGpu } from "./hooks/useGpu.js";
import { useCurve } from "./hooks/useCurve"; import { useCurve } from "./hooks/useCurve.js";
import { useMonitor } from "./hooks/useMonitor"; import { useMonitor } from "./hooks/useMonitor.js";
import { useDashboard } from "./hooks/useDashboard"; import { useDashboard } from "./hooks/useDashboard.js";
import { StatusBar } from "./components/Monitor/StatusBar"; import { StatusBar } from "./components/Monitor/StatusBar.js";
import { LiveMonitor } from "./components/Monitor/LiveMonitor"; import { LiveMonitor } from "./components/Monitor/LiveMonitor.js";
import { Dashboard } from "./components/Dashboard/Dashboard"; import { Dashboard } from "./components/Dashboard/Dashboard.js";
import { CurveEditor } from "./components/CurveEditor/CurveEditor"; import { CurveEditor } from "./components/CurveEditor/CurveEditor.js";
import { PointTable } from "./components/PointTable/PointTable"; import { PointTable } from "./components/PointTable/PointTable.js";
import { PerformancePanel } from "./components/Limits/PerformancePanel"; import { PerformancePanel } from "./components/Limits/PerformancePanel.js";
import { PerformanceMonitor } from "./components/Monitor/PerformanceMonitor"; import { PerformanceMonitor } from "./components/Monitor/PerformanceMonitor.js";
import { FanMonitor } from "./components/Monitor/FanMonitor"; import { FanMonitor } from "./components/Monitor/FanMonitor.js";
import { FanCurveEditor } from "./components/Fans/FanCurveEditor"; import { FanCurveEditor } from "./components/Fans/FanCurveEditor.js";
import { ProfilePanel } from "./components/Profiles/ProfilePanel"; import { ProfilePanel } from "./components/Profiles/ProfilePanel.js";
import { api, onUnauthorized } from "./api/client"; import { api, onUnauthorized } from "./api/client.js";
import { LoginScreen } from "./components/Auth/LoginScreen"; import { LoginScreen } from "./components/Auth/LoginScreen.js";
import { useCurveStore } from "./store/curveStore"; import { useCurveStore } from "./store/curveStore.js";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { Loader, ChevronDown } from "lucide-react"; import { Loader, ChevronDown } from "lucide-react";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import type { FanState } from "./types"; import type { FanState } from "./types.js";
type AuthState = "checking" | "login" | "ok"; type AuthState = "checking" | "login" | "ok";
+1 -1
View File
@@ -8,7 +8,7 @@ import type {
FanState, FanState,
FanPoint, FanPoint,
DashboardInfo, DashboardInfo,
} from "../types"; } from "../types.js";
export class ApiError extends Error { export class ApiError extends Error {
status: number; status: number;
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { Loader, Lock, User } from "lucide-react"; import { Loader, Lock, User } from "lucide-react";
import { api, ApiError } from "../../api/client"; import { api, ApiError } from "../../api/client.js";
interface Props { interface Props {
onSuccess: (username: string) => void; onSuccess: (username: string) => void;
@@ -12,7 +12,7 @@ export function LoginScreen({ onSuccess }: Props) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) { async function submit(e: React.SubmitEvent) {
e.preventDefault(); e.preventDefault();
if (busy) return; if (busy) return;
setBusy(true); setBusy(true);
File diff suppressed because it is too large. Load diff
@@ -1,7 +1,7 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo } from "react";
import { ZoomIn, RotateCcw, Minus } from 'lucide-react'; import { ZoomIn, RotateCcw, Minus } from "lucide-react";
import { useCurveStore } from '../../store/curveStore'; import { useCurveStore } from "../../store/curveStore.js";
import type { VFPoint } from '../../types'; import type { VFPoint } from "../../types.js";
interface Props { interface Props {
/** All curve points — used by global offset slider */ /** All curve points — used by global offset slider */
@@ -18,23 +18,44 @@ interface Props {
onZoomChange: (factor: number) => void; onZoomChange: (factor: number) => void;
} }
export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomFactor, onZoomChange }: Props) { export function CurveToolbar({
const { pendingDeltas, selectedPoints, anchorPoint, curve, stageRangeEdit, flattenToAnchor } = useCurveStore(); activePts,
onResetZoom,
isZoomed,
readOnly,
zoomFactor,
onZoomChange,
}: Props) {
const {
pendingDeltas,
selectedPoints,
anchorPoint,
curve,
stageRangeEdit,
flattenToAnchor,
} = useCurveStore();
const [offsetMhz, setOffsetMhz] = useState(0); const [offsetMhz, setOffsetMhz] = useState(0);
const uniformDeltaMhz = useMemo(() => { const uniformDeltaMhz = useMemo(() => {
if (activePts.length === 0) return 0; if (activePts.length === 0) return 0;
const firstD = pendingDeltas.get(activePts[0].index) ?? activePts[0].delta_khz; const firstD =
const uniform = activePts.every((p) => (pendingDeltas.get(p.index) ?? p.delta_khz) === firstD); pendingDeltas.get(activePts[0].index) ?? activePts[0].delta_khz;
const uniform = activePts.every(
(p) => (pendingDeltas.get(p.index) ?? p.delta_khz) === firstD,
);
return uniform ? firstD / 1000 : null; return uniform ? firstD / 1000 : null;
}, [activePts, pendingDeltas]); }, [activePts, pendingDeltas]);
useEffect(() => { // Sync the slider to the uniform delta when it changes (adjust state during
if (uniformDeltaMhz !== null) { // render instead of an effect; undefined sentinel so the first render syncs).
setOffsetMhz(uniformDeltaMhz); const [lastUniformDelta, setLastUniformDelta] = useState<
} number | null | undefined
}, [uniformDeltaMhz]); >();
if (uniformDeltaMhz !== null && lastUniformDelta !== uniformDeltaMhz) {
setLastUniformDelta(uniformDeltaMhz);
setOffsetMhz(uniformDeltaMhz);
}
function handleOffsetChange(mhz: number) { function handleOffsetChange(mhz: number) {
setOffsetMhz(mhz); setOffsetMhz(mhz);
@@ -44,7 +65,10 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
return ( return (
<div className="flex flex-wrap items-center gap-2 px-1 pb-2"> <div className="flex flex-wrap items-center gap-2 px-1 pb-2">
{/* Zoom control */} {/* Zoom control */}
<div className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800/60 border border-zinc-700/40" title="Zoom x-axis (Alt+scroll also works)"> <div
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800/60 border border-zinc-700/40"
title="Zoom x-axis (Alt+scroll also works)"
>
<ZoomIn size={11} className="text-zinc-500 shrink-0" /> <ZoomIn size={11} className="text-zinc-500 shrink-0" />
<input <input
type="range" type="range"
@@ -55,7 +79,9 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
onChange={(e) => onZoomChange(Number(e.target.value))} onChange={(e) => onZoomChange(Number(e.target.value))}
className="w-20 h-1 cursor-pointer accent-cyan-400" className="w-20 h-1 cursor-pointer accent-cyan-400"
/> />
<span className={`text-xs font-mono w-8 tabular-nums ${isZoomed ? 'text-cyan-400' : 'text-zinc-600'}`}> <span
className={`text-xs font-mono w-8 tabular-nums ${isZoomed ? "text-cyan-400" : "text-zinc-600"}`}
>
{zoomFactor.toFixed(1)}× {zoomFactor.toFixed(1)}×
</span> </span>
{isZoomed && ( {isZoomed && (
@@ -75,7 +101,9 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
{/* Global offset slider — GPU only */} {/* Global offset slider — GPU only */}
{!readOnly && uniformDeltaMhz !== null && ( {!readOnly && uniformDeltaMhz !== null && (
<div className="flex items-center gap-1.5 min-w-[260px]"> <div className="flex items-center gap-1.5 min-w-[260px]">
<span className="text-zinc-500 text-xs whitespace-nowrap">Global Offset</span> <span className="text-zinc-500 text-xs whitespace-nowrap">
Global Offset
</span>
<input <input
type="range" type="range"
min={-1000} min={-1000}
@@ -84,46 +112,63 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
value={offsetMhz} value={offsetMhz}
onChange={(e) => handleOffsetChange(Number(e.target.value))} onChange={(e) => handleOffsetChange(Number(e.target.value))}
className="w-32 accent-cyan-400" className="w-32 accent-cyan-400"
title={`${offsetMhz > 0 ? '+' : ''}${offsetMhz} MHz`} title={`${offsetMhz > 0 ? "+" : ""}${offsetMhz} MHz`}
/> />
<span <span
className={[ className={[
'text-xs font-mono w-16', "text-xs font-mono w-16",
offsetMhz > 0 ? 'text-cyan-400' : offsetMhz < 0 ? 'text-orange-400' : 'text-zinc-500', offsetMhz > 0
].join(' ')} ? "text-cyan-400"
: offsetMhz < 0
? "text-orange-400"
: "text-zinc-500",
].join(" ")}
> >
{offsetMhz > 0 ? '+' : ''}{offsetMhz} MHz {offsetMhz > 0 ? "+" : ""}
{offsetMhz} MHz
</span> </span>
</div> </div>
)} )}
{/* Flatten — visible when 2+ points are selected */} {/* Flatten — visible when 2+ points are selected */}
{!readOnly && selectedPoints.size >= 2 && (() => { {!readOnly &&
const anchor = anchorPoint !== null && selectedPoints.has(anchorPoint) selectedPoints.size >= 2 &&
? anchorPoint (() => {
: Math.min(...selectedPoints); const anchor =
const anchorDelta = anchorPoint !== null && selectedPoints.has(anchorPoint)
pendingDeltas.get(anchor) ?? ? anchorPoint
curve?.points.find(p => p.index === anchor)?.delta_khz ?? : Math.min(...selectedPoints);
0; const anchorDelta =
const label = `·${anchor} ${anchorDelta >= 0 ? '+' : ''}${anchorDelta / 1000} MHz`; pendingDeltas.get(anchor) ??
return ( curve?.points.find((p) => p.index === anchor)?.delta_khz ??
<button 0;
onClick={flattenToAnchor} const label = `·${anchor} ${anchorDelta >= 0 ? "+" : ""}${anchorDelta / 1000} MHz`;
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium text-amber-400 hover:text-amber-300 hover:bg-zinc-800 border border-zinc-700/40 transition" return (
title={`Flatten all selected points to anchor point ${anchor} (${anchorDelta >= 0 ? '+' : ''}${anchorDelta / 1000} MHz)`} <button
> onClick={flattenToAnchor}
<Minus size={11} /> className="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium text-amber-400 hover:text-amber-300 hover:bg-zinc-800 border border-zinc-700/40 transition"
Flatten to {label} title={`Flatten all selected points to anchor point ${anchor} (${anchorDelta >= 0 ? "+" : ""}${anchorDelta / 1000} MHz)`}
</button> >
); <Minus size={11} />
})()} Flatten to {label}
</button>
);
})()}
{/* Legend — right-aligned */} {/* Legend — right-aligned */}
<div className="flex items-center gap-3 text-xs text-zinc-500 ml-auto"> <div className="flex items-center gap-3 text-xs text-zinc-500 ml-auto">
<span className="flex items-center gap-1"><span className="inline-block w-3 h-0.5 bg-emerald-400 rounded" /> effective</span> <span className="flex items-center gap-1">
<span className="flex items-center gap-1"><span className="inline-block w-3 h-px border-t-2 border-dashed border-cyan-400" /> pending</span> <span className="inline-block w-3 h-0.5 bg-emerald-400 rounded" />{" "}
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-yellow-400" /> current</span> effective
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-px border-t-2 border-dashed border-cyan-400" />{" "}
pending
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-yellow-400" />{" "}
current
</span>
</div> </div>
</div> </div>
); );
@@ -1,5 +1,5 @@
import { fmt } from '../../utils/units'; import { fmt } from '../../utils/units.js';
import type { VFPoint } from '../../types'; import type { VFPoint } from '../../types.js';
interface Props { interface Props {
point: VFPoint; point: VFPoint;
@@ -21,15 +21,7 @@ export function CurveTooltip({ point, pendingDeltaKhz, isClamped }: Props) {
return ( return (
<div <div
style={{ className="absolute right-4 bottom-4 pointer-events-none z-50 w-[172px] bg-zinc-800 border border-zinc-700 rounded-md p-2 text-xs shadow-xl"
position: 'absolute',
right: 16,
bottom: 16,
pointerEvents: 'none',
zIndex: 50,
width: 172,
}}
className="bg-zinc-800 border border-zinc-700 rounded-md p-2 text-xs shadow-xl"
> >
<div className="text-zinc-400 mb-1">Point {point.index}</div> <div className="text-zinc-400 mb-1">Point {point.index}</div>
<div className="text-zinc-200"> <div className="text-zinc-200">
@@ -1,7 +1,7 @@
import { Loader } from "lucide-react"; import { Loader } from "lucide-react";
import { GaugeCard } from "../Monitor/GaugeCard"; import { GaugeCard } from "../Monitor/GaugeCard.js";
import { fmt } from "../../utils/units"; import { fmt } from "../../utils/units.js";
import type { MonitoringSample, DashboardInfo } from "../../types"; import type { MonitoringSample, DashboardInfo } from "../../types.js";
interface Props { interface Props {
monitor: MonitoringSample | null; monitor: MonitoringSample | null;
+13 -12
View File
@@ -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.js";
import { useCurveStore } from "../../store/curveStore"; import { useCurveStore } from "../../store/curveStore.js";
import type { FanPoint, FanState } from "../../types"; import type { FanPoint, FanState } from "../../types.js";
import { toast } from "sonner"; import { toast } from "sonner";
import { ConfirmDialog } from "../common/ConfirmDialog"; import { ConfirmDialog } from "../common/ConfirmDialog.js";
function defaultCurve(): FanPoint[] { function defaultCurve(): FanPoint[] {
return [ return [
@@ -71,7 +71,10 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
} }
} }
// Data fetch on GPU change — setState calls happen after the await, not
// synchronously in the effect body (rule false-positive on async fetch).
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchFans(); fetchFans();
}, [selectedGpuIndex]); }, [selectedGpuIndex]);
@@ -92,8 +95,8 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
await fetchFans(); await fetchFans();
onChanged?.(); onChanged?.();
toast.success("Fan curve applied"); toast.success("Fan curve applied");
} catch (e: any) { } catch (e: unknown) {
setError(e.message ?? String(e)); setError(e instanceof Error ? e.message : String(e));
setConfirmApply(false); setConfirmApply(false);
} finally { } finally {
setBusy(false); setBusy(false);
@@ -110,8 +113,8 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
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: unknown) {
setError(e.message ?? String(e)); setError(e instanceof Error ? e.message : String(e));
setConfirmReset(false); setConfirmReset(false);
} finally { } finally {
setBusy(false); setBusy(false);
@@ -294,8 +297,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
ref={svgRef} ref={svgRef}
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 touch-none"
style={{ touchAction: "none" }}
onClick={handleCanvasClick} onClick={handleCanvasClick}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
> >
@@ -410,8 +412,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
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 touch-none"
style={{ touchAction: "none" }}
onPointerDown={(e) => { onPointerDown={(e) => {
e.stopPropagation(); e.stopPropagation();
handlePointerDown(i); handlePointerDown(i);
@@ -1,12 +1,12 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import { Check, X, RotateCcw } from 'lucide-react'; import { Check, X, RotateCcw } from "lucide-react";
import { api } from '../../api/client'; import { api } from "../../api/client.js";
import { useCurveStore } from '../../store/curveStore'; import { useCurveStore } from "../../store/curveStore.js";
import type { LimitsState } from '../../types'; import type { LimitsState } from "../../types.js";
import { toast } from 'sonner'; import { toast } from "sonner";
import { ConfirmDialog } from '../common/ConfirmDialog'; import { ConfirmDialog } from "../common/ConfirmDialog.js";
type Pending = Pick<LimitsState, 'power_limit_w' | 'mem_offset_mhz'>; type Pending = Pick<LimitsState, "power_limit_w" | "mem_offset_mhz">;
export function PerformancePanel() { export function PerformancePanel() {
const { selectedGpuIndex } = useCurveStore(); const { selectedGpuIndex } = useCurveStore();
@@ -23,13 +23,18 @@ export function PerformancePanel() {
setLoading(true); setLoading(true);
setLimits(await api.limits(selectedGpuIndex)); setLimits(await api.limits(selectedGpuIndex));
} catch { } catch {
toast.error('Failed to load performance limits'); toast.error("Failed to load performance limits");
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
useEffect(() => { fetchLimits(); }, [selectedGpuIndex]); // Data fetch on GPU change — setState calls happen after the await, not
// synchronously in the effect body (rule false-positive on async fetch).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchLimits();
}, [selectedGpuIndex]);
async function handleApply() { async function handleApply() {
setBusy(true); setBusy(true);
@@ -40,9 +45,9 @@ export function PerformancePanel() {
setPending({}); setPending({});
setConfirmApply(false); setConfirmApply(false);
await fetchLimits(); await fetchLimits();
toast.success('Performance limits applied'); toast.success("Performance limits applied");
} catch (e: any) { } catch (e: unknown) {
setError(e.message ?? String(e)); setError(e instanceof Error ? e.message : String(e));
setConfirmApply(false); setConfirmApply(false);
} finally { } finally {
setBusy(false); setBusy(false);
@@ -58,9 +63,9 @@ export function PerformancePanel() {
setPending({}); setPending({});
setConfirmReset(false); setConfirmReset(false);
await fetchLimits(); await fetchLimits();
toast.success('Performance limits reset to defaults'); toast.success("Performance limits reset to defaults");
} catch (e: any) { } catch (e: unknown) {
setError(e.message ?? String(e)); setError(e instanceof Error ? e.message : String(e));
setConfirmReset(false); setConfirmReset(false);
} finally { } finally {
setBusy(false); setBusy(false);
@@ -96,10 +101,11 @@ export function PerformancePanel() {
return ( return (
<> <>
<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">Performance</span> <span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
Performance
</span>
{hasPending && ( {hasPending && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs"> <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs">
@@ -117,7 +123,10 @@ export function PerformancePanel() {
Apply Apply
</button> </button>
<button <button
onClick={() => { setPending({}); setError(null); }} onClick={() => {
setPending({});
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"
> >
@@ -139,25 +148,32 @@ export function PerformancePanel() {
{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">✕</button> <button
onClick={() => setError(null)}
className="ml-2 text-red-400 hover:text-red-200"
>
✕
</button>
</div> </div>
)} )}
<div className="flex flex-col divide-y divide-zinc-800"> <div className="flex flex-col divide-y divide-zinc-800">
{/* ── Board Power Limit ─────────────────────────────────────────── */} {/* ── Board Power Limit ─────────────────────────────────────────── */}
<div className="px-4 py-4 flex flex-col gap-3"> <div className="px-4 py-4 flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-zinc-500 uppercase tracking-wider">Board Power Limit</span> <span className="text-xs text-zinc-500 uppercase tracking-wider">
Board Power Limit
</span>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<input <input
type="number" type="number"
min={pwrMin} min={pwrMin}
max={pwrMax} max={pwrMax}
value={pwrVal} value={pwrVal}
onChange={e => { onChange={(e) => {
const v = parseInt(e.target.value); const v = parseInt(e.target.value);
if (!isNaN(v)) setPending(p => ({ ...p, power_limit_w: v })); if (!isNaN(v))
setPending((p) => ({ ...p, power_limit_w: v }));
}} }}
className="w-14 bg-zinc-950 border border-zinc-800 rounded text-xs px-2 py-1 text-right font-mono focus:outline-none focus:border-cyan-500" className="w-14 bg-zinc-950 border border-zinc-800 rounded text-xs px-2 py-1 text-right font-mono focus:outline-none focus:border-cyan-500"
/> />
@@ -165,32 +181,44 @@ export function PerformancePanel() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-zinc-600 font-mono w-8 text-right">{pwrMin}</span> <span className="text-xs text-zinc-600 font-mono w-8 text-right">
{pwrMin}
</span>
<input <input
type="range" type="range"
min={pwrMin} min={pwrMin}
max={pwrMax} max={pwrMax}
value={pwrVal} value={pwrVal}
onChange={e => setPending(p => ({ ...p, power_limit_w: parseInt(e.target.value) }))} onChange={(e) =>
setPending((p) => ({
...p,
power_limit_w: parseInt(e.target.value),
}))
}
className="flex-1 accent-cyan-400 h-1 cursor-pointer" className="flex-1 accent-cyan-400 h-1 cursor-pointer"
/> />
<span className="text-xs text-zinc-600 font-mono w-8">{pwrMax}</span> <span className="text-xs text-zinc-600 font-mono w-8">
{pwrMax}
</span>
</div> </div>
</div> </div>
{/* ── Memory Clock Offset ───────────────────────────────────────── */} {/* ── Memory Clock Offset ───────────────────────────────────────── */}
<div className="px-4 py-4 flex flex-col gap-3"> <div className="px-4 py-4 flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-zinc-500 uppercase tracking-wider">Memory Clock Offset</span> <span className="text-xs text-zinc-500 uppercase tracking-wider">
Memory Clock Offset
</span>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<input <input
type="number" type="number"
min={memMin} min={memMin}
max={memMax} max={memMax}
value={memVal} value={memVal}
onChange={e => { onChange={(e) => {
const v = parseInt(e.target.value); const v = parseInt(e.target.value);
if (!isNaN(v)) setPending(p => ({ ...p, mem_offset_mhz: v })); if (!isNaN(v))
setPending((p) => ({ ...p, mem_offset_mhz: v }));
}} }}
className="w-16 bg-zinc-950 border border-zinc-800 rounded text-xs px-2 py-1 text-right font-mono focus:outline-none focus:border-cyan-500" className="w-16 bg-zinc-950 border border-zinc-800 rounded text-xs px-2 py-1 text-right font-mono focus:outline-none focus:border-cyan-500"
/> />
@@ -198,20 +226,28 @@ export function PerformancePanel() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-zinc-600 font-mono w-10 text-right">{memMin}</span> <span className="text-xs text-zinc-600 font-mono w-10 text-right">
{memMin}
</span>
<input <input
type="range" type="range"
min={memMin} min={memMin}
max={memMax} max={memMax}
step={1} step={1}
value={memVal} value={memVal}
onChange={e => setPending(p => ({ ...p, mem_offset_mhz: parseInt(e.target.value) }))} onChange={(e) =>
setPending((p) => ({
...p,
mem_offset_mhz: parseInt(e.target.value),
}))
}
className="flex-1 accent-cyan-400 h-1 cursor-pointer" className="flex-1 accent-cyan-400 h-1 cursor-pointer"
/> />
<span className="text-xs text-zinc-600 font-mono w-10">+{memMax}</span> <span className="text-xs text-zinc-600 font-mono w-10">
+{memMax}
</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
+23 -11
View File
@@ -1,6 +1,6 @@
import { GaugeCard } from './GaugeCard'; import { GaugeCard } from "./GaugeCard.js";
import { fmt } from '../../utils/units'; import { fmt } from "../../utils/units.js";
import type { MonitoringSample, FanPoint } from '../../types'; import type { MonitoringSample, FanPoint } from "../../types.js";
interface Props { interface Props {
monitor: MonitoringSample | null; monitor: MonitoringSample | null;
@@ -16,7 +16,10 @@ function pluck<K extends keyof MonitoringSample>(
return history.map((s) => (s[key] as number | null) ?? 0); return history.map((s) => (s[key] as number | null) ?? 0);
} }
function computeTargetFan(curve: FanPoint[] | null, tempC: number | null): number | null { function computeTargetFan(
curve: FanPoint[] | null,
tempC: number | null,
): number | null {
if (!curve || !tempC || curve.length < 2) return null; if (!curve || !tempC || curve.length < 2) return null;
for (let i = 0; i < curve.length - 1; i++) { for (let i = 0; i < curve.length - 1; i++) {
@@ -36,19 +39,28 @@ function computeTargetFan(curve: FanPoint[] | null, tempC: number | null): numbe
return curve[curve.length - 1].fan_pct; return curve[curve.length - 1].fan_pct;
} }
export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props) { export function FanMonitor({
const fanHistory = pluck(history, 'fan_pct'); monitor,
const tempHistory = pluck(history, 'temp_c'); history,
fanCurve,
fanCurveActive,
}: Props) {
const fanHistory = pluck(history, "fan_pct");
const tempHistory = pluck(history, "temp_c");
const currentTemp = monitor?.temp_c ?? null; const currentTemp = monitor?.temp_c ?? null;
const targetFan = computeTargetFan(fanCurve, currentTemp); const targetFan = computeTargetFan(fanCurve, currentTemp);
const targetFanHistory = history.map((s) => computeTargetFan(fanCurve, s.temp_c) ?? 0); const targetFanHistory = history.map(
(s) => computeTargetFan(fanCurve, s.temp_c) ?? 0,
);
return ( return (
<div className="flex flex-col gap-2 w-full h-full"> <div className="flex flex-col gap-2 w-full h-full">
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full"> <div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full">
<div className="flex items-center justify-between pb-2 border-b border-zinc-800"> <div className="flex items-center justify-between pb-2 border-b border-zinc-800">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Live Monitor</span> <span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
Live Monitor
</span>
{fanCurveActive && ( {fanCurveActive && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-orange-500/15 border border-orange-500/30 text-orange-400 text-[10px] font-semibold"> <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-orange-500/15 border border-orange-500/30 text-orange-400 text-[10px] font-semibold">
Curve Active Curve Active
@@ -73,7 +85,7 @@ export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props
{fanCurveActive && ( {fanCurveActive && (
<GaugeCard <GaugeCard
label="Target Fan" label="Target Fan"
value={targetFan !== null ? `${targetFan}%` : '—'} value={targetFan !== null ? `${targetFan}%` : "—"}
history={targetFanHistory} history={targetFanHistory}
color="#fbbf24" color="#fbbf24"
max={100} max={100}
@@ -82,7 +94,7 @@ export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props
<div className="mt-auto"> <div className="mt-auto">
<GaugeCard <GaugeCard
label="Fan Mode" label="Fan Mode"
value={fanCurveActive ? 'Curve' : 'Auto'} value={fanCurveActive ? "Curve" : "Auto"}
/> />
</div> </div>
</div> </div>
+41 -39
View File
@@ -1,6 +1,6 @@
import { GaugeCard } from './GaugeCard'; import { GaugeCard } from "./GaugeCard.js";
import { fmt } from '../../utils/units'; import { fmt } from "../../utils/units.js";
import type { MonitoringSample } from '../../types'; import type { MonitoringSample } from "../../types.js";
interface Props { interface Props {
monitor: MonitoringSample | null; monitor: MonitoringSample | null;
@@ -19,48 +19,50 @@ export function LiveMonitor({ monitor, history }: Props) {
<div className="flex flex-col gap-2 w-full h-full"> <div className="flex flex-col gap-2 w-full h-full">
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full"> <div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full">
<div className="flex items-center justify-between pb-2 border-b border-zinc-800"> <div className="flex items-center justify-between pb-2 border-b border-zinc-800">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Live Monitor</span> <span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
Live Monitor
</span>
</div> </div>
<div className="flex flex-col gap-2 mt-1"> <div className="flex flex-col gap-2 mt-1">
<GaugeCard
label="Core Clock"
value={fmt.mhz(monitor?.clock_mhz)}
history={pluck(history, 'clock_mhz')}
color="#34d399"
max={3000}
/>
<GaugeCard
label="Voltage"
value={fmt.mv(monitor?.voltage_mv)}
history={pluck(history, 'voltage_mv')}
color="#a78bfa"
max={1100}
/>
<GaugeCard
label="Power Draw"
value={fmt.watts(monitor?.power_w)}
history={pluck(history, 'power_w')}
color="#f472b6"
max={600}
/>
<GaugeCard
label="GPU Util"
value={fmt.pct(monitor?.gpu_util_pct)}
history={pluck(history, 'gpu_util_pct')}
color="#facc15"
max={100}
/>
<div className="mt-auto">
<GaugeCard <GaugeCard
label="P-State" label="Core Clock"
value={monitor?.pstate_label ?? 'Unknown'} value={fmt.mhz(monitor?.clock_mhz)}
history={pluck(history, 'pstate')} history={pluck(history, "clock_mhz")}
color="#a8a29e" color="#34d399"
max={15} max={3000}
/> />
<GaugeCard
label="Voltage"
value={fmt.mv(monitor?.voltage_mv)}
history={pluck(history, "voltage_mv")}
color="#a78bfa"
max={1100}
/>
<GaugeCard
label="Power Draw"
value={fmt.watts(monitor?.power_w)}
history={pluck(history, "power_w")}
color="#f472b6"
max={600}
/>
<GaugeCard
label="GPU Util"
value={fmt.pct(monitor?.gpu_util_pct)}
history={pluck(history, "gpu_util_pct")}
color="#facc15"
max={100}
/>
<div className="mt-auto">
<GaugeCard
label="P-State"
value={monitor?.pstate_label ?? "Unknown"}
history={pluck(history, "pstate")}
color="#a8a29e"
max={15}
/>
</div>
</div> </div>
</div> </div>
</div>
</div> </div>
); );
} }
@@ -1,6 +1,6 @@
import { GaugeCard } from './GaugeCard'; import { GaugeCard } from "./GaugeCard.js";
import { fmt } from '../../utils/units'; import { fmt } from "../../utils/units.js";
import type { MonitoringSample } from '../../types'; import type { MonitoringSample } from "../../types.js";
interface Props { interface Props {
monitor: MonitoringSample | null; monitor: MonitoringSample | null;
@@ -17,35 +17,38 @@ function pluck<K extends keyof MonitoringSample>(
export function PerformanceMonitor({ monitor, history }: Props) { export function PerformanceMonitor({ monitor, history }: Props) {
const memUsed = monitor?.mem_used_mib ?? null; const memUsed = monitor?.mem_used_mib ?? null;
const memTotal = monitor?.mem_total_mib ?? null; const memTotal = monitor?.mem_total_mib ?? null;
const memLabel = memUsed != null && memTotal != null const memLabel =
? `${memUsed.toFixed(0)} / ${memTotal.toFixed(0)} MiB` memUsed != null && memTotal != null
: '—'; ? `${memUsed.toFixed(0)} / ${memTotal.toFixed(0)} MiB`
: "—";
return ( return (
<div className="flex flex-col gap-2 w-full h-full"> <div className="flex flex-col gap-2 w-full h-full">
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full"> <div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full">
<div className="flex items-center justify-between pb-2 border-b border-zinc-800"> <div className="flex items-center justify-between pb-2 border-b border-zinc-800">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Live Monitor</span> <span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
Live Monitor
</span>
</div> </div>
<div className="flex flex-col gap-2 mt-1"> <div className="flex flex-col gap-2 mt-1">
<GaugeCard <GaugeCard
label="Mem Clock" label="Mem Clock"
value={fmt.mhz(monitor?.mem_clock_mhz)} value={fmt.mhz(monitor?.mem_clock_mhz)}
history={pluck(history, 'mem_clock_mhz')} history={pluck(history, "mem_clock_mhz")}
color="#67e8f9" color="#67e8f9"
max={20000} max={20000}
/> />
<GaugeCard <GaugeCard
label="Power Draw" label="Power Draw"
value={fmt.watts(monitor?.power_w)} value={fmt.watts(monitor?.power_w)}
history={pluck(history, 'power_w')} history={pluck(history, "power_w")}
color="#f472b6" color="#f472b6"
max={600} max={600}
/> />
<GaugeCard <GaugeCard
label="VRAM Used" label="VRAM Used"
value={memLabel} value={memLabel}
history={pluck(history, 'mem_used_mib')} history={pluck(history, "mem_used_mib")}
color="#a78bfa" color="#a78bfa"
max={memTotal ?? 32768} max={memTotal ?? 32768}
/> />
@@ -8,9 +8,9 @@ import {
LogOut, LogOut,
User, User,
} from "lucide-react"; } from "lucide-react";
import type { GpuInfo, MonitoringSample } from "../../types"; import type { GpuInfo, MonitoringSample } from "../../types.js";
import { fmt } from "../../utils/units"; import { fmt } from "../../utils/units.js";
import { useCurveStore } from "../../store/curveStore"; import { useCurveStore } from "../../store/curveStore.js";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
interface Props { interface Props {
+58 -22
View File
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from "react";
import { fmt } from '../../utils/units'; import { fmt } from "../../utils/units.js";
import type { VFPoint } from '../../types'; import type { VFPoint } from "../../types.js";
import { useCurveStore } from '../../store/curveStore'; import { useCurveStore } from "../../store/curveStore.js";
interface Props { interface Props {
point: VFPoint; point: VFPoint;
@@ -14,17 +14,28 @@ interface Props {
onMouseEnter?: () => void; onMouseEnter?: () => void;
} }
export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDeltaKhz, shouldAutoScroll, onMouseDown, onMouseEnter }: Props) { export function PointRow({
point,
isCurrent,
isSelected,
isClamped,
pendingDeltaKhz,
shouldAutoScroll,
onMouseDown,
onMouseEnter,
}: Props) {
const { stageEdit } = useCurveStore(); const { stageEdit } = useCurveStore();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const trRef = useRef<HTMLTableRowElement>(null); const trRef = useRef<HTMLTableRowElement>(null);
useEffect(() => { useEffect(() => {
if (shouldAutoScroll && trRef.current) { if (shouldAutoScroll && trRef.current) {
// @ts-expect-error: the typing seems to not include the valid 'container' option trRef.current.scrollIntoView({
trRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', container: 'nearest' }); behavior: "smooth",
block: "nearest",
});
} }
}, [shouldAutoScroll]); }, [shouldAutoScroll]);
@@ -36,11 +47,19 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
const displayEffMhz = point.freq_mhz + deltaChange / 1000; const displayEffMhz = point.freq_mhz + deltaChange / 1000;
const deltaColor = hasPending const deltaColor = hasPending
? displayDeltaKhz > 0 ? 'text-cyan-400' : displayDeltaKhz < 0 ? 'text-orange-400' : 'text-zinc-400' ? displayDeltaKhz > 0
: point.delta_khz > 0 ? 'text-emerald-400' : point.delta_khz < 0 ? 'text-red-400' : 'text-zinc-500'; ? "text-cyan-400"
: displayDeltaKhz < 0
? "text-orange-400"
: "text-zinc-400"
: point.delta_khz > 0
? "text-emerald-400"
: point.delta_khz < 0
? "text-red-400"
: "text-zinc-500";
function startEdit() { function startEdit() {
setInputValue((displayDeltaMhz).toFixed(1)); setInputValue(displayDeltaMhz.toFixed(1));
setEditing(true); setEditing(true);
setTimeout(() => { setTimeout(() => {
inputRef.current?.select(); inputRef.current?.select();
@@ -64,9 +83,13 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
<tr <tr
ref={trRef} ref={trRef}
className={[ className={[
'border-b border-zinc-800 text-xs font-mono cursor-pointer', "border-b border-zinc-800 text-xs font-mono cursor-pointer",
isCurrent ? 'bg-yellow-400/10' : isSelected ? 'bg-cyan-500/10' : 'hover:bg-zinc-800/50', isCurrent
].join(' ')} ? "bg-yellow-400/10"
: isSelected
? "bg-cyan-500/10"
: "hover:bg-zinc-800/50",
].join(" ")}
onMouseDown={(e) => { onMouseDown={(e) => {
if (editing) return; if (editing) return;
onMouseDown?.(e); onMouseDown?.(e);
@@ -80,7 +103,13 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
<td className="px-3 py-1 text-zinc-300">{fmt.mv(point.volt_mv, 0)}</td> <td className="px-3 py-1 text-zinc-300">{fmt.mv(point.volt_mv, 0)}</td>
{/* Offset — click to edit inline */} {/* Offset — click to edit inline */}
<td className={`px-3 py-1 ${deltaColor}`} onClick={(e) => { e.stopPropagation(); startEdit(); }}> <td
className={`px-3 py-1 ${deltaColor}`}
onClick={(e) => {
e.stopPropagation();
startEdit();
}}
>
{editing ? ( {editing ? (
<input <input
ref={inputRef} ref={inputRef}
@@ -90,28 +119,35 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
onBlur={commitEdit} onBlur={commitEdit}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); commitEdit(); } if (e.key === "Enter" || e.key === "Tab") {
if (e.key === 'Escape') cancelEdit(); e.preventDefault();
commitEdit();
}
if (e.key === "Escape") cancelEdit();
}} }}
className="w-20 bg-zinc-700 text-cyan-300 rounded px-1 py-0 border border-cyan-500 outline-none text-xs" className="w-20 bg-zinc-700 text-cyan-300 rounded px-1 py-0 border border-cyan-500 outline-none text-xs font-mono"
style={{ fontFamily: 'monospace' }}
/> />
) : ( ) : (
<span title="Click to edit"> <span title="Click to edit">
{hasPending && <span className="text-cyan-500 mr-0.5">✎</span>} {hasPending && <span className="text-cyan-500 mr-0.5">✎</span>}
{displayDeltaKhz > 0 ? '+' : ''}{displayDeltaMhz.toFixed(1)} MHz {displayDeltaKhz > 0 ? "+" : ""}
{displayDeltaMhz.toFixed(1)} MHz
</span> </span>
)} )}
</td> </td>
{/* Eff. Freq */} {/* Eff. Freq */}
<td className={`px-3 py-1 font-semibold ${hasPending ? 'text-cyan-200' : 'text-zinc-100'}`}> <td
className={`px-3 py-1 font-semibold ${hasPending ? "text-cyan-200" : "text-zinc-100"}`}
>
{fmt.mhz(displayEffMhz, 0)} {fmt.mhz(displayEffMhz, 0)}
{isClamped && !hasPending && ( {isClamped && !hasPending && (
<span <span
className="ml-1 text-amber-500 cursor-help" className="ml-1 text-amber-500 cursor-help"
title="Clamped by monotonicity — a lower-voltage point with a higher offset is holding this frequency up" title="Clamped by monotonicity — a lower-voltage point with a higher offset is holding this frequency up"
>⇡</span> >
⇡
</span>
)} )}
</td> </td>
@@ -1,8 +1,11 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from "react";
import { PointRow } from './PointRow'; import { PointRow } from "./PointRow.js";
import type { VFPoint } from '../../types'; import type { VFPoint } from "../../types.js";
import { findCurrentPoint, detectClampedPoints } from '../../utils/curveHelpers'; import {
import { useCurveStore } from '../../store/curveStore'; findCurrentPoint,
detectClampedPoints,
} from "../../utils/curveHelpers.js";
import { useCurveStore } from "../../store/curveStore.js";
interface Props { interface Props {
points: VFPoint[]; points: VFPoint[];
@@ -11,22 +14,32 @@ interface Props {
} }
export function PointTable({ points, currentVoltageMv, readOnly }: Props) { export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
const { pendingDeltas, selectedPoints, selectPoint, selectRange } = useCurveStore(); const {
pendingDeltas,
selectedPoints,
selectPoint,
togglePoint,
selectRange,
} = useCurveStore();
const currentPoint = findCurrentPoint(points, currentVoltageMv); const currentPoint = findCurrentPoint(points, currentVoltageMv);
const clampedPoints = useMemo(() => detectClampedPoints(points), [points]); const clampedPoints = useMemo(() => detectClampedPoints(points), [points]);
const [dragStartIdx, setDragStartIdx] = useState<number | null>(null); const [dragStartIdx, setDragStartIdx] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
function onUp() { setDragStartIdx(null); } function onUp() {
window.addEventListener('mouseup', onUp); setDragStartIdx(null);
return () => window.removeEventListener('mouseup', onUp); }
window.addEventListener("mouseup", onUp);
return () => window.removeEventListener("mouseup", onUp);
}, []); }, []);
return ( return (
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col"> <div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
<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 mr-2">Points</span> <span className="text-xs text-zinc-500 uppercase tracking-wider mr-2">
Points
</span>
{!readOnly && pendingDeltas.size > 0 && ( {!readOnly && pendingDeltas.size > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs"> <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs">
{pendingDeltas.size} staged {pendingDeltas.size} staged
@@ -35,13 +48,17 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
{readOnly && ( {readOnly && (
<span className="text-xs text-zinc-600 italic">read-only</span> <span className="text-xs text-zinc-600 italic">read-only</span>
)} )}
<span className="ml-auto text-xs text-zinc-600">{points.length} points</span> <span className="ml-auto text-xs text-zinc-600">
{points.length} points
</span>
{!readOnly && selectedPoints.size === 1 && ( {!readOnly && selectedPoints.size === 1 && (
<div className="flex gap-1 ml-4 border-l border-zinc-800 pl-4"> <div className="flex gap-1 ml-4 border-l border-zinc-800 pl-4">
<button <button
onClick={() => { onClick={() => {
const idx = Array.from(selectedPoints)[0]; const idx = Array.from(selectedPoints)[0];
const beforeIdxs = points.filter(p => p.index <= idx).map(p => p.index); const beforeIdxs = points
.filter((p) => p.index <= idx)
.map((p) => p.index);
selectRange(beforeIdxs); selectRange(beforeIdxs);
}} }}
className="px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors whitespace-nowrap" className="px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors whitespace-nowrap"
@@ -51,7 +68,9 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
<button <button
onClick={() => { onClick={() => {
const idx = Array.from(selectedPoints)[0]; const idx = Array.from(selectedPoints)[0];
const afterIdxs = points.filter(p => p.index >= idx).map(p => p.index); const afterIdxs = points
.filter((p) => p.index >= idx)
.map((p) => p.index);
selectRange(afterIdxs); selectRange(afterIdxs);
}} }}
className="px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors whitespace-nowrap" className="px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors whitespace-nowrap"
@@ -66,9 +85,15 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
<thead className="sticky top-0 bg-zinc-900 z-10"> <thead className="sticky top-0 bg-zinc-900 z-10">
<tr className="text-xs text-zinc-500 uppercase tracking-wider border-b border-zinc-800"> <tr className="text-xs text-zinc-500 uppercase tracking-wider border-b border-zinc-800">
<th className="px-3 py-2 text-left font-normal bg-zinc-900">#</th> <th className="px-3 py-2 text-left font-normal bg-zinc-900">#</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900">Voltage</th> <th className="px-3 py-2 text-left font-normal bg-zinc-900">
<th className="px-3 py-2 text-left font-normal bg-zinc-900">Offset</th> Voltage
<th className="px-3 py-2 text-left font-normal bg-zinc-900">Eff. Freq</th> </th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900">
Offset
</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900">
Eff. Freq
</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900" /> <th className="px-3 py-2 text-left font-normal bg-zinc-900" />
</tr> </tr>
</thead> </thead>
@@ -81,34 +106,48 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
isSelected={selectedPoints.has(p.index)} isSelected={selectedPoints.has(p.index)}
isClamped={clampedPoints.has(p.index)} isClamped={clampedPoints.has(p.index)}
pendingDeltaKhz={pendingDeltas.get(p.index)} pendingDeltaKhz={pendingDeltas.get(p.index)}
shouldAutoScroll={selectedPoints.size === 1 && selectedPoints.has(p.index)} shouldAutoScroll={
onMouseDown={readOnly ? undefined : (e) => { selectedPoints.size === 1 && selectedPoints.has(p.index)
if (e.shiftKey) { }
const currentSelected = Array.from(selectedPoints); onMouseDown={
if (currentSelected.length > 0) { readOnly
const last = Math.max(...currentSelected); ? undefined
const min = Math.min(last, p.index); : (e) => {
const max = Math.max(last, p.index); if (e.shiftKey) {
const toSelect = points.filter(a => a.index >= min && a.index <= max).map(a => a.index); const currentSelected = Array.from(selectedPoints);
selectRange(toSelect); if (currentSelected.length > 0) {
} else { const last = Math.max(...currentSelected);
selectPoint(p.index, false); const min = Math.min(last, p.index);
} const max = Math.max(last, p.index);
} else if (e.ctrlKey || e.metaKey) { const toSelect = points
selectPoint(p.index, true); .filter((a) => a.index >= min && a.index <= max)
} else { .map((a) => a.index);
setDragStartIdx(p.index); selectRange(toSelect);
selectPoint(p.index, false); } else {
} selectPoint(p.index);
}} }
onMouseEnter={readOnly ? undefined : () => { } else if (e.ctrlKey || e.metaKey) {
if (dragStartIdx !== null) { togglePoint(p.index);
const min = Math.min(dragStartIdx, p.index); } else {
const max = Math.max(dragStartIdx, p.index); setDragStartIdx(p.index);
const toSelect = points.filter(a => a.index >= min && a.index <= max).map(a => a.index); selectPoint(p.index);
selectRange(toSelect); }
} }
}} }
onMouseEnter={
readOnly
? undefined
: () => {
if (dragStartIdx !== null) {
const min = Math.min(dragStartIdx, p.index);
const max = Math.max(dragStartIdx, p.index);
const toSelect = points
.filter((a) => a.index >= min && a.index <= max)
.map((a) => a.index);
selectRange(toSelect);
}
}
}
/> />
))} ))}
</tbody> </tbody>
@@ -8,10 +8,14 @@ import {
Star, Star,
Fan, Fan,
} from "lucide-react"; } from "lucide-react";
import { api } from "../../api/client"; import { api } from "../../api/client.js";
import type { ProfileData } from "../../types"; import type { ProfileData } from "../../types.js";
import { toast } from "sonner"; import { toast } from "sonner";
import { useCurveStore } from "../../store/curveStore"; import { useCurveStore } from "../../store/curveStore.js";
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
interface ProfilePanelProps { interface ProfilePanelProps {
activeProfile: string | null; activeProfile: string | null;
@@ -65,37 +69,42 @@ export function ProfilePanel({
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: unknown) {
toast.error( toast.error("Failed to update default profile: " + errMsg(e));
"Failed to update default profile: " + (e.message || String(e)),
);
} }
} }
// Data fetch on GPU change — setState calls happen after the await, not
// synchronously in the effect body (rule false-positive on async fetch).
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchProfiles(); fetchProfiles();
}, [selectedGpuIndex]); }, [selectedGpuIndex]);
useEffect(() => { useEffect(() => {
if (isSaveOpen) saveInputRef.current?.focus(); if (isSaveOpen) saveInputRef.current?.focus();
else setNewName("");
}, [isSaveOpen]); }, [isSaveOpen]);
useEffect(() => { useEffect(() => {
if (renamingName) renameInputRef.current?.focus(); if (renamingName) renameInputRef.current?.focus();
}, [renamingName]); }, [renamingName]);
async function handleSave(e: React.FormEvent) { function closeSaveForm() {
setIsSaveOpen(false);
setNewName("");
}
async function handleSave(e: React.SubmitEvent) {
e.preventDefault(); e.preventDefault();
if (!newName.trim()) return; if (!newName.trim()) return;
try { try {
setIsSaving(true); setIsSaving(true);
await api.saveProfile(newName.trim(), selectedGpuIndex); await api.saveProfile(newName.trim(), selectedGpuIndex);
toast.success(`Profile "${newName.trim()}" saved`); toast.success(`Profile "${newName.trim()}" saved`);
setIsSaveOpen(false); closeSaveForm();
await fetchProfiles(); await fetchProfiles();
} catch (e: any) { } catch (e: unknown) {
toast.error("Failed to save: " + (e.message || String(e))); toast.error("Failed to save: " + errMsg(e));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -107,8 +116,8 @@ export function ProfilePanel({
await api.applyProfile(name, selectedGpuIndex); await api.applyProfile(name, selectedGpuIndex);
onProfileApplied(name); onProfileApplied(name);
toast.success(`"${name}" applied`); toast.success(`"${name}" applied`);
} catch (e: any) { } catch (e: unknown) {
toast.error(`Failed to apply "${name}": ` + (e.message || String(e))); toast.error(`Failed to apply "${name}": ` + errMsg(e));
} finally { } finally {
setApplyingName(null); setApplyingName(null);
} }
@@ -123,14 +132,14 @@ export function ProfilePanel({
if (autoLoadProfile === name) setAutoLoadProfile(null); if (autoLoadProfile === name) setAutoLoadProfile(null);
setDeletingName(null); setDeletingName(null);
await fetchProfiles(); await fetchProfiles();
} catch (e: any) { } catch (e: unknown) {
toast.error("Failed to delete: " + (e.message || String(e))); toast.error("Failed to delete: " + errMsg(e));
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
} }
} }
async function handleRename(e: React.FormEvent, oldName: string) { async function handleRename(e: React.SubmitEvent, oldName: string) {
e.preventDefault(); e.preventDefault();
if (!renameValue.trim() || renameValue.trim() === oldName) { if (!renameValue.trim() || renameValue.trim() === oldName) {
setRenamingName(null); setRenamingName(null);
@@ -144,8 +153,8 @@ export function ProfilePanel({
if (autoLoadProfile === oldName) setAutoLoadProfile(renameValue.trim()); if (autoLoadProfile === oldName) setAutoLoadProfile(renameValue.trim());
setRenamingName(null); setRenamingName(null);
await fetchProfiles(); await fetchProfiles();
} catch (e: any) { } catch (e: unknown) {
toast.error("Failed to rename: " + (e.message || String(e))); toast.error("Failed to rename: " + errMsg(e));
} finally { } finally {
setIsRenaming(false); setIsRenaming(false);
} }
@@ -179,7 +188,7 @@ export function ProfilePanel({
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" && closeSaveForm()}
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
@@ -191,7 +200,7 @@ export function ProfilePanel({
</button> </button>
<button <button
type="button" type="button"
onClick={() => setIsSaveOpen(false)} onClick={closeSaveForm}
className="px-3 py-1.5 text-zinc-400 hover:text-zinc-200 rounded text-sm transition shrink-0" className="px-3 py-1.5 text-zinc-400 hover:text-zinc-200 rounded text-sm transition shrink-0"
> >
Cancel Cancel
@@ -10,16 +10,17 @@ interface Props {
export function ConfirmDialog({ export function ConfirmDialog({
message, message,
detail, detail,
confirmLabel = 'Confirm', confirmLabel = "Confirm",
isDestructive = false, isDestructive = false,
onConfirm, onConfirm,
onCancel, onCancel,
}: Props) { }: Props) {
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center" className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
style={{ background: 'rgba(0,0,0,0.7)' }} onMouseDown={(e) => {
onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }} if (e.target === e.currentTarget) onCancel();
}}
> >
<div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 w-96 max-w-[90vw]"> <div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 w-96 max-w-[90vw]">
<h2 className="text-zinc-100 font-semibold text-sm mb-1">{message}</h2> <h2 className="text-zinc-100 font-semibold text-sm mb-1">{message}</h2>
@@ -35,11 +36,11 @@ export function ConfirmDialog({
<button <button
onClick={onConfirm} onClick={onConfirm}
className={[ className={[
'px-3 py-1.5 rounded text-xs font-semibold transition-colors', "px-3 py-1.5 rounded text-xs font-semibold transition-colors",
isDestructive isDestructive
? 'bg-red-600 hover:bg-red-500 text-white' ? "bg-red-600 hover:bg-red-500 text-white"
: 'bg-emerald-600 hover:bg-emerald-500 text-white', : "bg-emerald-600 hover:bg-emerald-500 text-white",
].join(' ')} ].join(" ")}
> >
{confirmLabel} {confirmLabel}
</button> </button>
+9 -7
View File
@@ -1,12 +1,14 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from "react";
import { api } from '../api/client'; import { api } from "../api/client.js";
import { createWsConnection } from '../api/websocket'; import { createWsConnection } from "../api/websocket.js";
import { useCurveStore } from '../store/curveStore'; import { useCurveStore } from "../store/curveStore.js";
import type { CurveState } from '../types'; import type { CurveState } from "../types.js";
export function useCurve() { export function useCurve() {
const { curve, setCurve, selectedGpuIndex } = useCurveStore(); const { curve, setCurve, selectedGpuIndex } = useCurveStore();
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting'); const [wsStatus, setWsStatus] = useState<
"connecting" | "connected" | "disconnected"
>("connecting");
const wsRef = useRef<ReturnType<typeof createWsConnection> | null>(null); const wsRef = useRef<ReturnType<typeof createWsConnection> | null>(null);
useEffect(() => { useEffect(() => {
@@ -15,7 +17,7 @@ export function useCurve() {
// Subscribe to /ws/curve for push updates after writes // Subscribe to /ws/curve for push updates after writes
wsRef.current = createWsConnection<CurveState>( wsRef.current = createWsConnection<CurveState>(
'/ws/curve', "/ws/curve",
(data) => setCurve(data), (data) => setCurve(data),
setWsStatus, setWsStatus,
selectedGpuIndex, selectedGpuIndex,
+9 -9
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api } from "../api/client"; import { api } from "../api/client.js";
import { useCurveStore } from "../store/curveStore"; import { useCurveStore } from "../store/curveStore.js";
import type { DashboardInfo } from "../types"; import type { DashboardInfo } from "../types.js";
interface DashboardState { interface DashboardState {
gpuIndex: number; gpuIndex: number;
@@ -23,17 +23,17 @@ export function useDashboard() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
api (async () => {
.dashboard(selectedGpuIndex) try {
.then((data) => { const data = await api.dashboard(selectedGpuIndex);
if (!cancelled) if (!cancelled)
setState({ gpuIndex: selectedGpuIndex, data, done: true }); setState({ gpuIndex: selectedGpuIndex, data, done: true });
}) } catch (err) {
.catch((err) => {
console.error("Failed to load dashboard info:", err); console.error("Failed to load dashboard info:", err);
if (!cancelled) if (!cancelled)
setState({ gpuIndex: selectedGpuIndex, data: null, done: true }); setState({ gpuIndex: selectedGpuIndex, data: null, done: true });
}); }
})();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
+5 -4
View File
@@ -1,9 +1,10 @@
import { useEffect } from 'react'; import { useEffect } from "react";
import { api } from '../api/client'; import { api } from "../api/client.js";
import { useCurveStore } from '../store/curveStore'; import { useCurveStore } from "../store/curveStore.js";
export function useGpu() { export function useGpu() {
const { gpuInfo, setGpuInfo, setAvailableGpus, selectedGpuIndex } = useCurveStore(); const { gpuInfo, setGpuInfo, setAvailableGpus, selectedGpuIndex } =
useCurveStore();
useEffect(() => { useEffect(() => {
api.gpus().then(setAvailableGpus).catch(console.error); api.gpus().then(setAvailableGpus).catch(console.error);
+10 -7
View File
@@ -1,16 +1,19 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from "react";
import { createWsConnection } from '../api/websocket'; import { createWsConnection } from "../api/websocket.js";
import { useCurveStore } from '../store/curveStore'; import { useCurveStore } from "../store/curveStore.js";
import type { MonitoringSample } from '../types'; import type { MonitoringSample } from "../types.js";
export function useMonitor() { export function useMonitor() {
const { monitor, monitorHistory, pushMonitor, selectedGpuIndex } = useCurveStore(); const { monitor, monitorHistory, pushMonitor, selectedGpuIndex } =
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting'); useCurveStore();
const [wsStatus, setWsStatus] = useState<
"connecting" | "connected" | "disconnected"
>("connecting");
const wsRef = useRef<ReturnType<typeof createWsConnection> | null>(null); const wsRef = useRef<ReturnType<typeof createWsConnection> | null>(null);
useEffect(() => { useEffect(() => {
wsRef.current = createWsConnection<MonitoringSample>( wsRef.current = createWsConnection<MonitoringSample>(
'/ws/monitor', "/ws/monitor",
pushMonitor, pushMonitor,
setWsStatus, setWsStatus,
selectedGpuIndex, selectedGpuIndex,
+95 -45
View File
@@ -1,7 +1,12 @@
import { create } from 'zustand'; import { create } from "zustand";
import type { CurveState, GpuInfo, MonitoringSample, VFPoint } from '../types'; import type {
import { api } from '../api/client'; CurveState,
import { toast } from 'sonner'; GpuInfo,
MonitoringSample,
VFPoint,
} from "../types.js";
import { api } from "../api/client.js";
import { toast } from "sonner";
const HISTORY_SIZE = 120; // ~60s at 2Hz const HISTORY_SIZE = 120; // ~60s at 2Hz
@@ -50,7 +55,9 @@ interface CurveStore {
resetAllDeltas: (onSuccess: () => void) => Promise<void>; resetAllDeltas: (onSuccess: () => void) => Promise<void>;
// Selection actions // Selection actions
selectPoint: (index: number, multi?: boolean) => void; selectPoint: (index: number) => void;
/** Toggle a point in the selection (Shift/Ctrl+click); updates the anchor. */
togglePoint: (index: number) => void;
selectRange: (indices: number[]) => void; selectRange: (indices: number[]) => void;
clearSelection: () => void; clearSelection: () => void;
/** /**
@@ -80,7 +87,16 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
setAvailableGpus: (availableGpus) => set({ availableGpus }), setAvailableGpus: (availableGpus) => set({ availableGpus }),
setSelectedGpuIndex: (selectedGpuIndex) => { setSelectedGpuIndex: (selectedGpuIndex) => {
set({ selectedGpuIndex, curve: null, gpuInfo: null, monitor: null, monitorHistory: [], pendingDeltas: new Map(), selectedPoints: new Set(), anchorPoint: null }); set({
selectedGpuIndex,
curve: null,
gpuInfo: null,
monitor: null,
monitorHistory: [],
pendingDeltas: new Map(),
selectedPoints: new Set(),
anchorPoint: null,
});
}, },
setCurve: (curve) => set({ curve }), setCurve: (curve) => set({ curve }),
setGpuInfo: (gpuInfo) => set({ gpuInfo }), setGpuInfo: (gpuInfo) => set({ gpuInfo }),
@@ -95,7 +111,7 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
stageEdit: (pointIndex, deltaKhz) => stageEdit: (pointIndex, deltaKhz) =>
set((s) => { set((s) => {
const next = new Map(s.pendingDeltas); const next = new Map(s.pendingDeltas);
const point = s.curve?.points.find(p => p.index === pointIndex); const point = s.curve?.points.find((p) => p.index === pointIndex);
if (point && point.delta_khz === deltaKhz) { if (point && point.delta_khz === deltaKhz) {
next.delete(pointIndex); next.delete(pointIndex);
} else { } else {
@@ -108,7 +124,7 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
set((s) => { set((s) => {
const next = new Map(s.pendingDeltas); const next = new Map(s.pendingDeltas);
edits.forEach((deltaKhz, index) => { edits.forEach((deltaKhz, index) => {
const point = s.curve?.points.find(p => p.index === index); const point = s.curve?.points.find((p) => p.index === index);
if (point && point.delta_khz === deltaKhz) { if (point && point.delta_khz === deltaKhz) {
next.delete(index); next.delete(index);
} else { } else {
@@ -128,27 +144,39 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
}), }),
discardEdits: () => discardEdits: () =>
set({ pendingDeltas: new Map(), selectedPoints: new Set(), anchorPoint: null }), set({
pendingDeltas: new Map(),
selectedPoints: new Set(),
anchorPoint: null,
}),
applyEdits: async (onSuccess) => { applyEdits: async (onSuccess) => {
const { pendingDeltas, selectedGpuIndex } = get(); const { pendingDeltas, selectedGpuIndex } = get();
if (pendingDeltas.size === 0) return; if (pendingDeltas.size === 0) return;
// Convert Map to plain record for the API // Convert Map to plain record for the API
const deltas: Record<number, number> = {}; const deltas: Record<number, number> = Object.fromEntries(pendingDeltas);
pendingDeltas.forEach((v, k) => { deltas[k] = v; });
try { try {
const result = await api.writeDeltas(deltas, selectedGpuIndex); const result = await api.writeDeltas(deltas, selectedGpuIndex);
set({ pendingDeltas: new Map(), selectedPoints: new Set(), activeProfile: null }); set({
pendingDeltas: new Map(),
selectedPoints: new Set(),
activeProfile: null,
});
if (result?.freq_warnings?.length) { if (result?.freq_warnings?.length) {
toast.warning('Curve applied — driver clamped some points to 0 MHz (negative freq delta)'); toast.warning(
"Curve applied — driver clamped some points to 0 MHz (negative freq delta)",
);
} else { } else {
toast.success('Curve applied successfully'); toast.success("Curve applied successfully");
} }
onSuccess(); onSuccess();
} catch (e: any) { } catch (e: unknown) {
toast.error('Failed to apply curve: ' + (e.message || String(e))); toast.error(
"Failed to apply curve: " +
(e instanceof Error ? e.message : String(e)),
);
} }
}, },
@@ -156,35 +184,48 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
const { selectedGpuIndex } = get(); const { selectedGpuIndex } = get();
try { try {
await api.resetCurve(selectedGpuIndex); await api.resetCurve(selectedGpuIndex);
set({ pendingDeltas: new Map(), selectedPoints: new Set(), activeProfile: null }); set({
toast.success('Curve reset to hardware defaults'); pendingDeltas: new Map(),
selectedPoints: new Set(),
activeProfile: null,
});
toast.success("Curve reset to hardware defaults");
onSuccess(); onSuccess();
} catch (e: any) { } catch (e: unknown) {
toast.error('Failed to reset curve: ' + (e.message || String(e))); toast.error(
"Failed to reset curve: " +
(e instanceof Error ? e.message : String(e)),
);
} }
}, },
selectPoint: (index, multi = false) => selectPoint: (index) =>
set((s) => {
const next = new Set(s.selectedPoints);
let anchor: number | null = index;
if (next.size === 1 && next.has(index)) {
next.clear();
anchor = null;
} else {
next.clear();
next.add(index);
}
return { selectedPoints: next, anchorPoint: anchor };
}),
togglePoint: (index) =>
set((s) => { set((s) => {
const next = new Set(s.selectedPoints); const next = new Set(s.selectedPoints);
let anchor = s.anchorPoint; let anchor = s.anchorPoint;
if (multi) { if (next.has(index)) {
if (next.has(index)) { next.delete(index);
next.delete(index); if (anchor === index) {
if (anchor === index) anchor = next.size > 0 ? [...next].at(-1)! : null; const last = [...next].at(-1);
} else { anchor = last ?? null;
next.add(index);
anchor = index; // last explicitly added point is the new anchor
} }
} else { } else {
if (next.size === 1 && next.has(index)) { next.add(index);
next.clear(); anchor = index; // last explicitly added point is the new anchor
anchor = null;
} else {
next.clear();
next.add(index);
anchor = index;
}
} }
return { selectedPoints: next, anchorPoint: anchor }; return { selectedPoints: next, anchorPoint: anchor };
}), }),
@@ -193,29 +234,38 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
// Bulk selects don't change the anchor — preserve it if still in the new selection. // Bulk selects don't change the anchor — preserve it if still in the new selection.
set((s) => { set((s) => {
const next = new Set(indices); const next = new Set(indices);
const anchor = s.anchorPoint !== null && next.has(s.anchorPoint) ? s.anchorPoint : null; const anchor =
s.anchorPoint !== null && next.has(s.anchorPoint)
? s.anchorPoint
: null;
return { selectedPoints: next, anchorPoint: anchor }; return { selectedPoints: next, anchorPoint: anchor };
}), }),
clearSelection: () => clearSelection: () => set({ selectedPoints: new Set(), anchorPoint: null }),
set({ selectedPoints: new Set(), anchorPoint: null }),
flattenToAnchor: () => { flattenToAnchor: () => {
const { selectedPoints, anchorPoint, pendingDeltas, curve, stageMultiEdit } = get(); const {
selectedPoints,
anchorPoint,
pendingDeltas,
curve,
stageMultiEdit,
} = get();
if (selectedPoints.size < 2 || !curve) return; if (selectedPoints.size < 2 || !curve) return;
const anchor = anchorPoint !== null && selectedPoints.has(anchorPoint) const anchor =
? anchorPoint anchorPoint !== null && selectedPoints.has(anchorPoint)
: Math.min(...selectedPoints); ? anchorPoint
: Math.min(...selectedPoints);
const anchorPt = curve.points.find(p => p.index === anchor); const anchorPt = curve.points.find((p) => p.index === anchor);
if (!anchorPt) return; if (!anchorPt) return;
const anchorPendingDelta = pendingDeltas.get(anchor) ?? anchorPt.delta_khz; const anchorPendingDelta = pendingDeltas.get(anchor) ?? anchorPt.delta_khz;
const anchorEffectiveKhz = anchorPt.freq_khz + anchorPendingDelta; const anchorEffectiveKhz = anchorPt.freq_khz + anchorPendingDelta;
const edits = new Map<number, number>(); const edits = new Map<number, number>();
for (const idx of selectedPoints) { for (const idx of selectedPoints) {
const pt = curve.points.find(p => p.index === idx); const pt = curve.points.find((p) => p.index === idx);
if (!pt) continue; if (!pt) continue;
edits.set(idx, anchorEffectiveKhz - pt.freq_khz); edits.set(idx, anchorEffectiveKhz - pt.freq_khz);
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import type { VFPoint } from '../types'; import type { VFPoint } from '../types.js';
/** /**
* Approximate reference frequency (MHz) for a point: effective − delta. * Approximate reference frequency (MHz) for a point: effective − delta.
+124 -58
View File
@@ -31,6 +31,7 @@ First-time / diagnostic commands (bypass server, escalate to root):
import argparse import argparse
import json import json
import logging
import os import os
import struct import struct
import sys import sys
@@ -48,6 +49,8 @@ from .nvapi.constants import (
VFP_STRIDE, VFP_STRIDE,
) )
log = logging.getLogger("nvcurve.cli")
# ── Utilities ───────────────────────────────────────────────────────────────── # ── Utilities ─────────────────────────────────────────────────────────────────
@@ -69,8 +72,8 @@ def parse_range(s: str):
raise argparse.ArgumentTypeError(f"Expected A-B format, got '{s}'") raise argparse.ArgumentTypeError(f"Expected A-B format, got '{s}'")
try: try:
a, b = int(parts[0]), int(parts[1]) a, b = int(parts[0]), int(parts[1])
except ValueError: except ValueError as exc:
raise argparse.ArgumentTypeError(f"Non-integer in range: '{s}'") raise argparse.ArgumentTypeError(f"Non-integer in range: '{s}'") from exc
if a > b: if a > b:
raise argparse.ArgumentTypeError(f"Start > end in range: {a}-{b}") raise argparse.ArgumentTypeError(f"Start > end in range: {a}-{b}")
if a < 0 or b >= CT_POINTS: if a < 0 or b >= CT_POINTS:
@@ -98,7 +101,7 @@ def print_curve(points, offsets, voltage, domains=None, full=False):
current_idx = None current_idx = None
if voltage: if voltage:
for i, (f, v) in enumerate(points): for i, (_f, v) in enumerate(points):
if v > 0 and abs(v - voltage) < 10000: if v > 0 and abs(v - voltage) < 10000:
current_idx = i current_idx = i
break break
@@ -214,7 +217,7 @@ def print_curve(points, offsets, voltage, domains=None, full=False):
if offsets: if offsets:
nonzero = sum(1 for o in offsets if o != 0) nonzero = sum(1 for o in offsets if o != 0)
if nonzero > 0: if nonzero > 0:
vals = set(o for o in offsets if o != 0) vals = {o for o in offsets if o != 0}
if len(vals) == 1: if len(vals) == 1:
print( print(
f"Global offset: {next(iter(vals)) / 1000:+.0f} MHz " f"Global offset: {next(iter(vals)) / 1000:+.0f} MHz "
@@ -316,7 +319,7 @@ def run_diagnostics(gpu, gpu_name, gpu_index: int = 0):
("SetClockBoostTable", FUNC["SetClockBoostTable"], CT_SIZE, 1, True), ("SetClockBoostTable", FUNC["SetClockBoostTable"], CT_SIZE, 1, True),
] ]
for name, fid, size, ver, needs_mask in probes: for name, fid, size, ver, _needs_mask in probes:
ptr = query_interface(fid) ptr = query_interface(fid)
resolved = "resolved" if ptr else "NOT FOUND" resolved = "resolved" if ptr else "NOT FOUND"
print(f" {name:30s} 0x{fid:08X} size=0x{size:04X} ver={ver} {resolved}") print(f" {name:30s} 0x{fid:08X} size=0x{size:04X} ver={ver} {resolved}")
@@ -420,8 +423,8 @@ def _open_browser_as_user(url: str) -> None:
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
return return
except Exception: except Exception as exc:
pass log.debug("runuser xdg-open failed, falling back to webbrowser: %s", exc)
import webbrowser import webbrowser
webbrowser.open(url) webbrowser.open(url)
@@ -445,7 +448,7 @@ def require_root():
] ]
try: try:
# PYTHONDONTWRITEBYTECODE prevents root-owned __pycache__ in site-packages. # PYTHONDONTWRITEBYTECODE prevents root-owned __pycache__ in site-packages.
os.execvp( os.execvp( # noqa: S606 — intentional re-exec via sudo
"sudo", "sudo",
[ [
"sudo", "sudo",
@@ -501,7 +504,7 @@ def _safe_host(host: str, cfg: Config) -> str:
0.0.0.0 (bind-all) is silently remapped to 127.0.0.1 — it's a valid local 0.0.0.0 (bind-all) is silently remapped to 127.0.0.1 — it's a valid local
server address, just not usable as a client connection target. server address, just not usable as a client connection target.
""" """
if host in ("0.0.0.0", "::"): if host in ("0.0.0.0", "::"): # noqa: S104 — comparison only, no binding here
return "127.0.0.1" return "127.0.0.1"
if host not in _ALLOWED_HOSTS: if host not in _ALLOWED_HOSTS:
print( print(
@@ -514,7 +517,7 @@ def _safe_host(host: str, cfg: Config) -> str:
def _log_file() -> str: def _log_file() -> str:
return "/var/log/nvcurve.log" if os.geteuid() == 0 else "/tmp/nvcurve.log" return "/var/log/nvcurve.log" if os.geteuid() == 0 else "/tmp/nvcurve.log" # noqa: S108
def _read_server_info() -> dict | None: def _read_server_info() -> dict | None:
@@ -737,8 +740,14 @@ def cmd_inspect(args):
def cmd_write(args): def cmd_write(args):
delta_khz = int(args.delta * 1000) try:
max_delta_khz = int(args.max_delta * 1000) if args.max_delta is not None else None delta_khz = int(args.delta * 1000)
max_delta_khz = (
int(args.max_delta * 1000) if args.max_delta is not None else None
)
except (TypeError, ValueError, OverflowError) as exc:
print(f"Error: invalid numeric argument: {exc}", file=sys.stderr)
sys.exit(1)
point_deltas = {} point_deltas = {}
if args.reset: if args.reset:
@@ -837,14 +846,15 @@ def cmd_write(args):
print(f"Write OK — {len(point_deltas)} point(s) updated.") print(f"Write OK — {len(point_deltas)} point(s) updated.")
try: try:
curve_state = None
if not args.glob: if not args.glob:
curve_state, _ = read_curve(gpu, gpu_name) curve_state, _ = read_curve(gpu, gpu_name)
if curve_state: if curve_state:
vfp_freqs = [p.freq_khz for p in curve_state.points] vfp_freqs = [p.freq_khz for p in curve_state.points]
for w in check_negative_freq_warnings(point_deltas, vfp_freqs, []): for w in check_negative_freq_warnings(point_deltas, vfp_freqs, []):
print(f"WARNING: {w}") print(f"WARNING: {w}")
except Exception: except Exception as exc:
pass log.debug("Post-write curve check failed: %s", exc)
def cmd_verify(args): def cmd_verify(args):
@@ -855,7 +865,11 @@ def cmd_verify(args):
from .hal.snapshot import save as snapshot_save from .hal.snapshot import save as snapshot_save
from .hal.vfcurve import read_clock_offsets, write_offsets from .hal.vfcurve import read_clock_offsets, write_offsets
delta_khz = int(args.delta * 1000) try:
delta_khz = int(args.delta * 1000)
except (TypeError, ValueError, OverflowError) as exc:
print(f"Error: invalid numeric argument: {exc}", file=sys.stderr)
sys.exit(1)
if args.point is not None: if args.point is not None:
points = [args.point] points = [args.point]
@@ -1016,8 +1030,11 @@ def _profile_config_write(key: str, value) -> None:
data.pop(key, None) data.pop(key, None)
else: else:
data[key] = value data[key] = value
with open(_PERSISTENT_CONFIG_FILE, "w") as f: try:
_json.dump(data, f, indent=2) with open(_PERSISTENT_CONFIG_FILE, "w") as f:
_json.dump(data, f, indent=2)
except OSError as exc:
raise RuntimeError(f"Cannot write {_PERSISTENT_CONFIG_FILE}: {exc}") from exc
def _gpu_stable_key_offline(gpu_index: int) -> str | None: def _gpu_stable_key_offline(gpu_index: int) -> str | None:
@@ -1066,8 +1083,11 @@ def _profile_config_set_default(gpu_index: int, name: str | None) -> None:
profiles[gpu_key] = name profiles[gpu_key] = name
if not profiles: if not profiles:
data.pop("auto_load_profiles", None) data.pop("auto_load_profiles", None)
with open(_PERSISTENT_CONFIG_FILE, "w") as f: try:
_json.dump(data, f, indent=2) with open(_PERSISTENT_CONFIG_FILE, "w") as f:
_json.dump(data, f, indent=2)
except OSError as exc:
raise RuntimeError(f"Cannot write {_PERSISTENT_CONFIG_FILE}: {exc}") from exc
def cmd_profile(args): def cmd_profile(args):
@@ -1094,8 +1114,8 @@ def cmd_profile(args):
profiles.append( profiles.append(
{"name": name, "curve_deltas": p.get("curve_deltas", {})} {"name": name, "curve_deltas": p.get("curve_deltas", {})}
) )
except Exception: except Exception as exc:
pass log.debug("Skipping unreadable profile %s: %s", path, exc)
if not profiles: if not profiles:
print("No profiles found.") print("No profiles found.")
return return
@@ -1117,7 +1137,7 @@ def cmd_profile(args):
require_root() require_root()
try: try:
_profile_config_set_default(gpu_index, None if clearing else args.name) _profile_config_set_default(gpu_index, None if clearing else args.name)
except ValueError as e: except (ValueError, RuntimeError) as e:
print(f"Error: {e}", file=sys.stderr) print(f"Error: {e}", file=sys.stderr)
return return
if clearing: if clearing:
@@ -1205,7 +1225,14 @@ def cmd_profile(args):
errs.append(f"Power limit: {msg}") errs.append(f"Power limit: {msg}")
if profile.curve_deltas: if profile.curve_deltas:
deltas = {int(k): v for k, v in profile.curve_deltas.items()} try:
deltas = {int(k): v for k, v in profile.curve_deltas.items()}
except ValueError:
print(
f"Profile '{args.name}' has invalid curve point keys.",
file=sys.stderr,
)
sys.exit(1)
errors = validate_write(deltas, default_config.max_delta_khz) errors = validate_write(deltas, default_config.max_delta_khz)
if errors: if errors:
errs.append("Curve: " + "; ".join(errors)) errs.append("Curve: " + "; ".join(errors))
@@ -1328,7 +1355,11 @@ def cmd_setup(args):
"""One-shot hardware compatibility check: diag → read → write-verify → restore.""" """One-shot hardware compatibility check: diag → read → write-verify → restore."""
explicit_point = getattr(args, "point", None) explicit_point = getattr(args, "point", None)
verify_delta_mhz = getattr(args, "delta", 5.0) or 5.0 verify_delta_mhz = getattr(args, "delta", 5.0) or 5.0
verify_delta_khz = int(verify_delta_mhz * 1000) try:
verify_delta_khz = int(verify_delta_mhz * 1000)
except (TypeError, ValueError, OverflowError) as exc:
print(f"Error: invalid numeric argument: {exc}", file=sys.stderr)
sys.exit(1)
require_root() require_root()
@@ -1452,13 +1483,20 @@ def cmd_setup(args):
print() print()
print("Step 4/4 Restoring snapshot") print("Step 4/4 Restoring snapshot")
print() print()
ok = snapshot_restore(gpu, default_config.snapshot_dir, snap_path) if snap_path is None:
if ok:
print(" Hardware state restored to baseline.")
else:
print( print(
" WARNING: Restore failed. Run: nvcurve snapshot restore", file=sys.stderr " WARNING: Snapshot save failed — cannot restore baseline.",
file=sys.stderr,
) )
else:
ok = snapshot_restore(gpu, default_config.snapshot_dir, snap_path)
if ok:
print(" Hardware state restored to baseline.")
else:
print(
" WARNING: Restore failed. Run: nvcurve snapshot restore",
file=sys.stderr,
)
print() print()
print(sep) print(sep)
@@ -1509,24 +1547,36 @@ def cmd_service(args):
"WantedBy=multi-user.target\n" "WantedBy=multi-user.target\n"
) )
with open(unit_path, "w") as f: try:
f.write(unit) with open(unit_path, "w") as f:
f.write(unit)
except OSError as exc:
print(f"Failed to write {unit_path}: {exc}", file=sys.stderr)
return
print(f"Unit file written to {unit_path}") print(f"Unit file written to {unit_path}")
# Write persistent config. # Write persistent config.
os.makedirs("/etc/nvcurve", exist_ok=True) try:
os.makedirs("/etc/nvcurve", exist_ok=True)
except OSError as exc:
print(f"Failed to create /etc/nvcurve: {exc}", file=sys.stderr)
return
persistent_cfg: dict = {} persistent_cfg: dict = {}
try: try:
with open(_PERSISTENT_CONFIG_FILE) as f: with open(_PERSISTENT_CONFIG_FILE) as f:
persistent_cfg = json.load(f) persistent_cfg = json.load(f)
except Exception: except Exception as exc:
pass log.debug("Could not read persistent config: %s", exc)
host = getattr(args, "host", "127.0.0.1") host = getattr(args, "host", "127.0.0.1")
port = getattr(args, "port", 8042) port = getattr(args, "port", 8042)
auto_serve = getattr(args, "auto_serve", False) auto_serve = getattr(args, "auto_serve", False)
persistent_cfg.update({"host": host, "port": port, "auto_serve": auto_serve}) persistent_cfg.update({"host": host, "port": port, "auto_serve": auto_serve})
with open(_PERSISTENT_CONFIG_FILE, "w") as f: try:
json.dump(persistent_cfg, f, indent=2) with open(_PERSISTENT_CONFIG_FILE, "w") as f:
json.dump(persistent_cfg, f, indent=2)
except OSError as exc:
print(f"Failed to write {_PERSISTENT_CONFIG_FILE}: {exc}", file=sys.stderr)
return
print(f"Persistent config written to {_PERSISTENT_CONFIG_FILE}") print(f"Persistent config written to {_PERSISTENT_CONFIG_FILE}")
if auto_serve: if auto_serve:
print(f" Web server will auto-start on boot at {host}:{port}") print(f" Web server will auto-start on boot at {host}:{port}")
@@ -1538,12 +1588,8 @@ def cmd_service(args):
try: try:
subprocess.run(["systemctl", "daemon-reload"], check=True) subprocess.run(["systemctl", "daemon-reload"], check=True)
was_active = ( probe = subprocess.run(["systemctl", "is-active", "--quiet", "nvcurve"])
subprocess.run( was_active = probe.returncode == 0
["systemctl", "is-active", "--quiet", "nvcurve"],
).returncode
== 0
)
subprocess.run(["systemctl", "enable", "--now", "nvcurve"], check=True) subprocess.run(["systemctl", "enable", "--now", "nvcurve"], check=True)
print("Service enabled and started.") print("Service enabled and started.")
@@ -1560,7 +1606,7 @@ def cmd_service(args):
print(" systemctl status nvcurve") print(" systemctl status nvcurve")
print(" journalctl -u nvcurve -f") print(" journalctl -u nvcurve -f")
print(" nvcurve service uninstall") print(" nvcurve service uninstall")
except subprocess.CalledProcessError as e: except (subprocess.CalledProcessError, FileNotFoundError) as e:
print(f"systemctl failed: {e}", file=sys.stderr) print(f"systemctl failed: {e}", file=sys.stderr)
elif action == "uninstall": elif action == "uninstall":
@@ -1672,13 +1718,17 @@ def cmd_service(args):
require_root() require_root()
import subprocess import subprocess
os.makedirs("/etc/nvcurve", exist_ok=True) try:
os.makedirs("/etc/nvcurve", exist_ok=True)
except OSError as exc:
print(f"Failed to create /etc/nvcurve: {exc}", file=sys.stderr)
return
pcfg: dict = {} pcfg: dict = {}
try: try:
with open(_PERSISTENT_CONFIG_FILE) as f: with open(_PERSISTENT_CONFIG_FILE) as f:
pcfg = json.load(f) pcfg = json.load(f)
except Exception: except Exception as exc:
pass log.debug("Could not read persistent config: %s", exc)
if hasattr(args, "auto_serve") and args.auto_serve is not None: if hasattr(args, "auto_serve") and args.auto_serve is not None:
pcfg["auto_serve"] = args.auto_serve pcfg["auto_serve"] = args.auto_serve
@@ -1687,8 +1737,12 @@ def cmd_service(args):
if hasattr(args, "port") and args.port is not None: if hasattr(args, "port") and args.port is not None:
pcfg["port"] = args.port pcfg["port"] = args.port
with open(_PERSISTENT_CONFIG_FILE, "w") as f: try:
json.dump(pcfg, f, indent=2) with open(_PERSISTENT_CONFIG_FILE, "w") as f:
json.dump(pcfg, f, indent=2)
except OSError as exc:
print(f"Failed to write {_PERSISTENT_CONFIG_FILE}: {exc}", file=sys.stderr)
return
print(f"Config updated ({_PERSISTENT_CONFIG_FILE}):") print(f"Config updated ({_PERSISTENT_CONFIG_FILE}):")
print(f" auto-serve: {'on' if pcfg.get('auto_serve', False) else 'off'}") print(f" auto-serve: {'on' if pcfg.get('auto_serve', False) else 'off'}")
print(f" host: {pcfg.get('host', '127.0.0.1')}") print(f" host: {pcfg.get('host', '127.0.0.1')}")
@@ -1715,8 +1769,12 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None:
# --direct: skip daemon round-trip (used when the daemon itself spawns us). # --direct: skip daemon round-trip (used when the daemon itself spawns us).
if getattr(args, "direct", False): if getattr(args, "direct", False):
require_root() require_root()
with open(_SERVER_INFO_FILE, "w") as f: try:
json.dump({"pid": os.getpid(), "host": host, "port": port}, f) with open(_SERVER_INFO_FILE, "w") as f:
json.dump({"pid": os.getpid(), "host": host, "port": port}, f)
except OSError as exc:
print(f"Failed to write {_SERVER_INFO_FILE}: {exc}", file=sys.stderr)
return
try: try:
from .server import run as server_run from .server import run as server_run
@@ -1773,8 +1831,12 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None:
cmd += ["--gpu", str(args.gpu_index)] cmd += ["--gpu", str(args.gpu_index)]
log_path = _log_file() log_path = _log_file()
print("Starting nvcurve server in background...") print("Starting nvcurve server in background...")
with open(log_path, "a") as lf: try:
p = subprocess.Popen(cmd, stdout=lf, stderr=lf, start_new_session=True) with open(log_path, "a") as lf:
p = subprocess.Popen(cmd, stdout=lf, stderr=lf, start_new_session=True)
except OSError as exc:
print(f"Failed to open log file {log_path}: {exc}", file=sys.stderr)
return
print(f"Server starting (PID {p.pid}). Logs: {log_path}") print(f"Server starting (PID {p.pid}). Logs: {log_path}")
if open_browser: if open_browser:
time.sleep(1.5) time.sleep(1.5)
@@ -1782,8 +1844,12 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None:
return return
# Foreground mode — write info file so clients can discover host:port. # Foreground mode — write info file so clients can discover host:port.
with open(_SERVER_INFO_FILE, "w") as f: try:
json.dump({"pid": os.getpid(), "host": host, "port": port}, f) with open(_SERVER_INFO_FILE, "w") as f:
json.dump({"pid": os.getpid(), "host": host, "port": port}, f)
except OSError as exc:
print(f"Failed to write {_SERVER_INFO_FILE}: {exc}", file=sys.stderr)
return
try: try:
from .server import run as server_run from .server import run as server_run
@@ -2095,8 +2161,8 @@ def main():
if "fan_curves" in data: if "fan_curves" in data:
# Per-GPU active fan curves, restored on server startup. # Per-GPU active fan curves, restored on server startup.
cfg.fan_curves = dict(data["fan_curves"]) cfg.fan_curves = dict(data["fan_curves"])
except Exception: except Exception as exc:
pass log.debug("Could not load user config: %s", exc)
base_url = args.server or _discover_server_url(cfg) base_url = args.server or _discover_server_url(cfg)
client = NvCurveClient(base=base_url, gpu_index=getattr(args, "gpu_index", 0)) client = NvCurveClient(base=base_url, gpu_index=getattr(args, "gpu_index", 0))
@@ -2148,8 +2214,8 @@ def main():
if os.path.exists(_SERVER_INFO_FILE): if os.path.exists(_SERVER_INFO_FILE):
try: try:
os.remove(_SERVER_INFO_FILE) os.remove(_SERVER_INFO_FILE)
except OSError: except OSError as exc:
pass log.debug("Could not remove %s: %s", _SERVER_INFO_FILE, exc)
except ApiError as e: except ApiError as e:
if e.status_code == 401: if e.status_code == 401:
print( print(
+2 -1
View File
@@ -209,8 +209,9 @@ async def _serve_socket(auto_serve: bool = False) -> None:
# regular user and talks to this root daemon over the socket. 0o666 is # regular user and talks to this root daemon over the socket. 0o666 is
# intentional (standard for /run daemon sockets). # intentional (standard for /run daemon sockets).
# pi-lens-ignore: S103 # pi-lens-ignore: S103
_SOCKET_MODE = 0o666
os.chmod( os.chmod(
SOCKET_PATH, 0o666 SOCKET_PATH, _SOCKET_MODE
) # nosemgrep: python.lang.security.audit.insecure-file-permissions.insecure-file-permissions ) # nosemgrep: python.lang.security.audit.insecure-file-permissions.insecure-file-permissions
log.info("Daemon listening on %s", SOCKET_PATH) log.info("Daemon listening on %s", SOCKET_PATH)
+29 -11
View File
@@ -9,14 +9,19 @@ Uses NVML (via pynvml) for all operations:
import ctypes import ctypes
import logging import logging
from typing import List, Optional from typing import Any
try: try:
import pynvml import pynvml as _pynvml_import
_NVML_AVAILABLE = True _NVML_AVAILABLE = True
except ImportError: except ImportError:
_pynvml_import = None
_NVML_AVAILABLE = False _NVML_AVAILABLE = False
# Aliased as Any so attribute access is not flagged when the import failed.
pynvml: Any = _pynvml_import
log = logging.getLogger("nvcurve.hal.fans") log = logging.getLogger("nvcurve.hal.fans")
# We use fan index 0 (first/primary fan) for all operations. # We use fan index 0 (first/primary fan) for all operations.
@@ -35,7 +40,7 @@ def get_fan_info(gpu_index: int = 0) -> dict:
Returns None values on failure. Returns None values on failure.
""" """
out = { out: dict[str, float | None] = {
"fan_pct": None, "fan_pct": None,
"fan_mode": None, "fan_mode": None,
"min_fan_pct": None, "min_fan_pct": None,
@@ -75,7 +80,10 @@ def get_fan_info(gpu_index: int = 0) -> dict:
def set_fan_speed(gpu_index: int, pct: int) -> tuple[bool, str]: def set_fan_speed(gpu_index: int, pct: int) -> tuple[bool, str]:
"""Set fan speed to a percentage (0-100) on the primary fan.""" """Set fan speed to a percentage (0-100) on the primary fan."""
pct = max(0, min(100, int(pct))) try:
pct = max(0, min(100, int(pct)))
except (TypeError, ValueError):
return False, "Invalid fan speed"
if not _NVML_AVAILABLE: if not _NVML_AVAILABLE:
return False, "NVML not available" return False, "NVML not available"
try: try:
@@ -102,7 +110,9 @@ def reset_fan(gpu_index: int = 0) -> tuple[bool, str]:
try: try:
ret = subprocess.run( ret = subprocess.run(
["nvidia-smi", "-i", str(gpu_index), "-fan", "default"], ["nvidia-smi", "-i", str(gpu_index), "-fan", "default"],
capture_output=True, text=True, timeout=10, capture_output=True,
text=True,
timeout=10,
) )
if ret.returncode == 0: if ret.returncode == 0:
return True, "OK" return True, "OK"
@@ -123,19 +133,21 @@ def reset_fan(gpu_index: int = 0) -> tuple[bool, str]:
return False, f"Failed to reset fan: {exc}" return False, f"Failed to reset fan: {exc}"
def get_temp(gpu_index: int = 0) -> Optional[float]: def get_temp(gpu_index: int = 0) -> float | None:
"""Read current GPU temperature in °C.""" """Read current GPU temperature in °C."""
if not _NVML_AVAILABLE: if not _NVML_AVAILABLE:
return None return None
try: try:
handle = _get_handle(gpu_index) handle = _get_handle(gpu_index)
return float(pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU)) return float(
pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU)
)
except pynvml.NVMLError as exc: except pynvml.NVMLError as exc:
log.debug("get_temp: %s", exc) log.debug("get_temp: %s", exc)
return None return None
def interpolate_fan_speed(curve: List[dict], temp_c: float) -> Optional[int]: def interpolate_fan_speed(curve: list[dict], temp_c: float) -> int | None:
"""Interpolate target fan speed from a curve at a given temperature. """Interpolate target fan speed from a curve at a given temperature.
curve: list of {temp_c: int, fan_pct: int} sorted by temp_c curve: list of {temp_c: int, fan_pct: int} sorted by temp_c
@@ -144,7 +156,10 @@ def interpolate_fan_speed(curve: List[dict], temp_c: float) -> Optional[int]:
if not curve or len(curve) < 2: if not curve or len(curve) < 2:
return None return None
temp = float(temp_c) try:
temp = float(temp_c)
except (TypeError, ValueError):
return None
# Find the two surrounding points # Find the two surrounding points
for i in range(len(curve) - 1): for i in range(len(curve) - 1):
@@ -157,7 +172,10 @@ def interpolate_fan_speed(curve: List[dict], temp_c: float) -> Optional[int]:
if t0 <= temp <= t1: if t0 <= temp <= t1:
fraction = (temp - t0) / (t1 - t0) fraction = (temp - t0) / (t1 - t0)
result = f0 + fraction * (f1 - f0) result = f0 + fraction * (f1 - f0)
return max(0, min(100, int(round(result)))) try:
return max(0, min(100, int(round(result))))
except (TypeError, ValueError):
return None
# Outside range: clamp to first or last point # Outside range: clamp to first or last point
if temp <= curve[0]["temp_c"]: if temp <= curve[0]["temp_c"]:
@@ -165,7 +183,7 @@ def interpolate_fan_speed(curve: List[dict], temp_c: float) -> Optional[int]:
return max(0, min(100, curve[-1]["fan_pct"])) return max(0, min(100, curve[-1]["fan_pct"]))
def validate_curve(curve: List[dict]) -> tuple[bool, str]: def validate_curve(curve: list[dict]) -> tuple[bool, str]:
"""Validate a fan curve. """Validate a fan curve.
Returns (True, "OK") or (False, error_message). Returns (True, "OK") or (False, error_message).
+38 -20
View File
@@ -1,12 +1,17 @@
"""GPU discovery and initialization.""" """GPU discovery and initialization."""
import contextlib
import ctypes import ctypes
import logging
import sys import sys
from typing import Any
from ..nvapi.bootstrap import query_interface from ..nvapi.bootstrap import query_interface
from ..nvapi.constants import FUNC from ..nvapi.constants import FUNC
from ..nvapi.types import GpuInfo from ..nvapi.types import GpuInfo
log = logging.getLogger("nvcurve.hal.gpu")
def init_nvapi() -> None: def init_nvapi() -> None:
"""Initialize NvAPI. Must be called before any GPU operations.""" """Initialize NvAPI. Must be called before any GPU operations."""
@@ -19,7 +24,10 @@ def enumerate_gpus() -> tuple[ctypes.Array, int]:
"""Return (gpu_handles_array, count). Exits if no GPUs found.""" """Return (gpu_handles_array, count). Exits if no GPUs found."""
gpus = (ctypes.c_void_p * 64)() gpus = (ctypes.c_void_p * 64)()
ngpu = ctypes.c_int32() ngpu = ctypes.c_int32()
query_interface(FUNC["EnumPhysicalGPUs"])(ctypes.byref(gpus), ctypes.byref(ngpu)) enum_fn = query_interface(FUNC["EnumPhysicalGPUs"])
if enum_fn is None:
raise RuntimeError("NvAPI function EnumPhysicalGPUs not available")
enum_fn(ctypes.byref(gpus), ctypes.byref(ngpu))
if ngpu.value == 0: if ngpu.value == 0:
print("No NVIDIA GPUs found") print("No NVIDIA GPUs found")
sys.exit(1) sys.exit(1)
@@ -29,7 +37,10 @@ def enumerate_gpus() -> tuple[ctypes.Array, int]:
def get_gpu_name(gpu) -> str: def get_gpu_name(gpu) -> str:
"""Return the full name string for a GPU handle.""" """Return the full name string for a GPU handle."""
name_buf = ctypes.create_string_buffer(256) name_buf = ctypes.create_string_buffer(256)
query_interface(FUNC["GetFullName"])(gpu, name_buf) fn = query_interface(FUNC["GetFullName"])
if fn is None:
raise RuntimeError("NvAPI function GetFullName not available")
fn(gpu, name_buf)
return name_buf.value.decode(errors="replace") return name_buf.value.decode(errors="replace")
@@ -40,38 +51,45 @@ def discover_gpus() -> list[GpuInfo]:
infos = [] infos = []
try: try:
import pynvml import pynvml as _pynvml
pynvml.nvmlInit()
has_nvml = True _pynvml.nvmlInit()
except Exception: except Exception:
has_nvml = False _pynvml = None
# Aliased as Any so attribute access is not flagged when the import failed.
pynvml: Any = _pynvml
for i in range(count): for i in range(count):
name = get_gpu_name(gpus[i]) name = get_gpu_name(gpus[i])
uuid = None uuid = None
pci_bus_id = None pci_bus_id = None
if has_nvml: if pynvml is not None:
try: try:
handle = pynvml.nvmlDeviceGetHandleByIndex(i) handle = pynvml.nvmlDeviceGetHandleByIndex(i)
uuid = pynvml.nvmlDeviceGetUUID(handle) raw_uuid = pynvml.nvmlDeviceGetUUID(handle)
# NVML might return bytes # NVML might return bytes
if isinstance(uuid, bytes): if isinstance(raw_uuid, bytes):
uuid = uuid.decode('utf-8', errors='ignore') uuid = raw_uuid.decode("utf-8", errors="ignore")
elif raw_uuid is not None:
uuid = str(raw_uuid)
pci_info = pynvml.nvmlDeviceGetPciInfo(handle) pci_info = pynvml.nvmlDeviceGetPciInfo(handle)
# Parse something like "00000000:01:00.0" -> bus is 1 # Parse something like "00000000:01:00.0" -> bus is 1.
if isinstance(pci_info.bus, bytes): # PCI bus numbers are hex by convention (pynvml's field is an
pci_bus_id = int(pci_info.bus.decode('utf-8', errors='ignore'), 16) # int; the str/bytes branches are defensive).
bus = pci_info.bus
if isinstance(bus, bytes):
pci_bus_id = int(bus.decode("utf-8", errors="ignore"), 16)
elif isinstance(bus, str):
pci_bus_id = int(bus, 16)
else: else:
pci_bus_id = pci_info.bus pci_bus_id = int(bus)
except Exception: except Exception as exc:
pass log.debug("NVML query for GPU %d failed: %s", i, exc)
infos.append(GpuInfo(name=name, index=i, uuid=uuid, pci_bus_id=pci_bus_id)) infos.append(GpuInfo(name=name, index=i, uuid=uuid, pci_bus_id=pci_bus_id))
if has_nvml: if pynvml is not None:
try: with contextlib.suppress(Exception):
pynvml.nvmlShutdown() pynvml.nvmlShutdown()
except Exception:
pass
return infos return infos
+75 -38
View File
@@ -11,21 +11,26 @@ that are explicitly specified, leaving others unchanged on hardware.
""" """
import ctypes import ctypes
import subprocess
import logging import logging
from typing import Optional import subprocess
from typing import Any
try: try:
import pynvml import pynvml as _pynvml_import
_NVML_AVAILABLE = True _NVML_AVAILABLE = True
except ImportError: except ImportError:
_pynvml_import = None
_NVML_AVAILABLE = False _NVML_AVAILABLE = False
# Aliased as Any so attribute access is not flagged when the import failed.
pynvml: Any = _pynvml_import
log = logging.getLogger("nvcurve.hal.limits") log = logging.getLogger("nvcurve.hal.limits")
# ── NVML library / handle helpers ───────────────────────────────────────────── # ── NVML library / handle helpers ─────────────────────────────────────────────
_nvml_lib: Optional[ctypes.CDLL] = None _nvml_lib: ctypes.CDLL | None = None
def _nvml_cdll() -> ctypes.CDLL: def _nvml_cdll() -> ctypes.CDLL:
@@ -34,12 +39,12 @@ def _nvml_cdll() -> ctypes.CDLL:
if _nvml_lib is not None: if _nvml_lib is not None:
return _nvml_lib return _nvml_lib
# Prefer to reuse the library already loaded by pynvml to avoid dlopen races. # Prefer to reuse the library already loaded by pynvml to avoid dlopen races.
for attr in ("nvml", "_nvml"): # attribute name varies by pynvml version for attr in ("nvml", "_nvml"): # attribute name varies by pynvml version
mod = getattr(pynvml, attr, None) mod = getattr(pynvml, attr, None)
lib = getattr(mod, "_lib", None) or getattr(mod, "_nvmlLib", None) lib = getattr(mod, "_lib", None) or getattr(mod, "_nvmlLib", None)
if lib is not None: if lib is not None:
_nvml_lib = lib _nvml_lib = lib
return _nvml_lib return lib
_nvml_lib = ctypes.CDLL("libnvidia-ml.so.1") _nvml_lib = ctypes.CDLL("libnvidia-ml.so.1")
return _nvml_lib return _nvml_lib
@@ -53,9 +58,10 @@ def _get_handle(gpu_index: int):
# ── Power limit ─────────────────────────────────────────────────────────────── # ── Power limit ───────────────────────────────────────────────────────────────
def get_power_limit(gpu_index: int = 0) -> dict: def get_power_limit(gpu_index: int = 0) -> dict:
"""Return dict with power_limit_w, default_power_limit_w, min_power_limit_w, max_power_limit_w.""" """Return dict with power_limit_w, default_power_limit_w, min_power_limit_w, max_power_limit_w."""
out = { out: dict[str, int | None] = {
"power_limit_w": None, "power_limit_w": None,
"default_power_limit_w": None, "default_power_limit_w": None,
"min_power_limit_w": None, "min_power_limit_w": None,
@@ -71,8 +77,8 @@ def get_power_limit(gpu_index: int = 0) -> dict:
try: try:
default = pynvml.nvmlDeviceGetPowerManagementDefaultLimit(handle) default = pynvml.nvmlDeviceGetPowerManagementDefaultLimit(handle)
out["default_power_limit_w"] = default // 1000 out["default_power_limit_w"] = default // 1000
except Exception: except Exception as exc:
pass log.debug("nvmlDeviceGetPowerManagementDefaultLimit: %s", exc)
except Exception as exc: except Exception as exc:
log.warning("get_power_limit: %s", exc) log.warning("get_power_limit: %s", exc)
return out return out
@@ -89,7 +95,8 @@ def set_power_limit(limit_w: int, gpu_index: int = 0) -> tuple[bool, str]:
ret = subprocess.run( ret = subprocess.run(
["nvidia-smi", "-i", str(gpu_index), "-pl", str(limit_w)], ["nvidia-smi", "-i", str(gpu_index), "-pl", str(limit_w)],
capture_output=True, text=True, capture_output=True,
text=True,
) )
if ret.returncode == 0: if ret.returncode == 0:
return True, "OK" return True, "OK"
@@ -111,34 +118,38 @@ def set_power_limit(limit_w: int, gpu_index: int = 0) -> tuple[bool, str]:
# pynvml (nvidia-ml-py ≥ 12) exposes c_nvmlClockOffset_t and nvmlClockOffset_v1 # pynvml (nvidia-ml-py ≥ 12) exposes c_nvmlClockOffset_t and nvmlClockOffset_v1
# as ctypes objects; we use them when available and fall back to our own definition. # as ctypes objects; we use them when available and fall back to our own definition.
class _ClockOffset(ctypes.Structure): class _ClockOffset(ctypes.Structure):
_fields_ = [ _fields_ = [
("version", ctypes.c_uint), ("version", ctypes.c_uint),
("type", ctypes.c_uint), # nvmlClockType_t ("type", ctypes.c_uint), # nvmlClockType_t
("pstate", ctypes.c_uint), # nvmlPstates_t ("pstate", ctypes.c_uint), # nvmlPstates_t
("clockOffsetMHz", ctypes.c_int), ("clockOffsetMHz", ctypes.c_int),
] ]
_CLOCK_OFFSET_VER = (1 << 24) | ctypes.sizeof(_ClockOffset) # = 0x01000010 (16 bytes) _CLOCK_OFFSET_VER = (1 << 24) | ctypes.sizeof(_ClockOffset) # = 0x01000010 (16 bytes)
# NVML clock-type constants (same values as pynvml). # NVML clock-type constants (same values as pynvml).
_NVML_CLOCK_GRAPHICS = 0 _NVML_CLOCK_GRAPHICS = 0
_NVML_CLOCK_MEM = 2 _NVML_CLOCK_MEM = 2
def _make_clock_offset(clock_type: int, pstate: int = 0, offset_mhz: int = 0) -> ctypes.Structure: def _make_clock_offset(
clock_type: int, pstate: int = 0, offset_mhz: int = 0
) -> ctypes.Structure:
"""Return a populated nvmlClockOffset_t struct, using pynvml's type when available.""" """Return a populated nvmlClockOffset_t struct, using pynvml's type when available."""
if hasattr(pynvml, "c_nvmlClockOffset_t") and hasattr(pynvml, "nvmlClockOffset_v1"): if hasattr(pynvml, "c_nvmlClockOffset_t") and hasattr(pynvml, "nvmlClockOffset_v1"):
info = pynvml.c_nvmlClockOffset_t() info = pynvml.c_nvmlClockOffset_t()
info.version = pynvml.nvmlClockOffset_v1 info.version = pynvml.nvmlClockOffset_v1
info.type = clock_type info.type = clock_type
info.pstate = pstate info.pstate = pstate
info.clockOffsetMHz = offset_mhz info.clockOffsetMHz = offset_mhz
return info return info
info = _ClockOffset() info = _ClockOffset()
info.version = _CLOCK_OFFSET_VER info.version = _CLOCK_OFFSET_VER
info.type = clock_type info.type = clock_type
info.pstate = pstate info.pstate = pstate
info.clockOffsetMHz = offset_mhz info.clockOffsetMHz = offset_mhz
return info return info
@@ -158,7 +169,7 @@ def get_clock_offsets(gpu_index: int = 0) -> dict:
Keys: gpc_offset_mhz, mem_offset_mhz (both int or None on failure). Keys: gpc_offset_mhz, mem_offset_mhz (both int or None on failure).
Calls nvmlDeviceGetClockOffsets once per clock domain (GRAPHICS, MEM). Calls nvmlDeviceGetClockOffsets once per clock domain (GRAPHICS, MEM).
""" """
out = {"gpc_offset_mhz": None, "mem_offset_mhz": None} out: dict[str, int | None] = {"gpc_offset_mhz": None, "mem_offset_mhz": None}
if not _NVML_AVAILABLE: if not _NVML_AVAILABLE:
return out return out
try: try:
@@ -167,11 +178,15 @@ def get_clock_offsets(gpu_index: int = 0) -> dict:
# Try pynvml wrapper first (nvidia-ml-py ≥ 12 exposes it correctly). # Try pynvml wrapper first (nvidia-ml-py ≥ 12 exposes it correctly).
# Fall back to ctypes-direct if pynvml doesn't have it. # Fall back to ctypes-direct if pynvml doesn't have it.
_pynvml_get = getattr(pynvml, "nvmlDeviceGetClockOffsets", None) _pynvml_get = getattr(pynvml, "nvmlDeviceGetClockOffsets", None)
fn_get = _try_nvml_fn("nvmlDeviceGetClockOffsets") if _pynvml_get is None else None fn_get = (
_try_nvml_fn("nvmlDeviceGetClockOffsets") if _pynvml_get is None else None
)
used_new_api = False used_new_api = False
for clock_type, key in ((_NVML_CLOCK_GRAPHICS, "gpc_offset_mhz"), for clock_type, key in (
(_NVML_CLOCK_MEM, "mem_offset_mhz")): (_NVML_CLOCK_GRAPHICS, "gpc_offset_mhz"),
(_NVML_CLOCK_MEM, "mem_offset_mhz"),
):
info = _make_clock_offset(clock_type, pstate=0) info = _make_clock_offset(clock_type, pstate=0)
try: try:
if _pynvml_get is not None: if _pynvml_get is not None:
@@ -184,7 +199,9 @@ def get_clock_offsets(gpu_index: int = 0) -> dict:
out[key] = int(info.clockOffsetMHz) out[key] = int(info.clockOffsetMHz)
used_new_api = True used_new_api = True
else: else:
log.debug("nvmlDeviceGetClockOffsets(type=%d) returned %d", clock_type, rc) log.debug(
"nvmlDeviceGetClockOffsets(type=%d) returned %d", clock_type, rc
)
except Exception as exc: except Exception as exc:
log.debug("nvmlDeviceGetClockOffsets(type=%d): %s", clock_type, exc) log.debug("nvmlDeviceGetClockOffsets(type=%d): %s", clock_type, exc)
@@ -200,7 +217,9 @@ def get_clock_offsets(gpu_index: int = 0) -> dict:
if hasattr(pynvml, "nvmlDeviceGetMemClkVfOffset"): if hasattr(pynvml, "nvmlDeviceGetMemClkVfOffset"):
try: try:
res = pynvml.nvmlDeviceGetMemClkVfOffset(handle) res = pynvml.nvmlDeviceGetMemClkVfOffset(handle)
out["mem_offset_mhz"] = int(res[0] if isinstance(res, (list, tuple)) else res) out["mem_offset_mhz"] = int(
res[0] if isinstance(res, (list, tuple)) else res
)
except Exception as exc: except Exception as exc:
log.debug("nvmlDeviceGetMemClkVfOffset: %s", exc) log.debug("nvmlDeviceGetMemClkVfOffset: %s", exc)
@@ -210,8 +229,8 @@ def get_clock_offsets(gpu_index: int = 0) -> dict:
def set_clock_offsets( def set_clock_offsets(
gpc_offset_mhz: Optional[int] = None, gpc_offset_mhz: int | None = None,
mem_offset_mhz: Optional[int] = None, mem_offset_mhz: int | None = None,
gpu_index: int = 0, gpu_index: int = 0,
) -> tuple[bool, str]: ) -> tuple[bool, str]:
"""Set clock offsets (MHz) for the specified domains only. """Set clock offsets (MHz) for the specified domains only.
@@ -235,20 +254,35 @@ def set_clock_offsets(
domains.append((_NVML_CLOCK_MEM, mem_offset_mhz)) domains.append((_NVML_CLOCK_MEM, mem_offset_mhz))
_pynvml_set = getattr(pynvml, "nvmlDeviceSetClockOffsets", None) _pynvml_set = getattr(pynvml, "nvmlDeviceSetClockOffsets", None)
fn_set = _try_nvml_fn("nvmlDeviceSetClockOffsets") if _pynvml_set is None else None fn_set = (
_try_nvml_fn("nvmlDeviceSetClockOffsets") if _pynvml_set is None else None
)
if _pynvml_set is not None or fn_set is not None: if _pynvml_set is not None or fn_set is not None:
all_ok = True all_ok = True
for clock_type, offset in domains: for clock_type, offset in domains:
info = _make_clock_offset(clock_type, pstate=0, offset_mhz=offset) info = _make_clock_offset(clock_type, pstate=0, offset_mhz=offset)
try: try:
rc = _pynvml_set(handle, ctypes.byref(info)) if _pynvml_set else fn_set(handle, ctypes.byref(info)) if _pynvml_set is not None:
rc = _pynvml_set(handle, ctypes.byref(info))
elif fn_set is not None:
rc = fn_set(handle, ctypes.byref(info))
else:
break
if rc != 0: if rc != 0:
log.debug("nvmlDeviceSetClockOffsets(type=%d) returned %d — trying fallback", clock_type, rc) log.debug(
"nvmlDeviceSetClockOffsets(type=%d) returned %d — trying fallback",
clock_type,
rc,
)
all_ok = False all_ok = False
break break
except Exception as exc: except Exception as exc:
log.debug("nvmlDeviceSetClockOffsets(type=%d): %s — trying fallback", clock_type, exc) log.debug(
"nvmlDeviceSetClockOffsets(type=%d): %s — trying fallback",
clock_type,
exc,
)
all_ok = False all_ok = False
break break
if all_ok: if all_ok:
@@ -257,12 +291,16 @@ def set_clock_offsets(
# Deprecated per-domain fallback (works on Blackwell/driver 590.x). # Deprecated per-domain fallback (works on Blackwell/driver 590.x).
errs = [] errs = []
if gpc_offset_mhz is not None and hasattr(pynvml, "nvmlDeviceSetGpcClkVfOffset"): if gpc_offset_mhz is not None and hasattr(
pynvml, "nvmlDeviceSetGpcClkVfOffset"
):
try: try:
pynvml.nvmlDeviceSetGpcClkVfOffset(handle, gpc_offset_mhz) pynvml.nvmlDeviceSetGpcClkVfOffset(handle, gpc_offset_mhz)
except Exception as exc: except Exception as exc:
errs.append(f"GPC: {exc}") errs.append(f"GPC: {exc}")
if mem_offset_mhz is not None and hasattr(pynvml, "nvmlDeviceSetMemClkVfOffset"): if mem_offset_mhz is not None and hasattr(
pynvml, "nvmlDeviceSetMemClkVfOffset"
):
try: try:
pynvml.nvmlDeviceSetMemClkVfOffset(handle, mem_offset_mhz) pynvml.nvmlDeviceSetMemClkVfOffset(handle, mem_offset_mhz)
except Exception as exc: except Exception as exc:
@@ -278,6 +316,7 @@ def set_clock_offsets(
# ── Range queries ───────────────────────────────────────────────────────────── # ── Range queries ─────────────────────────────────────────────────────────────
def get_mem_offset_range(gpu_index: int = 0) -> dict: def get_mem_offset_range(gpu_index: int = 0) -> dict:
"""Return the min/max allowed memory clock offset (MHz). """Return the min/max allowed memory clock offset (MHz).
@@ -285,7 +324,7 @@ def get_mem_offset_range(gpu_index: int = 0) -> dict:
Uses nvmlDeviceGetMemClkMinMaxVfOffset; falls back to observed RTX values. Uses nvmlDeviceGetMemClkMinMaxVfOffset; falls back to observed RTX values.
""" """
# Observed RTX 5090 defaults (NvAPI GetClockBoostRanges says -1000/+3000). # Observed RTX 5090 defaults (NvAPI GetClockBoostRanges says -1000/+3000).
out = {"min_mem_offset_mhz": -2000, "max_mem_offset_mhz": 3000} out: dict[str, int] = {"min_mem_offset_mhz": -2000, "max_mem_offset_mhz": 3000}
if not _NVML_AVAILABLE: if not _NVML_AVAILABLE:
return out return out
try: try:
@@ -317,5 +356,3 @@ def get_mem_offset_range(gpu_index: int = 0) -> dict:
except Exception as exc: except Exception as exc:
log.debug("get_mem_offset_range: %s", exc) log.debug("get_mem_offset_range: %s", exc)
return out return out
+61 -30
View File
@@ -2,18 +2,20 @@
import ctypes import ctypes
import json import json
import logging
import os import os
import struct import struct
from datetime import datetime from datetime import datetime
from typing import Optional
from ..nvapi.bootstrap import nvcall_raw from ..nvapi.bootstrap import nvcall_raw
from ..nvapi.constants import FUNC, CT_SIZE, CT_BASE, CT_STRIDE, CT_DELTA_OFF, CT_POINTS from ..nvapi.constants import CT_BASE, CT_DELTA_OFF, CT_SIZE, CT_STRIDE, FUNC
from ..nvapi.types import SnapshotInfo from ..nvapi.types import SnapshotInfo
from .vfcurve import read_clock_table_raw, get_boost_mask from .vfcurve import get_boost_mask, read_clock_table_raw
log = logging.getLogger("nvcurve.hal.snapshot")
def save(gpu, gpu_name: str, snapshot_dir: str, max_snapshots: int = 0) -> Optional[str]: def save(gpu, gpu_name: str, snapshot_dir: str, max_snapshots: int = 0) -> str | None:
"""Save the current ClockBoostTable to disk. """Save the current ClockBoostTable to disk.
Writes both a binary .bin file and a human-readable .json metadata file. Writes both a binary .bin file and a human-readable .json metadata file.
@@ -25,13 +27,21 @@ def save(gpu, gpu_name: str, snapshot_dir: str, max_snapshots: int = 0) -> Optio
print(f"Failed to read ClockBoostTable: {err}") print(f"Failed to read ClockBoostTable: {err}")
return None return None
os.makedirs(snapshot_dir, exist_ok=True) try:
os.makedirs(snapshot_dir, exist_ok=True)
except OSError as exc:
print(f"Failed to create snapshot dir {snapshot_dir}: {exc}")
return None
ts = datetime.now().strftime("%Y%m%d_%H%M%S") ts = datetime.now().strftime("%Y%m%d_%H%M%S")
bin_path = os.path.join(snapshot_dir, f"clock_boost_table_{ts}.bin") bin_path = os.path.join(snapshot_dir, f"clock_boost_table_{ts}.bin")
meta_path = os.path.join(snapshot_dir, f"clock_boost_table_{ts}.json") meta_path = os.path.join(snapshot_dir, f"clock_boost_table_{ts}.json")
with open(bin_path, "wb") as f: try:
f.write(raw) with open(bin_path, "wb") as f:
f.write(raw)
except OSError as exc:
print(f"Failed to write snapshot {bin_path}: {exc}")
return None
offsets = [] offsets = []
max_entries = (len(raw) - CT_BASE) // CT_STRIDE max_entries = (len(raw) - CT_BASE) // CT_STRIDE
@@ -48,10 +58,14 @@ def save(gpu, gpu_name: str, snapshot_dir: str, max_snapshots: int = 0) -> Optio
"offsets_kHz": offsets, "offsets_kHz": offsets,
"nonzero_offsets": sum(1 for o in offsets if o != 0), "nonzero_offsets": sum(1 for o in offsets if o != 0),
} }
with open(meta_path, "w") as f: try:
json.dump(meta, f, indent=2) with open(meta_path, "w") as f:
json.dump(meta, f, indent=2)
except OSError as exc:
print(f"Failed to write snapshot metadata {meta_path}: {exc}")
return None
print(f"Snapshot saved:") print("Snapshot saved:")
print(f" Binary: {bin_path}") print(f" Binary: {bin_path}")
print(f" Metadata: {meta_path}") print(f" Metadata: {meta_path}")
print(f" Size: {len(raw)} bytes") print(f" Size: {len(raw)} bytes")
@@ -65,20 +79,22 @@ def save(gpu, gpu_name: str, snapshot_dir: str, max_snapshots: int = 0) -> Optio
def _prune_snapshots(snapshot_dir: str, max_snapshots: int) -> None: def _prune_snapshots(snapshot_dir: str, max_snapshots: int) -> None:
"""Delete oldest snapshots (both .bin and .json) to stay within max_snapshots.""" """Delete oldest snapshots (both .bin and .json) to stay within max_snapshots."""
bins = sorted( # Oldest first (lexicographic = chronological for our timestamp format).
f for f in os.listdir(snapshot_dir) if f.endswith(".bin") try:
) # oldest first (lexicographic = chronological for our timestamp format) bins = sorted(f for f in os.listdir(snapshot_dir) if f.endswith(".bin"))
except OSError:
return
excess = len(bins) - max_snapshots excess = len(bins) - max_snapshots
for fname in bins[:excess]: for fname in bins[:excess]:
stem = fname[:-4] # strip .bin stem = fname[:-4] # strip .bin
for ext in (".bin", ".json"): for ext in (".bin", ".json"):
try: try:
os.remove(os.path.join(snapshot_dir, stem + ext)) os.remove(os.path.join(snapshot_dir, stem + ext))
except OSError: except OSError as exc:
pass log.debug("Could not remove %s: %s", stem + ext, exc)
def restore(gpu, snapshot_dir: str, filepath: str = None) -> bool: def restore(gpu, snapshot_dir: str, filepath: str | None = None) -> bool:
"""Restore a ClockBoostTable snapshot from disk. """Restore a ClockBoostTable snapshot from disk.
If no filepath is given, uses the most recent snapshot in snapshot_dir. If no filepath is given, uses the most recent snapshot in snapshot_dir.
@@ -88,10 +104,14 @@ def restore(gpu, snapshot_dir: str, filepath: str = None) -> bool:
if not os.path.isdir(snapshot_dir): if not os.path.isdir(snapshot_dir):
print(f"No snapshots found in {snapshot_dir}") print(f"No snapshots found in {snapshot_dir}")
return False return False
bins = sorted( try:
[f for f in os.listdir(snapshot_dir) if f.endswith(".bin")], bins = sorted(
reverse=True, [f for f in os.listdir(snapshot_dir) if f.endswith(".bin")],
) reverse=True,
)
except OSError:
print(f"No snapshots found in {snapshot_dir}")
return False
if not bins: if not bins:
print(f"No snapshot .bin files in {snapshot_dir}") print(f"No snapshot .bin files in {snapshot_dir}")
return False return False
@@ -101,8 +121,12 @@ def restore(gpu, snapshot_dir: str, filepath: str = None) -> bool:
print(f"Snapshot file not found: {filepath}") print(f"Snapshot file not found: {filepath}")
return False return False
with open(filepath, "rb") as f: try:
raw = f.read() with open(filepath, "rb") as f:
raw = f.read()
except OSError as exc:
print(f"Failed to read snapshot {filepath}: {exc}")
return False
if len(raw) != CT_SIZE: if len(raw) != CT_SIZE:
print(f"Snapshot size mismatch: expected {CT_SIZE}, got {len(raw)}") print(f"Snapshot size mismatch: expected {CT_SIZE}, got {len(raw)}")
@@ -134,8 +158,13 @@ def list_snapshots(snapshot_dir: str) -> list[SnapshotInfo]:
if not os.path.isdir(snapshot_dir): if not os.path.isdir(snapshot_dir):
return [] return []
try:
fnames = sorted(os.listdir(snapshot_dir), reverse=True)
except OSError:
return []
results = [] results = []
for fname in sorted(os.listdir(snapshot_dir), reverse=True): for fname in fnames:
if not fname.endswith(".json"): if not fname.endswith(".json"):
continue continue
meta_path = os.path.join(snapshot_dir, fname) meta_path = os.path.join(snapshot_dir, fname)
@@ -143,13 +172,15 @@ def list_snapshots(snapshot_dir: str) -> list[SnapshotInfo]:
with open(meta_path) as f: with open(meta_path) as f:
meta = json.load(f) meta = json.load(f)
bin_path = meta.get("file", meta_path.replace(".json", ".bin")) bin_path = meta.get("file", meta_path.replace(".json", ".bin"))
results.append(SnapshotInfo( results.append(
filepath=bin_path, SnapshotInfo(
timestamp=meta.get("timestamp", ""), filepath=bin_path,
gpu=meta.get("gpu", ""), timestamp=meta.get("timestamp", ""),
nonzero_offsets=meta.get("nonzero_offsets", 0), gpu=meta.get("gpu", ""),
size=meta.get("size", 0), nonzero_offsets=meta.get("nonzero_offsets", 0),
)) size=meta.get("size", 0),
)
)
except (json.JSONDecodeError, KeyError): except (json.JSONDecodeError, KeyError):
continue continue
+89 -38
View File
@@ -21,12 +21,12 @@ def _gpu_stable_key(info) -> str:
def apply_profile(gpu_index: int, name: str, cfg) -> list[str]: def apply_profile(gpu_index: int, name: str, cfg) -> list[str]:
"""Apply a named profile to the given GPU. Returns a list of error strings.""" """Apply a named profile to the given GPU. Returns a list of error strings."""
from .native import load_profile
from ..hal.gpu import get_gpu from ..hal.gpu import get_gpu
from ..hal.limits import set_clock_offsets, set_power_limit from ..hal.limits import set_clock_offsets, set_power_limit
from ..hal.vfcurve import write_offsets, reset_offsets
from ..hal.snapshot import save as snapshot_save from ..hal.snapshot import save as snapshot_save
from ..hal.vfcurve import reset_offsets, write_offsets
from ..safety import validate_write from ..safety import validate_write
from .native import load_profile
safe_name = "".join(c for c in name if c.isalnum() or c in " _-()").strip() safe_name = "".join(c for c in name if c.isalnum() or c in " _-()").strip()
filepath = os.path.join(cfg.profile_dir, f"{safe_name}.json") filepath = os.path.join(cfg.profile_dir, f"{safe_name}.json")
@@ -48,19 +48,25 @@ def apply_profile(gpu_index: int, name: str, cfg) -> list[str]:
errs.append(f"Power limit: {msg}") errs.append(f"Power limit: {msg}")
if profile.curve_deltas: if profile.curve_deltas:
deltas = {int(k): v for k, v in profile.curve_deltas.items()} try:
errors = validate_write(deltas, cfg.max_delta_khz) deltas = {int(k): v for k, v in profile.curve_deltas.items()}
if errors: except ValueError:
errs.append("Curve: " + "; ".join(errors)) errs.append("Curve: invalid point keys in profile")
else: else:
if cfg.auto_snapshot: errors = validate_write(deltas, cfg.max_delta_khz)
try: if errors:
snapshot_save(gpu, gpu_name, cfg.snapshot_dir, cfg.max_snapshots) errs.append("Curve: " + "; ".join(errors))
except Exception as exc: else:
log.warning("Auto-snapshot failed: %s", exc) if cfg.auto_snapshot:
ret, desc = write_offsets(gpu, deltas) try:
if ret != 0: snapshot_save(
errs.append(f"Curve write failed ({ret}): {desc}") gpu, gpu_name, cfg.snapshot_dir, cfg.max_snapshots
)
except Exception as exc:
log.warning("Auto-snapshot failed: %s", exc)
ret, desc = write_offsets(gpu, deltas)
if ret != 0:
errs.append(f"Curve write failed ({ret}): {desc}")
else: else:
reset_offsets(gpu) reset_offsets(gpu)
@@ -69,9 +75,9 @@ def apply_profile(gpu_index: int, name: str, cfg) -> list[str]:
def apply_with_retry(gpu_index: int, name: str, cfg, max_retries: int = 3) -> bool: def apply_with_retry(gpu_index: int, name: str, cfg, max_retries: int = 3) -> bool:
"""Apply a named profile with read-back verification, retrying on mismatch.""" """Apply a named profile with read-back verification, retrying on mismatch."""
from .native import load_profile
from ..hal.gpu import get_gpu from ..hal.gpu import get_gpu
from ..hal.vfcurve import read_clock_offsets from ..hal.vfcurve import read_clock_offsets
from .native import load_profile
safe_name = "".join(c for c in name if c.isalnum() or c in " _-()").strip() safe_name = "".join(c for c in name if c.isalnum() or c in " _-()").strip()
filepath = os.path.join(cfg.profile_dir, f"{safe_name}.json") filepath = os.path.join(cfg.profile_dir, f"{safe_name}.json")
@@ -82,51 +88,88 @@ def apply_with_retry(gpu_index: int, name: str, cfg, max_retries: int = 3) -> bo
log.warning("Auto-load profile %r not found — skipping GPU %d", name, gpu_index) log.warning("Auto-load profile %r not found — skipping GPU %d", name, gpu_index)
return False return False
expected: dict[int, int] = ( try:
{int(k): v for k, v in profile.curve_deltas.items()} expected: dict[int, int] = (
if profile.curve_deltas else {} {int(k): v for k, v in profile.curve_deltas.items()}
) if profile.curve_deltas
else {}
)
except ValueError:
log.warning(
"Profile %r has invalid curve point keys — skipping GPU %d",
name,
gpu_index,
)
return False
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
errs = apply_profile(gpu_index, name, cfg) errs = apply_profile(gpu_index, name, cfg)
except Exception as exc: except Exception as exc:
log.warning("Auto-load attempt %d/%d exception: %s", attempt + 1, max_retries, exc) log.warning(
"Auto-load attempt %d/%d exception: %s", attempt + 1, max_retries, exc
)
errs = [str(exc)] errs = [str(exc)]
if errs: if errs:
log.warning("Auto-load attempt %d/%d errors: %s", log.warning(
attempt + 1, max_retries, "; ".join(errs)) "Auto-load attempt %d/%d errors: %s",
attempt + 1,
max_retries,
"; ".join(errs),
)
elif expected: elif expected:
gpu, _ = get_gpu(index=gpu_index) gpu, _ = get_gpu(index=gpu_index)
offsets, err = read_clock_offsets(gpu) offsets, err = read_clock_offsets(gpu)
if offsets is None: if offsets is None:
log.warning("Auto-load attempt %d/%d: read-back failed: %s", log.warning(
attempt + 1, max_retries, err) "Auto-load attempt %d/%d: read-back failed: %s",
attempt + 1,
max_retries,
err,
)
else: else:
mismatches = [ mismatches = [
f"pt{idx}: expected {val/1000:+.0f}MHz got {offsets[idx]/1000:+.0f}MHz" f"pt{idx}: expected {val / 1000:+.0f}MHz got {offsets[idx] / 1000:+.0f}MHz"
for idx, val in expected.items() for idx, val in expected.items()
if idx < len(offsets) and offsets[idx] != val if idx < len(offsets) and offsets[idx] != val
] ]
if not mismatches: if not mismatches:
log.info("Auto-load profile %r verified on GPU %d (attempt %d/%d)", log.info(
name, gpu_index, attempt + 1, max_retries) "Auto-load profile %r verified on GPU %d (attempt %d/%d)",
name,
gpu_index,
attempt + 1,
max_retries,
)
return True return True
log.warning("Auto-load attempt %d/%d: read-back mismatch — %s", log.warning(
attempt + 1, max_retries, "; ".join(mismatches)) "Auto-load attempt %d/%d: read-back mismatch — %s",
attempt + 1,
max_retries,
"; ".join(mismatches),
)
else: else:
log.info("Auto-load profile %r applied on GPU %d (attempt %d/%d)", log.info(
name, gpu_index, attempt + 1, max_retries) "Auto-load profile %r applied on GPU %d (attempt %d/%d)",
name,
gpu_index,
attempt + 1,
max_retries,
)
return True return True
if attempt < max_retries - 1: if attempt < max_retries - 1:
delay = 2 ** attempt # 1s, 2s, 4s delay = 2**attempt # 1s, 2s, 4s
log.info("Retrying auto-load in %ds…", delay) log.info("Retrying auto-load in %ds…", delay)
time.sleep(delay) time.sleep(delay)
log.warning("Auto-load profile %r failed after %d attempts on GPU %d", log.warning(
name, max_retries, gpu_index) "Auto-load profile %r failed after %d attempts on GPU %d",
name,
max_retries,
gpu_index,
)
return False return False
@@ -153,13 +196,19 @@ def run_autoload() -> None:
return return
from ..config import Config from ..config import Config
cfg = Config() cfg = Config()
for key in ("max_delta_khz", "auto_snapshot", "max_snapshots", for key in (
"snapshot_dir", "profile_dir"): "max_delta_khz",
"auto_snapshot",
"max_snapshots",
"snapshot_dir",
"profile_dir",
):
if key in cfg_data: if key in cfg_data:
setattr(cfg, key, cfg_data[key]) setattr(cfg, key, cfg_data[key])
from ..hal.gpu import init_nvapi, discover_gpus from ..hal.gpu import discover_gpus, init_nvapi
from ..hal.monitoring import init_nvml, shutdown_nvml from ..hal.monitoring import init_nvml, shutdown_nvml
# Retry NvAPI init — the driver may not be fully ready at early boot. # Retry NvAPI init — the driver may not be fully ready at early boot.
@@ -189,7 +238,9 @@ def run_autoload() -> None:
if gpu_idx is None: if gpu_idx is None:
log.warning("Auto-load: no GPU found with key %r — skipping", gpu_key) log.warning("Auto-load: no GPU found with key %r — skipping", gpu_key)
continue continue
log.info("Auto-loading profile %r on GPU %d (%s)", profile_name, gpu_idx, gpu_key) log.info(
"Auto-loading profile %r on GPU %d (%s)", profile_name, gpu_idx, gpu_key
)
apply_with_retry(gpu_idx, profile_name, cfg) apply_with_retry(gpu_idx, profile_name, cfg)
shutdown_nvml() shutdown_nvml()
+29 -17
View File
@@ -1,39 +1,52 @@
"""Native profile storage and schema.""" """Native profile storage and schema."""
import json
import os
import glob import glob
from dataclasses import dataclass, asdict import json
from typing import Dict, Optional, List import logging
import os
from dataclasses import asdict, dataclass
log = logging.getLogger("nvcurve.profiles.native")
@dataclass @dataclass
class ProfileData: class ProfileData:
name: str name: str
gpu_name: str gpu_name: str
curve_deltas: Dict[str, int] # { "index": delta_khz } curve_deltas: dict[str, int] # { "index": delta_khz }
mem_offset_mhz: Optional[int] = None mem_offset_mhz: int | None = None
power_limit_w: Optional[int] = None power_limit_w: int | None = None
fan_curve: Optional[List[Dict[str, int]]] = None fan_curve: list[dict[str, int]] | None = None
def save_profile(profile_dir: str, data: ProfileData) -> str: def save_profile(profile_dir: str, data: ProfileData) -> str:
"""Save profile to JSON, sanitising the filename.""" """Save profile to JSON, sanitising the filename."""
os.makedirs(profile_dir, exist_ok=True) try:
os.makedirs(profile_dir, exist_ok=True)
except OSError as exc:
raise RuntimeError(f"Cannot create profile dir {profile_dir}: {exc}") from exc
safe_name = "".join(c for c in data.name if c.isalnum() or c in " _-()").strip() safe_name = "".join(c for c in data.name if c.isalnum() or c in " _-()").strip()
if not safe_name: if not safe_name:
safe_name = "Unnamed" safe_name = "Unnamed"
filepath = os.path.join(profile_dir, f"{safe_name}.json") filepath = os.path.join(profile_dir, f"{safe_name}.json")
with open(filepath, "w", encoding="utf-8") as f: try:
json.dump(asdict(data), f, indent=2) with open(filepath, "w", encoding="utf-8") as f:
json.dump(asdict(data), f, indent=2)
except OSError as exc:
raise RuntimeError(f"Cannot write profile {filepath}: {exc}") from exc
return filepath return filepath
def load_profile(filepath: str) -> ProfileData: def load_profile(filepath: str) -> ProfileData:
"""Load profile from JSON.""" """Load profile from JSON."""
with open(filepath, "r", encoding="utf-8") as f: try:
data = json.load(f) with open(filepath, encoding="utf-8") as f:
data = json.load(f)
except FileNotFoundError:
raise
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError(f"Cannot read profile {filepath}: {exc}") from exc
# Migrate old field names. # Migrate old field names.
if "vram_p0_offset_mhz" in data and "mem_offset_mhz" not in data: if "vram_p0_offset_mhz" in data and "mem_offset_mhz" not in data:
data["mem_offset_mhz"] = data.pop("vram_p0_offset_mhz") data["mem_offset_mhz"] = data.pop("vram_p0_offset_mhz")
@@ -43,7 +56,7 @@ def load_profile(filepath: str) -> ProfileData:
return ProfileData(**data) return ProfileData(**data)
def list_profiles(profile_dir: str) -> List[ProfileData]: def list_profiles(profile_dir: str) -> list[ProfileData]:
"""Return a list of all safely readable profiles.""" """Return a list of all safely readable profiles."""
if not os.path.exists(profile_dir): if not os.path.exists(profile_dir):
return [] return []
@@ -51,9 +64,8 @@ def list_profiles(profile_dir: str) -> List[ProfileData]:
for fp in glob.glob(os.path.join(profile_dir, "*.json")): for fp in glob.glob(os.path.join(profile_dir, "*.json")):
try: try:
profiles.append(load_profile(fp)) profiles.append(load_profile(fp))
except Exception as e: except Exception as exc:
# log warning ideally, but swallowing for robustness log.debug("Skipping unreadable profile %s: %s", fp, exc)
pass
# Sort alphabetically by name # Sort alphabetically by name
profiles.sort(key=lambda p: p.name.lower()) profiles.sort(key=lambda p: p.name.lower())
return profiles return profiles
+6 -3
View File
@@ -90,8 +90,8 @@ def _open_browser_as_user(url: str) -> None:
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
return return
except Exception: except Exception as exc:
pass log.debug("runuser xdg-open failed, falling back to webbrowser: %s", exc)
import webbrowser import webbrowser
webbrowser.open(url) webbrowser.open(url)
@@ -1692,7 +1692,10 @@ def _resolve_dist_dir() -> str:
the project-root layout used during local development. the project-root layout used during local development.
""" """
try: try:
from importlib.resources import files as _resource_files # Project requires Python >= 3.12, so the 3.7-compat finding is a false positive.
from importlib.resources import ( # nosemgrep: python.lang.compatibility.python37.python37-compatibility-importlib2
files as _resource_files,
)
candidate = _resource_files("nvcurve") / "frontend" / "dist" candidate = _resource_files("nvcurve") / "frontend" / "dist"
if candidate.is_dir(): if candidate.is_dir():