Clean up LSP diagnostics across backend and frontend #7

Merged
Pakobbix merged 1 commits from cleanup/lint-modernization into main 2026-09-08 21:58:42 +00:00
33 changed files with 1571 additions and 804 deletions

No files matched your search

+3 -1
View File
@@ -14,4 +14,6 @@ node_modules/
/dist/
/build/
*.egg-info/
.claude
.claude
# Local tool data
.codegraph/
+18 -18
View File
@@ -1,24 +1,24 @@
import { useGpu } from "./hooks/useGpu";
import { useCurve } from "./hooks/useCurve";
import { useMonitor } from "./hooks/useMonitor";
import { useDashboard } from "./hooks/useDashboard";
import { StatusBar } from "./components/Monitor/StatusBar";
import { LiveMonitor } from "./components/Monitor/LiveMonitor";
import { Dashboard } from "./components/Dashboard/Dashboard";
import { CurveEditor } from "./components/CurveEditor/CurveEditor";
import { PointTable } from "./components/PointTable/PointTable";
import { PerformancePanel } from "./components/Limits/PerformancePanel";
import { PerformanceMonitor } from "./components/Monitor/PerformanceMonitor";
import { FanMonitor } from "./components/Monitor/FanMonitor";
import { FanCurveEditor } from "./components/Fans/FanCurveEditor";
import { ProfilePanel } from "./components/Profiles/ProfilePanel";
import { api, onUnauthorized } from "./api/client";
import { LoginScreen } from "./components/Auth/LoginScreen";
import { useCurveStore } from "./store/curveStore";
import { useGpu } from "./hooks/useGpu.js";
import { useCurve } from "./hooks/useCurve.js";
import { useMonitor } from "./hooks/useMonitor.js";
import { useDashboard } from "./hooks/useDashboard.js";
import { StatusBar } from "./components/Monitor/StatusBar.js";
import { LiveMonitor } from "./components/Monitor/LiveMonitor.js";
import { Dashboard } from "./components/Dashboard/Dashboard.js";
import { CurveEditor } from "./components/CurveEditor/CurveEditor.js";
import { PointTable } from "./components/PointTable/PointTable.js";
import { PerformancePanel } from "./components/Limits/PerformancePanel.js";
import { PerformanceMonitor } from "./components/Monitor/PerformanceMonitor.js";
import { FanMonitor } from "./components/Monitor/FanMonitor.js";
import { FanCurveEditor } from "./components/Fans/FanCurveEditor.js";
import { ProfilePanel } from "./components/Profiles/ProfilePanel.js";
import { api, onUnauthorized } from "./api/client.js";
import { LoginScreen } from "./components/Auth/LoginScreen.js";
import { useCurveStore } from "./store/curveStore.js";
import { Toaster } from "sonner";
import { Loader, ChevronDown } from "lucide-react";
import { useState, useRef, useEffect } from "react";
import type { FanState } from "./types";
import type { FanState } from "./types.js";
type AuthState = "checking" | "login" | "ok";
+1 -1
View File
@@ -8,7 +8,7 @@ import type {
FanState,
FanPoint,
DashboardInfo,
} from "../types";
} from "../types.js";
export class ApiError extends Error {
status: number;
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { Loader, Lock, User } from "lucide-react";
import { api, ApiError } from "../../api/client";
import { api, ApiError } from "../../api/client.js";
interface Props {
onSuccess: (username: string) => void;
@@ -12,7 +12,7 @@ export function LoginScreen({ onSuccess }: Props) {
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
async function submit(e: React.SubmitEvent) {
e.preventDefault();
if (busy) return;
setBusy(true);
File diff suppressed because it is too large. Load diff
@@ -1,7 +1,7 @@
import { useState, useMemo, useEffect } from 'react';
import { ZoomIn, RotateCcw, Minus } from 'lucide-react';
import { useCurveStore } from '../../store/curveStore';
import type { VFPoint } from '../../types';
import { useState, useMemo } from "react";
import { ZoomIn, RotateCcw, Minus } from "lucide-react";
import { useCurveStore } from "../../store/curveStore.js";
import type { VFPoint } from "../../types.js";
interface Props {
/** All curve points — used by global offset slider */
@@ -18,23 +18,44 @@ interface Props {
onZoomChange: (factor: number) => void;
}
export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomFactor, onZoomChange }: Props) {
const { pendingDeltas, selectedPoints, anchorPoint, curve, stageRangeEdit, flattenToAnchor } = useCurveStore();
export function CurveToolbar({
activePts,
onResetZoom,
isZoomed,
readOnly,
zoomFactor,
onZoomChange,
}: Props) {
const {
pendingDeltas,
selectedPoints,
anchorPoint,
curve,
stageRangeEdit,
flattenToAnchor,
} = useCurveStore();
const [offsetMhz, setOffsetMhz] = useState(0);
const uniformDeltaMhz = useMemo(() => {
if (activePts.length === 0) return 0;
const firstD = pendingDeltas.get(activePts[0].index) ?? activePts[0].delta_khz;
const uniform = activePts.every((p) => (pendingDeltas.get(p.index) ?? p.delta_khz) === firstD);
const 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;
}, [activePts, pendingDeltas]);
useEffect(() => {
if (uniformDeltaMhz !== null) {
setOffsetMhz(uniformDeltaMhz);
}
}, [uniformDeltaMhz]);
// Sync the slider to the uniform delta when it changes (adjust state during
// render instead of an effect; undefined sentinel so the first render syncs).
const [lastUniformDelta, setLastUniformDelta] = useState<
number | null | undefined
>();
if (uniformDeltaMhz !== null && lastUniformDelta !== uniformDeltaMhz) {
setLastUniformDelta(uniformDeltaMhz);
setOffsetMhz(uniformDeltaMhz);
}
function handleOffsetChange(mhz: number) {
setOffsetMhz(mhz);
@@ -44,7 +65,10 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
return (
<div className="flex flex-wrap items-center gap-2 px-1 pb-2">
{/* 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" />
<input
type="range"
@@ -55,7 +79,9 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
onChange={(e) => onZoomChange(Number(e.target.value))}
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)}×
</span>
{isZoomed && (
@@ -75,7 +101,9 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
{/* Global offset slider — GPU only */}
{!readOnly && uniformDeltaMhz !== null && (
<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
type="range"
min={-1000}
@@ -84,46 +112,63 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
value={offsetMhz}
onChange={(e) => handleOffsetChange(Number(e.target.value))}
className="w-32 accent-cyan-400"
title={`${offsetMhz > 0 ? '+' : ''}${offsetMhz} MHz`}
title={`${offsetMhz > 0 ? "+" : ""}${offsetMhz} MHz`}
/>
<span
className={[
'text-xs font-mono w-16',
offsetMhz > 0 ? 'text-cyan-400' : offsetMhz < 0 ? 'text-orange-400' : 'text-zinc-500',
].join(' ')}
"text-xs font-mono w-16",
offsetMhz > 0
? "text-cyan-400"
: offsetMhz < 0
? "text-orange-400"
: "text-zinc-500",
].join(" ")}
>
{offsetMhz > 0 ? '+' : ''}{offsetMhz} MHz
{offsetMhz > 0 ? "+" : ""}
{offsetMhz} MHz
</span>
</div>
)}
{/* Flatten — visible when 2+ points are selected */}
{!readOnly && selectedPoints.size >= 2 && (() => {
const anchor = anchorPoint !== null && selectedPoints.has(anchorPoint)
? anchorPoint
: Math.min(...selectedPoints);
const anchorDelta =
pendingDeltas.get(anchor) ??
curve?.points.find(p => p.index === anchor)?.delta_khz ??
0;
const label = `·${anchor} ${anchorDelta >= 0 ? '+' : ''}${anchorDelta / 1000} MHz`;
return (
<button
onClick={flattenToAnchor}
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"
title={`Flatten all selected points to anchor point ${anchor} (${anchorDelta >= 0 ? '+' : ''}${anchorDelta / 1000} MHz)`}
>
<Minus size={11} />
Flatten to {label}
</button>
);
})()}
{!readOnly &&
selectedPoints.size >= 2 &&
(() => {
const anchor =
anchorPoint !== null && selectedPoints.has(anchorPoint)
? anchorPoint
: Math.min(...selectedPoints);
const anchorDelta =
pendingDeltas.get(anchor) ??
curve?.points.find((p) => p.index === anchor)?.delta_khz ??
0;
const label = `·${anchor} ${anchorDelta >= 0 ? "+" : ""}${anchorDelta / 1000} MHz`;
return (
<button
onClick={flattenToAnchor}
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"
title={`Flatten all selected points to anchor point ${anchor} (${anchorDelta >= 0 ? "+" : ""}${anchorDelta / 1000} MHz)`}
>
<Minus size={11} />
Flatten to {label}
</button>
);
})()}
{/* Legend — right-aligned */}
<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="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>
<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="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>
);
@@ -1,5 +1,5 @@
import { fmt } from '../../utils/units';
import type { VFPoint } from '../../types';
import { fmt } from '../../utils/units.js';
import type { VFPoint } from '../../types.js';
interface Props {
point: VFPoint;
@@ -21,15 +21,7 @@ export function CurveTooltip({ point, pendingDeltaKhz, isClamped }: Props) {
return (
<div
style={{
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"
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"
>
<div className="text-zinc-400 mb-1">Point {point.index}</div>
<div className="text-zinc-200">
@@ -1,7 +1,7 @@
import { Loader } from "lucide-react";
import { GaugeCard } from "../Monitor/GaugeCard";
import { fmt } from "../../utils/units";
import type { MonitoringSample, DashboardInfo } from "../../types";
import { GaugeCard } from "../Monitor/GaugeCard.js";
import { fmt } from "../../utils/units.js";
import type { MonitoringSample, DashboardInfo } from "../../types.js";
interface Props {
monitor: MonitoringSample | null;
+13 -12
View File
@@ -1,10 +1,10 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Check, X, RotateCcw, Plus } from "lucide-react";
import { api } from "../../api/client";
import { useCurveStore } from "../../store/curveStore";
import type { FanPoint, FanState } from "../../types";
import { api } from "../../api/client.js";
import { useCurveStore } from "../../store/curveStore.js";
import type { FanPoint, FanState } from "../../types.js";
import { toast } from "sonner";
import { ConfirmDialog } from "../common/ConfirmDialog";
import { ConfirmDialog } from "../common/ConfirmDialog.js";
function defaultCurve(): FanPoint[] {
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(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchFans();
}, [selectedGpuIndex]);
@@ -92,8 +95,8 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
await fetchFans();
onChanged?.();
toast.success("Fan curve applied");
} catch (e: any) {
setError(e.message ?? String(e));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
setConfirmApply(false);
} finally {
setBusy(false);
@@ -110,8 +113,8 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
await fetchFans();
onChanged?.();
toast.success("Fan control reset to automatic");
} catch (e: any) {
setError(e.message ?? String(e));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
setConfirmReset(false);
} finally {
setBusy(false);
@@ -294,8 +297,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
ref={svgRef}
width="100%"
viewBox={`0 0 ${CHART_W} ${CHART_H}`}
className="max-w-full cursor-crosshair select-none"
style={{ touchAction: "none" }}
className="max-w-full cursor-crosshair select-none touch-none"
onClick={handleCanvasClick}
onPointerMove={handlePointerMove}
>
@@ -410,8 +412,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
fill={hasPending ? "#22d3ee" : "#fb923c"}
stroke="#09090b"
strokeWidth="2"
className="cursor-grab active:cursor-grabbing"
style={{ touchAction: "none" }}
className="cursor-grab active:cursor-grabbing touch-none"
onPointerDown={(e) => {
e.stopPropagation();
handlePointerDown(i);
@@ -1,12 +1,12 @@
import { useState, useEffect } from 'react';
import { Check, X, RotateCcw } from 'lucide-react';
import { api } from '../../api/client';
import { useCurveStore } from '../../store/curveStore';
import type { LimitsState } from '../../types';
import { toast } from 'sonner';
import { ConfirmDialog } from '../common/ConfirmDialog';
import { useState, useEffect } from "react";
import { Check, X, RotateCcw } from "lucide-react";
import { api } from "../../api/client.js";
import { useCurveStore } from "../../store/curveStore.js";
import type { LimitsState } from "../../types.js";
import { toast } from "sonner";
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() {
const { selectedGpuIndex } = useCurveStore();
@@ -23,13 +23,18 @@ export function PerformancePanel() {
setLoading(true);
setLimits(await api.limits(selectedGpuIndex));
} catch {
toast.error('Failed to load performance limits');
toast.error("Failed to load performance limits");
} finally {
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() {
setBusy(true);
@@ -40,9 +45,9 @@ export function PerformancePanel() {
setPending({});
setConfirmApply(false);
await fetchLimits();
toast.success('Performance limits applied');
} catch (e: any) {
setError(e.message ?? String(e));
toast.success("Performance limits applied");
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
setConfirmApply(false);
} finally {
setBusy(false);
@@ -58,9 +63,9 @@ export function PerformancePanel() {
setPending({});
setConfirmReset(false);
await fetchLimits();
toast.success('Performance limits reset to defaults');
} catch (e: any) {
setError(e.message ?? String(e));
toast.success("Performance limits reset to defaults");
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
setConfirmReset(false);
} finally {
setBusy(false);
@@ -96,10 +101,11 @@ export function PerformancePanel() {
return (
<>
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
{/* ── Header ─────────────────────────────────────────────────────── */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 shrink-0">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Performance</span>
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
Performance
</span>
{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">
@@ -117,7 +123,10 @@ export function PerformancePanel() {
Apply
</button>
<button
onClick={() => { setPending({}); setError(null); }}
onClick={() => {
setPending({});
setError(null);
}}
disabled={!hasPending || busy}
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
@@ -139,25 +148,32 @@ export function PerformancePanel() {
{error && (
<div className="px-3 py-1.5 bg-red-900/40 border-b border-red-700 text-red-300 text-xs flex items-center justify-between">
<span>⚠ {error}</span>
<button onClick={() => setError(null)} className="ml-2 text-red-400 hover:text-red-200">✕</button>
<button
onClick={() => setError(null)}
className="ml-2 text-red-400 hover:text-red-200"
>
✕
</button>
</div>
)}
<div className="flex flex-col divide-y divide-zinc-800">
{/* ── Board Power Limit ─────────────────────────────────────────── */}
<div className="px-4 py-4 flex flex-col gap-3">
<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">
<input
type="number"
min={pwrMin}
max={pwrMax}
value={pwrVal}
onChange={e => {
onChange={(e) => {
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"
/>
@@ -165,32 +181,44 @@ export function PerformancePanel() {
</div>
</div>
<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
type="range"
min={pwrMin}
max={pwrMax}
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"
/>
<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>
{/* ── Memory Clock Offset ───────────────────────────────────────── */}
<div className="px-4 py-4 flex flex-col gap-3">
<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">
<input
type="number"
min={memMin}
max={memMax}
value={memVal}
onChange={e => {
onChange={(e) => {
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"
/>
@@ -198,20 +226,28 @@ export function PerformancePanel() {
</div>
</div>
<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
type="range"
min={memMin}
max={memMax}
step={1}
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"
/>
<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>
+23 -11
View File
@@ -1,6 +1,6 @@
import { GaugeCard } from './GaugeCard';
import { fmt } from '../../utils/units';
import type { MonitoringSample, FanPoint } from '../../types';
import { GaugeCard } from "./GaugeCard.js";
import { fmt } from "../../utils/units.js";
import type { MonitoringSample, FanPoint } from "../../types.js";
interface Props {
monitor: MonitoringSample | null;
@@ -16,7 +16,10 @@ function pluck<K extends keyof MonitoringSample>(
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;
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;
}
export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props) {
const fanHistory = pluck(history, 'fan_pct');
const tempHistory = pluck(history, 'temp_c');
export function FanMonitor({
monitor,
history,
fanCurve,
fanCurveActive,
}: Props) {
const fanHistory = pluck(history, "fan_pct");
const tempHistory = pluck(history, "temp_c");
const currentTemp = monitor?.temp_c ?? null;
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 (
<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="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 && (
<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
@@ -73,7 +85,7 @@ export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props
{fanCurveActive && (
<GaugeCard
label="Target Fan"
value={targetFan !== null ? `${targetFan}%` : '—'}
value={targetFan !== null ? `${targetFan}%` : "—"}
history={targetFanHistory}
color="#fbbf24"
max={100}
@@ -82,7 +94,7 @@ export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props
<div className="mt-auto">
<GaugeCard
label="Fan Mode"
value={fanCurveActive ? 'Curve' : 'Auto'}
value={fanCurveActive ? "Curve" : "Auto"}
/>
</div>
</div>
+41 -39
View File
@@ -1,6 +1,6 @@
import { GaugeCard } from './GaugeCard';
import { fmt } from '../../utils/units';
import type { MonitoringSample } from '../../types';
import { GaugeCard } from "./GaugeCard.js";
import { fmt } from "../../utils/units.js";
import type { MonitoringSample } from "../../types.js";
interface Props {
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="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">
<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 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
label="P-State"
value={monitor?.pstate_label ?? 'Unknown'}
history={pluck(history, 'pstate')}
color="#a8a29e"
max={15}
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
label="P-State"
value={monitor?.pstate_label ?? "Unknown"}
history={pluck(history, "pstate")}
color="#a8a29e"
max={15}
/>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -1,6 +1,6 @@
import { GaugeCard } from './GaugeCard';
import { fmt } from '../../utils/units';
import type { MonitoringSample } from '../../types';
import { GaugeCard } from "./GaugeCard.js";
import { fmt } from "../../utils/units.js";
import type { MonitoringSample } from "../../types.js";
interface Props {
monitor: MonitoringSample | null;
@@ -17,35 +17,38 @@ function pluck<K extends keyof MonitoringSample>(
export function PerformanceMonitor({ monitor, history }: Props) {
const memUsed = monitor?.mem_used_mib ?? null;
const memTotal = monitor?.mem_total_mib ?? null;
const memLabel = memUsed != null && memTotal != null
? `${memUsed.toFixed(0)} / ${memTotal.toFixed(0)} MiB`
: '—';
const memLabel =
memUsed != null && memTotal != null
? `${memUsed.toFixed(0)} / ${memTotal.toFixed(0)} MiB`
: "—";
return (
<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="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 className="flex flex-col gap-2 mt-1">
<GaugeCard
label="Mem Clock"
value={fmt.mhz(monitor?.mem_clock_mhz)}
history={pluck(history, 'mem_clock_mhz')}
history={pluck(history, "mem_clock_mhz")}
color="#67e8f9"
max={20000}
/>
<GaugeCard
label="Power Draw"
value={fmt.watts(monitor?.power_w)}
history={pluck(history, 'power_w')}
history={pluck(history, "power_w")}
color="#f472b6"
max={600}
/>
<GaugeCard
label="VRAM Used"
value={memLabel}
history={pluck(history, 'mem_used_mib')}
history={pluck(history, "mem_used_mib")}
color="#a78bfa"
max={memTotal ?? 32768}
/>
@@ -8,9 +8,9 @@ import {
LogOut,
User,
} from "lucide-react";
import type { GpuInfo, MonitoringSample } from "../../types";
import { fmt } from "../../utils/units";
import { useCurveStore } from "../../store/curveStore";
import type { GpuInfo, MonitoringSample } from "../../types.js";
import { fmt } from "../../utils/units.js";
import { useCurveStore } from "../../store/curveStore.js";
import { useState, useRef, useEffect } from "react";
interface Props {
+58 -22
View File
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import { fmt } from '../../utils/units';
import type { VFPoint } from '../../types';
import { useCurveStore } from '../../store/curveStore';
import { useState, useRef, useEffect } from "react";
import { fmt } from "../../utils/units.js";
import type { VFPoint } from "../../types.js";
import { useCurveStore } from "../../store/curveStore.js";
interface Props {
point: VFPoint;
@@ -14,17 +14,28 @@ interface Props {
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 [editing, setEditing] = useState(false);
const [inputValue, setInputValue] = useState('');
const [inputValue, setInputValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const trRef = useRef<HTMLTableRowElement>(null);
useEffect(() => {
if (shouldAutoScroll && trRef.current) {
// @ts-expect-error: the typing seems to not include the valid 'container' option
trRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', container: 'nearest' });
trRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [shouldAutoScroll]);
@@ -36,11 +47,19 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
const displayEffMhz = point.freq_mhz + deltaChange / 1000;
const deltaColor = hasPending
? displayDeltaKhz > 0 ? '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';
? displayDeltaKhz > 0
? "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() {
setInputValue((displayDeltaMhz).toFixed(1));
setInputValue(displayDeltaMhz.toFixed(1));
setEditing(true);
setTimeout(() => {
inputRef.current?.select();
@@ -64,9 +83,13 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
<tr
ref={trRef}
className={[
'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',
].join(' ')}
"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",
].join(" ")}
onMouseDown={(e) => {
if (editing) return;
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>
{/* 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 ? (
<input
ref={inputRef}
@@ -90,28 +119,35 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
onChange={(e) => setInputValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); commitEdit(); }
if (e.key === 'Escape') cancelEdit();
if (e.key === "Enter" || e.key === "Tab") {
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"
style={{ fontFamily: 'monospace' }}
className="w-20 bg-zinc-700 text-cyan-300 rounded px-1 py-0 border border-cyan-500 outline-none text-xs font-mono"
/>
) : (
<span title="Click to edit">
{hasPending && <span className="text-cyan-500 mr-0.5">✎</span>}
{displayDeltaKhz > 0 ? '+' : ''}{displayDeltaMhz.toFixed(1)} MHz
{displayDeltaKhz > 0 ? "+" : ""}
{displayDeltaMhz.toFixed(1)} MHz
</span>
)}
</td>
{/* 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)}
{isClamped && !hasPending && (
<span
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"
>⇡</span>
>
⇡
</span>
)}
</td>
@@ -1,8 +1,11 @@
import { useState, useMemo, useEffect } from 'react';
import { PointRow } from './PointRow';
import type { VFPoint } from '../../types';
import { findCurrentPoint, detectClampedPoints } from '../../utils/curveHelpers';
import { useCurveStore } from '../../store/curveStore';
import { useState, useMemo, useEffect } from "react";
import { PointRow } from "./PointRow.js";
import type { VFPoint } from "../../types.js";
import {
findCurrentPoint,
detectClampedPoints,
} from "../../utils/curveHelpers.js";
import { useCurveStore } from "../../store/curveStore.js";
interface Props {
points: VFPoint[];
@@ -11,22 +14,32 @@ interface 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 clampedPoints = useMemo(() => detectClampedPoints(points), [points]);
const [dragStartIdx, setDragStartIdx] = useState<number | null>(null);
useEffect(() => {
function onUp() { setDragStartIdx(null); }
window.addEventListener('mouseup', onUp);
return () => window.removeEventListener('mouseup', onUp);
function onUp() {
setDragStartIdx(null);
}
window.addEventListener("mouseup", onUp);
return () => window.removeEventListener("mouseup", onUp);
}, []);
return (
<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">
<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 && (
<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
@@ -35,13 +48,17 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
{readOnly && (
<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 && (
<div className="flex gap-1 ml-4 border-l border-zinc-800 pl-4">
<button
onClick={() => {
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);
}}
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
onClick={() => {
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);
}}
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">
<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">Voltage</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">
Voltage
</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" />
</tr>
</thead>
@@ -81,34 +106,48 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
isSelected={selectedPoints.has(p.index)}
isClamped={clampedPoints.has(p.index)}
pendingDeltaKhz={pendingDeltas.get(p.index)}
shouldAutoScroll={selectedPoints.size === 1 && selectedPoints.has(p.index)}
onMouseDown={readOnly ? undefined : (e) => {
if (e.shiftKey) {
const currentSelected = Array.from(selectedPoints);
if (currentSelected.length > 0) {
const last = Math.max(...currentSelected);
const min = Math.min(last, p.index);
const max = Math.max(last, p.index);
const toSelect = points.filter(a => a.index >= min && a.index <= max).map(a => a.index);
selectRange(toSelect);
} else {
selectPoint(p.index, false);
}
} else if (e.ctrlKey || e.metaKey) {
selectPoint(p.index, true);
} else {
setDragStartIdx(p.index);
selectPoint(p.index, false);
}
}}
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);
}
}}
shouldAutoScroll={
selectedPoints.size === 1 && selectedPoints.has(p.index)
}
onMouseDown={
readOnly
? undefined
: (e) => {
if (e.shiftKey) {
const currentSelected = Array.from(selectedPoints);
if (currentSelected.length > 0) {
const last = Math.max(...currentSelected);
const min = Math.min(last, p.index);
const max = Math.max(last, p.index);
const toSelect = points
.filter((a) => a.index >= min && a.index <= max)
.map((a) => a.index);
selectRange(toSelect);
} else {
selectPoint(p.index);
}
} else if (e.ctrlKey || e.metaKey) {
togglePoint(p.index);
} else {
setDragStartIdx(p.index);
selectPoint(p.index);
}
}
}
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>
@@ -8,10 +8,14 @@ import {
Star,
Fan,
} from "lucide-react";
import { api } from "../../api/client";
import type { ProfileData } from "../../types";
import { api } from "../../api/client.js";
import type { ProfileData } from "../../types.js";
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 {
activeProfile: string | null;
@@ -65,37 +69,42 @@ export function ProfilePanel({
setAutoLoadProfile(name);
if (name) toast.success(`"${name}" will load on server start`);
else toast.success("Auto-load cleared");
} catch (e: any) {
toast.error(
"Failed to update default profile: " + (e.message || String(e)),
);
} catch (e: unknown) {
toast.error("Failed to update default profile: " + errMsg(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(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchProfiles();
}, [selectedGpuIndex]);
useEffect(() => {
if (isSaveOpen) saveInputRef.current?.focus();
else setNewName("");
}, [isSaveOpen]);
useEffect(() => {
if (renamingName) renameInputRef.current?.focus();
}, [renamingName]);
async function handleSave(e: React.FormEvent) {
function closeSaveForm() {
setIsSaveOpen(false);
setNewName("");
}
async function handleSave(e: React.SubmitEvent) {
e.preventDefault();
if (!newName.trim()) return;
try {
setIsSaving(true);
await api.saveProfile(newName.trim(), selectedGpuIndex);
toast.success(`Profile "${newName.trim()}" saved`);
setIsSaveOpen(false);
closeSaveForm();
await fetchProfiles();
} catch (e: any) {
toast.error("Failed to save: " + (e.message || String(e)));
} catch (e: unknown) {
toast.error("Failed to save: " + errMsg(e));
} finally {
setIsSaving(false);
}
@@ -107,8 +116,8 @@ export function ProfilePanel({
await api.applyProfile(name, selectedGpuIndex);
onProfileApplied(name);
toast.success(`"${name}" applied`);
} catch (e: any) {
toast.error(`Failed to apply "${name}": ` + (e.message || String(e)));
} catch (e: unknown) {
toast.error(`Failed to apply "${name}": ` + errMsg(e));
} finally {
setApplyingName(null);
}
@@ -123,14 +132,14 @@ export function ProfilePanel({
if (autoLoadProfile === name) setAutoLoadProfile(null);
setDeletingName(null);
await fetchProfiles();
} catch (e: any) {
toast.error("Failed to delete: " + (e.message || String(e)));
} catch (e: unknown) {
toast.error("Failed to delete: " + errMsg(e));
} finally {
setIsDeleting(false);
}
}
async function handleRename(e: React.FormEvent, oldName: string) {
async function handleRename(e: React.SubmitEvent, oldName: string) {
e.preventDefault();
if (!renameValue.trim() || renameValue.trim() === oldName) {
setRenamingName(null);
@@ -144,8 +153,8 @@ export function ProfilePanel({
if (autoLoadProfile === oldName) setAutoLoadProfile(renameValue.trim());
setRenamingName(null);
await fetchProfiles();
} catch (e: any) {
toast.error("Failed to rename: " + (e.message || String(e)));
} catch (e: unknown) {
toast.error("Failed to rename: " + errMsg(e));
} finally {
setIsRenaming(false);
}
@@ -179,7 +188,7 @@ export function ProfilePanel({
value={newName}
onChange={(e) => setNewName(e.target.value)}
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"
/>
<button
@@ -191,7 +200,7 @@ export function ProfilePanel({
</button>
<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"
>
Cancel
@@ -10,16 +10,17 @@ interface Props {
export function ConfirmDialog({
message,
detail,
confirmLabel = 'Confirm',
confirmLabel = "Confirm",
isDestructive = false,
onConfirm,
onCancel,
}: Props) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.7)' }}
onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
onMouseDown={(e) => {
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]">
<h2 className="text-zinc-100 font-semibold text-sm mb-1">{message}</h2>
@@ -35,11 +36,11 @@ export function ConfirmDialog({
<button
onClick={onConfirm}
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
? 'bg-red-600 hover:bg-red-500 text-white'
: 'bg-emerald-600 hover:bg-emerald-500 text-white',
].join(' ')}
? "bg-red-600 hover:bg-red-500 text-white"
: "bg-emerald-600 hover:bg-emerald-500 text-white",
].join(" ")}
>
{confirmLabel}
</button>
+9 -7
View File
@@ -1,12 +1,14 @@
import { useEffect, useRef, useState } from 'react';
import { api } from '../api/client';
import { createWsConnection } from '../api/websocket';
import { useCurveStore } from '../store/curveStore';
import type { CurveState } from '../types';
import { useEffect, useRef, useState } from "react";
import { api } from "../api/client.js";
import { createWsConnection } from "../api/websocket.js";
import { useCurveStore } from "../store/curveStore.js";
import type { CurveState } from "../types.js";
export function useCurve() {
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);
useEffect(() => {
@@ -15,7 +17,7 @@ export function useCurve() {
// Subscribe to /ws/curve for push updates after writes
wsRef.current = createWsConnection<CurveState>(
'/ws/curve',
"/ws/curve",
(data) => setCurve(data),
setWsStatus,
selectedGpuIndex,
+9 -9
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { api } from "../api/client";
import { useCurveStore } from "../store/curveStore";
import type { DashboardInfo } from "../types";
import { api } from "../api/client.js";
import { useCurveStore } from "../store/curveStore.js";
import type { DashboardInfo } from "../types.js";
interface DashboardState {
gpuIndex: number;
@@ -23,17 +23,17 @@ export function useDashboard() {
useEffect(() => {
let cancelled = false;
api
.dashboard(selectedGpuIndex)
.then((data) => {
(async () => {
try {
const data = await api.dashboard(selectedGpuIndex);
if (!cancelled)
setState({ gpuIndex: selectedGpuIndex, data, done: true });
})
.catch((err) => {
} catch (err) {
console.error("Failed to load dashboard info:", err);
if (!cancelled)
setState({ gpuIndex: selectedGpuIndex, data: null, done: true });
});
}
})();
return () => {
cancelled = true;
};
+5 -4
View File
@@ -1,9 +1,10 @@
import { useEffect } from 'react';
import { api } from '../api/client';
import { useCurveStore } from '../store/curveStore';
import { useEffect } from "react";
import { api } from "../api/client.js";
import { useCurveStore } from "../store/curveStore.js";
export function useGpu() {
const { gpuInfo, setGpuInfo, setAvailableGpus, selectedGpuIndex } = useCurveStore();
const { gpuInfo, setGpuInfo, setAvailableGpus, selectedGpuIndex } =
useCurveStore();
useEffect(() => {
api.gpus().then(setAvailableGpus).catch(console.error);
+10 -7
View File
@@ -1,16 +1,19 @@
import { useEffect, useRef, useState } from 'react';
import { createWsConnection } from '../api/websocket';
import { useCurveStore } from '../store/curveStore';
import type { MonitoringSample } from '../types';
import { useEffect, useRef, useState } from "react";
import { createWsConnection } from "../api/websocket.js";
import { useCurveStore } from "../store/curveStore.js";
import type { MonitoringSample } from "../types.js";
export function useMonitor() {
const { monitor, monitorHistory, pushMonitor, selectedGpuIndex } = useCurveStore();
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
const { monitor, monitorHistory, pushMonitor, selectedGpuIndex } =
useCurveStore();
const [wsStatus, setWsStatus] = useState<
"connecting" | "connected" | "disconnected"
>("connecting");
const wsRef = useRef<ReturnType<typeof createWsConnection> | null>(null);
useEffect(() => {
wsRef.current = createWsConnection<MonitoringSample>(
'/ws/monitor',
"/ws/monitor",
pushMonitor,
setWsStatus,
selectedGpuIndex,
+95 -45
View File
@@ -1,7 +1,12 @@
import { create } from 'zustand';
import type { CurveState, GpuInfo, MonitoringSample, VFPoint } from '../types';
import { api } from '../api/client';
import { toast } from 'sonner';
import { create } from "zustand";
import type {
CurveState,
GpuInfo,
MonitoringSample,
VFPoint,
} from "../types.js";
import { api } from "../api/client.js";
import { toast } from "sonner";
const HISTORY_SIZE = 120; // ~60s at 2Hz
@@ -50,7 +55,9 @@ interface CurveStore {
resetAllDeltas: (onSuccess: () => void) => Promise<void>;
// 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;
clearSelection: () => void;
/**
@@ -80,7 +87,16 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
setAvailableGpus: (availableGpus) => set({ availableGpus }),
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 }),
setGpuInfo: (gpuInfo) => set({ gpuInfo }),
@@ -95,7 +111,7 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
stageEdit: (pointIndex, deltaKhz) =>
set((s) => {
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) {
next.delete(pointIndex);
} else {
@@ -108,7 +124,7 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
set((s) => {
const next = new Map(s.pendingDeltas);
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) {
next.delete(index);
} else {
@@ -128,27 +144,39 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
}),
discardEdits: () =>
set({ pendingDeltas: new Map(), selectedPoints: new Set(), anchorPoint: null }),
set({
pendingDeltas: new Map(),
selectedPoints: new Set(),
anchorPoint: null,
}),
applyEdits: async (onSuccess) => {
const { pendingDeltas, selectedGpuIndex } = get();
if (pendingDeltas.size === 0) return;
// Convert Map to plain record for the API
const deltas: Record<number, number> = {};
pendingDeltas.forEach((v, k) => { deltas[k] = v; });
const deltas: Record<number, number> = Object.fromEntries(pendingDeltas);
try {
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) {
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 {
toast.success('Curve applied successfully');
toast.success("Curve applied successfully");
}
onSuccess();
} catch (e: any) {
toast.error('Failed to apply curve: ' + (e.message || String(e)));
} catch (e: unknown) {
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();
try {
await api.resetCurve(selectedGpuIndex);
set({ pendingDeltas: new Map(), selectedPoints: new Set(), activeProfile: null });
toast.success('Curve reset to hardware defaults');
set({
pendingDeltas: new Map(),
selectedPoints: new Set(),
activeProfile: null,
});
toast.success("Curve reset to hardware defaults");
onSuccess();
} catch (e: any) {
toast.error('Failed to reset curve: ' + (e.message || String(e)));
} catch (e: unknown) {
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) => {
const next = new Set(s.selectedPoints);
let anchor = s.anchorPoint;
if (multi) {
if (next.has(index)) {
next.delete(index);
if (anchor === index) anchor = next.size > 0 ? [...next].at(-1)! : null;
} else {
next.add(index);
anchor = index; // last explicitly added point is the new anchor
if (next.has(index)) {
next.delete(index);
if (anchor === index) {
const last = [...next].at(-1);
anchor = last ?? null;
}
} else {
if (next.size === 1 && next.has(index)) {
next.clear();
anchor = null;
} else {
next.clear();
next.add(index);
anchor = index;
}
next.add(index);
anchor = index; // last explicitly added point is the new 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.
set((s) => {
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 };
}),
clearSelection: () =>
set({ selectedPoints: new Set(), anchorPoint: null }),
clearSelection: () => set({ selectedPoints: new Set(), anchorPoint: null }),
flattenToAnchor: () => {
const { selectedPoints, anchorPoint, pendingDeltas, curve, stageMultiEdit } = get();
const {
selectedPoints,
anchorPoint,
pendingDeltas,
curve,
stageMultiEdit,
} = get();
if (selectedPoints.size < 2 || !curve) return;
const anchor = anchorPoint !== null && selectedPoints.has(anchorPoint)
? anchorPoint
: Math.min(...selectedPoints);
const anchor =
anchorPoint !== null && selectedPoints.has(anchorPoint)
? 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;
const anchorPendingDelta = pendingDeltas.get(anchor) ?? anchorPt.delta_khz;
const anchorEffectiveKhz = anchorPt.freq_khz + anchorPendingDelta;
const edits = new Map<number, number>();
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;
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.
+124 -58
View File
@@ -31,6 +31,7 @@ First-time / diagnostic commands (bypass server, escalate to root):
import argparse
import json
import logging
import os
import struct
import sys
@@ -48,6 +49,8 @@ from .nvapi.constants import (
VFP_STRIDE,
)
log = logging.getLogger("nvcurve.cli")
# ── Utilities ─────────────────────────────────────────────────────────────────
@@ -69,8 +72,8 @@ def parse_range(s: str):
raise argparse.ArgumentTypeError(f"Expected A-B format, got '{s}'")
try:
a, b = int(parts[0]), int(parts[1])
except ValueError:
raise argparse.ArgumentTypeError(f"Non-integer in range: '{s}'")
except ValueError as exc:
raise argparse.ArgumentTypeError(f"Non-integer in range: '{s}'") from exc
if a > b:
raise argparse.ArgumentTypeError(f"Start > end in range: {a}-{b}")
if a < 0 or b >= CT_POINTS:
@@ -98,7 +101,7 @@ def print_curve(points, offsets, voltage, domains=None, full=False):
current_idx = None
if voltage:
for i, (f, v) in enumerate(points):
for i, (_f, v) in enumerate(points):
if v > 0 and abs(v - voltage) < 10000:
current_idx = i
break
@@ -214,7 +217,7 @@ def print_curve(points, offsets, voltage, domains=None, full=False):
if offsets:
nonzero = sum(1 for o in offsets if o != 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:
print(
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),
]
for name, fid, size, ver, needs_mask in probes:
for name, fid, size, ver, _needs_mask in probes:
ptr = query_interface(fid)
resolved = "resolved" if ptr else "NOT FOUND"
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,
)
return
except Exception:
pass
except Exception as exc:
log.debug("runuser xdg-open failed, falling back to webbrowser: %s", exc)
import webbrowser
webbrowser.open(url)
@@ -445,7 +448,7 @@ def require_root():
]
try:
# PYTHONDONTWRITEBYTECODE prevents root-owned __pycache__ in site-packages.
os.execvp(
os.execvp( # noqa: S606 — intentional re-exec via 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
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"
if host not in _ALLOWED_HOSTS:
print(
@@ -514,7 +517,7 @@ def _safe_host(host: str, cfg: Config) -> 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:
@@ -737,8 +740,14 @@ def cmd_inspect(args):
def cmd_write(args):
delta_khz = int(args.delta * 1000)
max_delta_khz = int(args.max_delta * 1000) if args.max_delta is not None else None
try:
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 = {}
if args.reset:
@@ -837,14 +846,15 @@ def cmd_write(args):
print(f"Write OK — {len(point_deltas)} point(s) updated.")
try:
curve_state = None
if not args.glob:
curve_state, _ = read_curve(gpu, gpu_name)
if curve_state:
vfp_freqs = [p.freq_khz for p in curve_state.points]
for w in check_negative_freq_warnings(point_deltas, vfp_freqs, []):
print(f"WARNING: {w}")
except Exception:
pass
except Exception as exc:
log.debug("Post-write curve check failed: %s", exc)
def cmd_verify(args):
@@ -855,7 +865,11 @@ def cmd_verify(args):
from .hal.snapshot import save as snapshot_save
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:
points = [args.point]
@@ -1016,8 +1030,11 @@ def _profile_config_write(key: str, value) -> None:
data.pop(key, None)
else:
data[key] = value
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
_json.dump(data, f, indent=2)
try:
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:
@@ -1066,8 +1083,11 @@ def _profile_config_set_default(gpu_index: int, name: str | None) -> None:
profiles[gpu_key] = name
if not profiles:
data.pop("auto_load_profiles", None)
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
_json.dump(data, f, indent=2)
try:
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):
@@ -1094,8 +1114,8 @@ def cmd_profile(args):
profiles.append(
{"name": name, "curve_deltas": p.get("curve_deltas", {})}
)
except Exception:
pass
except Exception as exc:
log.debug("Skipping unreadable profile %s: %s", path, exc)
if not profiles:
print("No profiles found.")
return
@@ -1117,7 +1137,7 @@ def cmd_profile(args):
require_root()
try:
_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)
return
if clearing:
@@ -1205,7 +1225,14 @@ def cmd_profile(args):
errs.append(f"Power limit: {msg}")
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)
if errors:
errs.append("Curve: " + "; ".join(errors))
@@ -1328,7 +1355,11 @@ def cmd_setup(args):
"""One-shot hardware compatibility check: diag → read → write-verify → restore."""
explicit_point = getattr(args, "point", None)
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()
@@ -1452,13 +1483,20 @@ def cmd_setup(args):
print()
print("Step 4/4 Restoring snapshot")
print()
ok = snapshot_restore(gpu, default_config.snapshot_dir, snap_path)
if ok:
print(" Hardware state restored to baseline.")
else:
if snap_path is None:
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(sep)
@@ -1509,24 +1547,36 @@ def cmd_service(args):
"WantedBy=multi-user.target\n"
)
with open(unit_path, "w") as f:
f.write(unit)
try:
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}")
# 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 = {}
try:
with open(_PERSISTENT_CONFIG_FILE) as f:
persistent_cfg = json.load(f)
except Exception:
pass
except Exception as exc:
log.debug("Could not read persistent config: %s", exc)
host = getattr(args, "host", "127.0.0.1")
port = getattr(args, "port", 8042)
auto_serve = getattr(args, "auto_serve", False)
persistent_cfg.update({"host": host, "port": port, "auto_serve": auto_serve})
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
json.dump(persistent_cfg, f, indent=2)
try:
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}")
if auto_serve:
print(f" Web server will auto-start on boot at {host}:{port}")
@@ -1538,12 +1588,8 @@ def cmd_service(args):
try:
subprocess.run(["systemctl", "daemon-reload"], check=True)
was_active = (
subprocess.run(
["systemctl", "is-active", "--quiet", "nvcurve"],
).returncode
== 0
)
probe = subprocess.run(["systemctl", "is-active", "--quiet", "nvcurve"])
was_active = probe.returncode == 0
subprocess.run(["systemctl", "enable", "--now", "nvcurve"], check=True)
print("Service enabled and started.")
@@ -1560,7 +1606,7 @@ def cmd_service(args):
print(" systemctl status nvcurve")
print(" journalctl -u nvcurve -f")
print(" nvcurve service uninstall")
except subprocess.CalledProcessError as e:
except (subprocess.CalledProcessError, FileNotFoundError) as e:
print(f"systemctl failed: {e}", file=sys.stderr)
elif action == "uninstall":
@@ -1672,13 +1718,17 @@ def cmd_service(args):
require_root()
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 = {}
try:
with open(_PERSISTENT_CONFIG_FILE) as f:
pcfg = json.load(f)
except Exception:
pass
except Exception as exc:
log.debug("Could not read persistent config: %s", exc)
if hasattr(args, "auto_serve") and args.auto_serve is not None:
pcfg["auto_serve"] = args.auto_serve
@@ -1687,8 +1737,12 @@ def cmd_service(args):
if hasattr(args, "port") and args.port is not None:
pcfg["port"] = args.port
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
json.dump(pcfg, f, indent=2)
try:
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" auto-serve: {'on' if pcfg.get('auto_serve', False) else 'off'}")
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).
if getattr(args, "direct", False):
require_root()
with open(_SERVER_INFO_FILE, "w") as f:
json.dump({"pid": os.getpid(), "host": host, "port": port}, f)
try:
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:
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)]
log_path = _log_file()
print("Starting nvcurve server in background...")
with open(log_path, "a") as lf:
p = subprocess.Popen(cmd, stdout=lf, stderr=lf, start_new_session=True)
try:
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}")
if open_browser:
time.sleep(1.5)
@@ -1782,8 +1844,12 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None:
return
# Foreground mode — write info file so clients can discover host:port.
with open(_SERVER_INFO_FILE, "w") as f:
json.dump({"pid": os.getpid(), "host": host, "port": port}, f)
try:
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:
from .server import run as server_run
@@ -2095,8 +2161,8 @@ def main():
if "fan_curves" in data:
# Per-GPU active fan curves, restored on server startup.
cfg.fan_curves = dict(data["fan_curves"])
except Exception:
pass
except Exception as exc:
log.debug("Could not load user config: %s", exc)
base_url = args.server or _discover_server_url(cfg)
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):
try:
os.remove(_SERVER_INFO_FILE)
except OSError:
pass
except OSError as exc:
log.debug("Could not remove %s: %s", _SERVER_INFO_FILE, exc)
except ApiError as e:
if e.status_code == 401:
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
# intentional (standard for /run daemon sockets).
# pi-lens-ignore: S103
_SOCKET_MODE = 0o666
os.chmod(
SOCKET_PATH, 0o666
SOCKET_PATH, _SOCKET_MODE
) # nosemgrep: python.lang.security.audit.insecure-file-permissions.insecure-file-permissions
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 logging
from typing import List, Optional
from typing import Any
try:
import pynvml
import pynvml as _pynvml_import
_NVML_AVAILABLE = True
except ImportError:
_pynvml_import = None
_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")
# 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.
"""
out = {
out: dict[str, float | None] = {
"fan_pct": None,
"fan_mode": 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]:
"""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:
return False, "NVML not available"
try:
@@ -102,7 +110,9 @@ def reset_fan(gpu_index: int = 0) -> tuple[bool, str]:
try:
ret = subprocess.run(
["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:
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}"
def get_temp(gpu_index: int = 0) -> Optional[float]:
def get_temp(gpu_index: int = 0) -> float | None:
"""Read current GPU temperature in °C."""
if not _NVML_AVAILABLE:
return None
try:
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:
log.debug("get_temp: %s", exc)
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.
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:
return None
temp = float(temp_c)
try:
temp = float(temp_c)
except (TypeError, ValueError):
return None
# Find the two surrounding points
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:
fraction = (temp - t0) / (t1 - t0)
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
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"]))
def validate_curve(curve: List[dict]) -> tuple[bool, str]:
def validate_curve(curve: list[dict]) -> tuple[bool, str]:
"""Validate a fan curve.
Returns (True, "OK") or (False, error_message).
+38 -20
View File
@@ -1,12 +1,17 @@
"""GPU discovery and initialization."""
import contextlib
import ctypes
import logging
import sys
from typing import Any
from ..nvapi.bootstrap import query_interface
from ..nvapi.constants import FUNC
from ..nvapi.types import GpuInfo
log = logging.getLogger("nvcurve.hal.gpu")
def init_nvapi() -> None:
"""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."""
gpus = (ctypes.c_void_p * 64)()
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:
print("No NVIDIA GPUs found")
sys.exit(1)
@@ -29,7 +37,10 @@ def enumerate_gpus() -> tuple[ctypes.Array, int]:
def get_gpu_name(gpu) -> str:
"""Return the full name string for a GPU handle."""
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")
@@ -40,38 +51,45 @@ def discover_gpus() -> list[GpuInfo]:
infos = []
try:
import pynvml
pynvml.nvmlInit()
has_nvml = True
import pynvml as _pynvml
_pynvml.nvmlInit()
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):
name = get_gpu_name(gpus[i])
uuid = None
pci_bus_id = None
if has_nvml:
if pynvml is not None:
try:
handle = pynvml.nvmlDeviceGetHandleByIndex(i)
uuid = pynvml.nvmlDeviceGetUUID(handle)
raw_uuid = pynvml.nvmlDeviceGetUUID(handle)
# NVML might return bytes
if isinstance(uuid, bytes):
uuid = uuid.decode('utf-8', errors='ignore')
if isinstance(raw_uuid, bytes):
uuid = raw_uuid.decode("utf-8", errors="ignore")
elif raw_uuid is not None:
uuid = str(raw_uuid)
pci_info = pynvml.nvmlDeviceGetPciInfo(handle)
# Parse something like "00000000:01:00.0" -> bus is 1
if isinstance(pci_info.bus, bytes):
pci_bus_id = int(pci_info.bus.decode('utf-8', errors='ignore'), 16)
# Parse something like "00000000:01:00.0" -> bus is 1.
# PCI bus numbers are hex by convention (pynvml's field is an
# 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:
pci_bus_id = pci_info.bus
except Exception:
pass
pci_bus_id = int(bus)
except Exception as exc:
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))
if has_nvml:
try:
if pynvml is not None:
with contextlib.suppress(Exception):
pynvml.nvmlShutdown()
except Exception:
pass
return infos
+75 -38
View File
@@ -11,21 +11,26 @@ that are explicitly specified, leaving others unchanged on hardware.
"""
import ctypes
import subprocess
import logging
from typing import Optional
import subprocess
from typing import Any
try:
import pynvml
import pynvml as _pynvml_import
_NVML_AVAILABLE = True
except ImportError:
_pynvml_import = None
_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")
# ── NVML library / handle helpers ─────────────────────────────────────────────
_nvml_lib: Optional[ctypes.CDLL] = None
_nvml_lib: ctypes.CDLL | None = None
def _nvml_cdll() -> ctypes.CDLL:
@@ -34,12 +39,12 @@ def _nvml_cdll() -> ctypes.CDLL:
if _nvml_lib is not None:
return _nvml_lib
# 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)
lib = getattr(mod, "_lib", None) or getattr(mod, "_nvmlLib", None)
if lib is not None:
_nvml_lib = lib
return _nvml_lib
return lib
_nvml_lib = ctypes.CDLL("libnvidia-ml.so.1")
return _nvml_lib
@@ -53,9 +58,10 @@ def _get_handle(gpu_index: int):
# ── Power limit ───────────────────────────────────────────────────────────────
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."""
out = {
out: dict[str, int | None] = {
"power_limit_w": None,
"default_power_limit_w": None,
"min_power_limit_w": None,
@@ -71,8 +77,8 @@ def get_power_limit(gpu_index: int = 0) -> dict:
try:
default = pynvml.nvmlDeviceGetPowerManagementDefaultLimit(handle)
out["default_power_limit_w"] = default // 1000
except Exception:
pass
except Exception as exc:
log.debug("nvmlDeviceGetPowerManagementDefaultLimit: %s", exc)
except Exception as exc:
log.warning("get_power_limit: %s", exc)
return out
@@ -89,7 +95,8 @@ def set_power_limit(limit_w: int, gpu_index: int = 0) -> tuple[bool, str]:
ret = subprocess.run(
["nvidia-smi", "-i", str(gpu_index), "-pl", str(limit_w)],
capture_output=True, text=True,
capture_output=True,
text=True,
)
if ret.returncode == 0:
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
# as ctypes objects; we use them when available and fall back to our own definition.
class _ClockOffset(ctypes.Structure):
_fields_ = [
("version", ctypes.c_uint),
("type", ctypes.c_uint), # nvmlClockType_t
("pstate", ctypes.c_uint), # nvmlPstates_t
("version", ctypes.c_uint),
("type", ctypes.c_uint), # nvmlClockType_t
("pstate", ctypes.c_uint), # nvmlPstates_t
("clockOffsetMHz", ctypes.c_int),
]
_CLOCK_OFFSET_VER = (1 << 24) | ctypes.sizeof(_ClockOffset) # = 0x01000010 (16 bytes)
# NVML clock-type constants (same values as pynvml).
_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."""
if hasattr(pynvml, "c_nvmlClockOffset_t") and hasattr(pynvml, "nvmlClockOffset_v1"):
info = pynvml.c_nvmlClockOffset_t()
info.version = pynvml.nvmlClockOffset_v1
info.type = clock_type
info.pstate = pstate
info.version = pynvml.nvmlClockOffset_v1
info.type = clock_type
info.pstate = pstate
info.clockOffsetMHz = offset_mhz
return info
info = _ClockOffset()
info.version = _CLOCK_OFFSET_VER
info.type = clock_type
info.pstate = pstate
info.version = _CLOCK_OFFSET_VER
info.type = clock_type
info.pstate = pstate
info.clockOffsetMHz = offset_mhz
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).
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:
return out
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).
# Fall back to ctypes-direct if pynvml doesn't have it.
_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
for clock_type, key in ((_NVML_CLOCK_GRAPHICS, "gpc_offset_mhz"),
(_NVML_CLOCK_MEM, "mem_offset_mhz")):
for clock_type, key in (
(_NVML_CLOCK_GRAPHICS, "gpc_offset_mhz"),
(_NVML_CLOCK_MEM, "mem_offset_mhz"),
):
info = _make_clock_offset(clock_type, pstate=0)
try:
if _pynvml_get is not None:
@@ -184,7 +199,9 @@ def get_clock_offsets(gpu_index: int = 0) -> dict:
out[key] = int(info.clockOffsetMHz)
used_new_api = True
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:
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"):
try:
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:
log.debug("nvmlDeviceGetMemClkVfOffset: %s", exc)
@@ -210,8 +229,8 @@ def get_clock_offsets(gpu_index: int = 0) -> dict:
def set_clock_offsets(
gpc_offset_mhz: Optional[int] = None,
mem_offset_mhz: Optional[int] = None,
gpc_offset_mhz: int | None = None,
mem_offset_mhz: int | None = None,
gpu_index: int = 0,
) -> tuple[bool, str]:
"""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))
_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:
all_ok = True
for clock_type, offset in domains:
info = _make_clock_offset(clock_type, pstate=0, offset_mhz=offset)
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:
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
break
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
break
if all_ok:
@@ -257,12 +291,16 @@ def set_clock_offsets(
# Deprecated per-domain fallback (works on Blackwell/driver 590.x).
errs = []
if gpc_offset_mhz is not None and hasattr(pynvml, "nvmlDeviceSetGpcClkVfOffset"):
if gpc_offset_mhz is not None and hasattr(
pynvml, "nvmlDeviceSetGpcClkVfOffset"
):
try:
pynvml.nvmlDeviceSetGpcClkVfOffset(handle, gpc_offset_mhz)
except Exception as 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:
pynvml.nvmlDeviceSetMemClkVfOffset(handle, mem_offset_mhz)
except Exception as exc:
@@ -278,6 +316,7 @@ def set_clock_offsets(
# ── Range queries ─────────────────────────────────────────────────────────────
def get_mem_offset_range(gpu_index: int = 0) -> dict:
"""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.
"""
# 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:
return out
try:
@@ -317,5 +356,3 @@ def get_mem_offset_range(gpu_index: int = 0) -> dict:
except Exception as exc:
log.debug("get_mem_offset_range: %s", exc)
return out
+61 -30
View File
@@ -2,18 +2,20 @@
import ctypes
import json
import logging
import os
import struct
from datetime import datetime
from typing import Optional
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 .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.
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}")
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")
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")
with open(bin_path, "wb") as f:
f.write(raw)
try:
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 = []
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,
"nonzero_offsets": sum(1 for o in offsets if o != 0),
}
with open(meta_path, "w") as f:
json.dump(meta, f, indent=2)
try:
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" Metadata: {meta_path}")
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:
"""Delete oldest snapshots (both .bin and .json) to stay within max_snapshots."""
bins = sorted(
f for f in os.listdir(snapshot_dir) if f.endswith(".bin")
) # oldest first (lexicographic = chronological for our timestamp format)
# Oldest first (lexicographic = chronological for our timestamp format).
try:
bins = sorted(f for f in os.listdir(snapshot_dir) if f.endswith(".bin"))
except OSError:
return
excess = len(bins) - max_snapshots
for fname in bins[:excess]:
stem = fname[:-4] # strip .bin
for ext in (".bin", ".json"):
try:
os.remove(os.path.join(snapshot_dir, stem + ext))
except OSError:
pass
except OSError as exc:
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.
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):
print(f"No snapshots found in {snapshot_dir}")
return False
bins = sorted(
[f for f in os.listdir(snapshot_dir) if f.endswith(".bin")],
reverse=True,
)
try:
bins = sorted(
[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:
print(f"No snapshot .bin files in {snapshot_dir}")
return False
@@ -101,8 +121,12 @@ def restore(gpu, snapshot_dir: str, filepath: str = None) -> bool:
print(f"Snapshot file not found: {filepath}")
return False
with open(filepath, "rb") as f:
raw = f.read()
try:
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:
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):
return []
try:
fnames = sorted(os.listdir(snapshot_dir), reverse=True)
except OSError:
return []
results = []
for fname in sorted(os.listdir(snapshot_dir), reverse=True):
for fname in fnames:
if not fname.endswith(".json"):
continue
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:
meta = json.load(f)
bin_path = meta.get("file", meta_path.replace(".json", ".bin"))
results.append(SnapshotInfo(
filepath=bin_path,
timestamp=meta.get("timestamp", ""),
gpu=meta.get("gpu", ""),
nonzero_offsets=meta.get("nonzero_offsets", 0),
size=meta.get("size", 0),
))
results.append(
SnapshotInfo(
filepath=bin_path,
timestamp=meta.get("timestamp", ""),
gpu=meta.get("gpu", ""),
nonzero_offsets=meta.get("nonzero_offsets", 0),
size=meta.get("size", 0),
)
)
except (json.JSONDecodeError, KeyError):
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]:
"""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.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.vfcurve import reset_offsets, write_offsets
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()
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}")
if profile.curve_deltas:
deltas = {int(k): v for k, v in profile.curve_deltas.items()}
errors = validate_write(deltas, cfg.max_delta_khz)
if errors:
errs.append("Curve: " + "; ".join(errors))
try:
deltas = {int(k): v for k, v in profile.curve_deltas.items()}
except ValueError:
errs.append("Curve: invalid point keys in profile")
else:
if cfg.auto_snapshot:
try:
snapshot_save(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}")
errors = validate_write(deltas, cfg.max_delta_khz)
if errors:
errs.append("Curve: " + "; ".join(errors))
else:
if cfg.auto_snapshot:
try:
snapshot_save(
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:
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:
"""Apply a named profile with read-back verification, retrying on mismatch."""
from .native import load_profile
from ..hal.gpu import get_gpu
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()
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)
return False
expected: dict[int, int] = (
{int(k): v for k, v in profile.curve_deltas.items()}
if profile.curve_deltas else {}
)
try:
expected: dict[int, int] = (
{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):
try:
errs = apply_profile(gpu_index, name, cfg)
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)]
if errs:
log.warning("Auto-load attempt %d/%d errors: %s",
attempt + 1, max_retries, "; ".join(errs))
log.warning(
"Auto-load attempt %d/%d errors: %s",
attempt + 1,
max_retries,
"; ".join(errs),
)
elif expected:
gpu, _ = get_gpu(index=gpu_index)
offsets, err = read_clock_offsets(gpu)
if offsets is None:
log.warning("Auto-load attempt %d/%d: read-back failed: %s",
attempt + 1, max_retries, err)
log.warning(
"Auto-load attempt %d/%d: read-back failed: %s",
attempt + 1,
max_retries,
err,
)
else:
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()
if idx < len(offsets) and offsets[idx] != val
]
if not mismatches:
log.info("Auto-load profile %r verified on GPU %d (attempt %d/%d)",
name, gpu_index, attempt + 1, max_retries)
log.info(
"Auto-load profile %r verified on GPU %d (attempt %d/%d)",
name,
gpu_index,
attempt + 1,
max_retries,
)
return True
log.warning("Auto-load attempt %d/%d: read-back mismatch — %s",
attempt + 1, max_retries, "; ".join(mismatches))
log.warning(
"Auto-load attempt %d/%d: read-back mismatch — %s",
attempt + 1,
max_retries,
"; ".join(mismatches),
)
else:
log.info("Auto-load profile %r applied on GPU %d (attempt %d/%d)",
name, gpu_index, attempt + 1, max_retries)
log.info(
"Auto-load profile %r applied on GPU %d (attempt %d/%d)",
name,
gpu_index,
attempt + 1,
max_retries,
)
return True
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)
time.sleep(delay)
log.warning("Auto-load profile %r failed after %d attempts on GPU %d",
name, max_retries, gpu_index)
log.warning(
"Auto-load profile %r failed after %d attempts on GPU %d",
name,
max_retries,
gpu_index,
)
return False
@@ -153,13 +196,19 @@ def run_autoload() -> None:
return
from ..config import Config
cfg = Config()
for key in ("max_delta_khz", "auto_snapshot", "max_snapshots",
"snapshot_dir", "profile_dir"):
for key in (
"max_delta_khz",
"auto_snapshot",
"max_snapshots",
"snapshot_dir",
"profile_dir",
):
if key in cfg_data:
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
# 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:
log.warning("Auto-load: no GPU found with key %r — skipping", gpu_key)
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)
shutdown_nvml()
+30 -18
View File
@@ -1,39 +1,52 @@
"""Native profile storage and schema."""
import json
import os
import glob
from dataclasses import dataclass, asdict
from typing import Dict, Optional, List
import json
import logging
import os
from dataclasses import asdict, dataclass
log = logging.getLogger("nvcurve.profiles.native")
@dataclass
class ProfileData:
name: str
gpu_name: str
curve_deltas: Dict[str, int] # { "index": delta_khz }
mem_offset_mhz: Optional[int] = None
power_limit_w: Optional[int] = None
fan_curve: Optional[List[Dict[str, int]]] = None
curve_deltas: dict[str, int] # { "index": delta_khz }
mem_offset_mhz: int | None = None
power_limit_w: int | None = None
fan_curve: list[dict[str, int]] | None = None
def save_profile(profile_dir: str, data: ProfileData) -> str:
"""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()
if not safe_name:
safe_name = "Unnamed"
filepath = os.path.join(profile_dir, f"{safe_name}.json")
with open(filepath, "w", encoding="utf-8") as f:
json.dump(asdict(data), f, indent=2)
try:
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
def load_profile(filepath: str) -> ProfileData:
"""Load profile from JSON."""
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
try:
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.
if "vram_p0_offset_mhz" in data and "mem_offset_mhz" not in data:
data["mem_offset_mhz"] = data.pop("vram_p0_offset_mhz")
@@ -43,7 +56,7 @@ def load_profile(filepath: str) -> ProfileData:
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."""
if not os.path.exists(profile_dir):
return []
@@ -51,9 +64,8 @@ def list_profiles(profile_dir: str) -> List[ProfileData]:
for fp in glob.glob(os.path.join(profile_dir, "*.json")):
try:
profiles.append(load_profile(fp))
except Exception as e:
# log warning ideally, but swallowing for robustness
pass
except Exception as exc:
log.debug("Skipping unreadable profile %s: %s", fp, exc)
# Sort alphabetically by name
profiles.sort(key=lambda p: p.name.lower())
return profiles
+6 -3
View File
@@ -90,8 +90,8 @@ def _open_browser_as_user(url: str) -> None:
stderr=subprocess.DEVNULL,
)
return
except Exception:
pass
except Exception as exc:
log.debug("runuser xdg-open failed, falling back to webbrowser: %s", exc)
import webbrowser
webbrowser.open(url)
@@ -1692,7 +1692,10 @@ def _resolve_dist_dir() -> str:
the project-root layout used during local development.
"""
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"
if candidate.is_dir():