diff --git a/.gitignore b/.gitignore index 37365ef..c8e4c18 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,6 @@ node_modules/ /dist/ /build/ *.egg-info/ -.claude \ No newline at end of file +.claude +# Local tool data +.codegraph/ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5d7c351..d763434 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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"; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 502648b..c4ed12f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -8,7 +8,7 @@ import type { FanState, FanPoint, DashboardInfo, -} from "../types"; +} from "../types.js"; export class ApiError extends Error { status: number; diff --git a/frontend/src/components/Auth/LoginScreen.tsx b/frontend/src/components/Auth/LoginScreen.tsx index 1f2bad8..e34f6e8 100644 --- a/frontend/src/components/Auth/LoginScreen.tsx +++ b/frontend/src/components/Auth/LoginScreen.tsx @@ -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(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); diff --git a/frontend/src/components/CurveEditor/CurveEditor.tsx b/frontend/src/components/CurveEditor/CurveEditor.tsx index ca0e854..493213f 100644 --- a/frontend/src/components/CurveEditor/CurveEditor.tsx +++ b/frontend/src/components/CurveEditor/CurveEditor.tsx @@ -1,17 +1,21 @@ -import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; -import { Check, X, RotateCcw, RefreshCw } from 'lucide-react'; -import { scaleLinear } from 'd3'; -import type { VFPoint, CurveState } from '../../types'; -import { CurveTooltip } from './CurveTooltip'; -import { CurveToolbar } from './CurveToolbar'; -import { ConfirmDialog } from '../common/ConfirmDialog'; -import { voltExtent, refBaseMhz, detectClampedPoints } from '../../utils/curveHelpers'; -import { useCurveStore } from '../../store/curveStore'; +import { useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { Check, X, RotateCcw, RefreshCw } from "lucide-react"; +import { scaleLinear } from "d3"; +import type { VFPoint, CurveState } from "../../types.js"; +import { CurveTooltip } from "./CurveTooltip.js"; +import { CurveToolbar } from "./CurveToolbar.js"; +import { ConfirmDialog } from "../common/ConfirmDialog.js"; +import { + voltExtent, + refBaseMhz, + detectClampedPoints, +} from "../../utils/curveHelpers.js"; +import { useCurveStore } from "../../store/curveStore.js"; interface Props { curve: CurveState; - activeDomain: 'gpu' | 'memory'; - onDomainChange: (d: 'gpu' | 'memory') => void; + activeDomain: "gpu" | "memory"; + onDomainChange: (d: "gpu" | "memory") => void; currentVoltageMv: number | null; currentClockMhz: number | null; onRefresh: () => void; @@ -30,38 +34,61 @@ interface DragInfo { } /** Box-select rubber-band rect (SVG inner coords) */ -interface BoxRect { x0: number; y0: number; x1: number; y1: number } +interface BoxRect { + x0: number; + y0: number; + x1: number; + y1: number; +} -export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltageMv, currentClockMhz, onRefresh }: Props) { - const readOnly = activeDomain === 'memory'; +export function CurveEditor({ + curve, + activeDomain, + onDomainChange, + currentVoltageMv, + currentClockMhz, + onRefresh, +}: Props) { + const readOnly = activeDomain === "memory"; const [hoveredPoint, setHoveredPoint] = useState(null); const [dragInfo, setDragInfo] = useState(null); const [boxRect, setBoxRect] = useState(null); - const [inlineInput, setInlineInput] = useState<{ pointIndex: number; value: string } | null>(null); + const [inlineInput, setInlineInput] = useState<{ + pointIndex: number; + value: string; + } | null>(null); const svgRef = useRef(null); const containerRef = useRef(null); - const [svgCursor, setSvgCursor] = useState('default'); + const [svgCursor, setSvgCursor] = useState("default"); // ─── Dynamic SVG dimensions ────────────────────────────────────────────── const [svgW, setSvgW] = useState(680); const svgWRef = useRef(680); - svgWRef.current = svgW; + useEffect(() => { + svgWRef.current = svgW; + }, [svgW]); const INNER_W = svgW - MARGIN.left - MARGIN.right; const [svgH, setSvgH] = useState(460); const svgHRef = useRef(460); - svgHRef.current = svgH; + useEffect(() => { + svgHRef.current = svgH; + }, [svgH]); const INNER_H = svgH - MARGIN.top - MARGIN.bottom; useEffect(() => { const el = containerRef.current; if (!el) return; - const ro = new ResizeObserver(entries => { + const ro = new ResizeObserver((entries) => { const { width, height } = entries[0].contentRect; const w = Math.round(width); - const h = Math.min(Math.round(height), 500, Math.round(window.innerHeight * 0.58)); - setSvgW(prev => prev === w ? prev : Math.max(300, w)); - setSvgH(prev => prev === h ? prev : Math.max(200, h)); + const h = Math.min( + Math.round(height), + 500, + Math.round(window.innerHeight * 0.58), + ); + setSvgW((prev) => (prev === w ? prev : Math.max(300, w))); + setSvgH((prev) => (prev === h ? prev : Math.max(200, h))); }); ro.observe(el); return () => ro.disconnect(); @@ -75,6 +102,7 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag stageEdit, stageMultiEdit, selectPoint, + togglePoint, selectRange, clearSelection, effectiveMhz, @@ -86,7 +114,7 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag const [busy, setBusy] = useState(false); const [actionError, setActionError] = useState(null); - const [dialog, setDialog] = useState<'apply' | 'reset' | null>(null); + const [dialog, setDialog] = useState<"apply" | "reset" | null>(null); async function handleApplyConfirm() { setDialog(null); @@ -115,7 +143,7 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag } const pts = useMemo( - () => curve.points.filter(p => p.domain === activeDomain), + () => curve.points.filter((p) => p.domain === activeDomain), [curve.points, activeDomain], ); const clampedPoints = useMemo(() => detectClampedPoints(pts), [pts]); @@ -123,19 +151,23 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag // Clear selection and pending when switching domains useEffect(() => { clearSelection(); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeDomain]); // ─── X-axis zoom / viewport ────────────────────────────────────────────── // Use a slightly larger padding to space things out better - const [xViewport, setXViewport] = useState<[number, number]>(() => voltExtent(pts, 40)); + const [xViewport, setXViewport] = useState<[number, number]>(() => + voltExtent(pts, 40), + ); - // Reset viewport when the set of points changes (curve refresh) - const ptsKey = pts.map((p) => p.index).join(','); - useEffect(() => { + // Reset viewport when the set of points changes (curve refresh) — + // adjust state during render instead of an effect (React-recommended pattern). + const ptsKey = pts.map((p) => p.index).join(","); + const [lastPtsKey, setLastPtsKey] = useState(ptsKey); + if (lastPtsKey !== ptsKey) { + setLastPtsKey(ptsKey); setXViewport(voltExtent(pts, 40)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ptsKey]); + } // Compute Y domain accounting for pending changes const allEffective = pts.map((p) => effectiveMhz(p)); @@ -148,14 +180,16 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag // Keep scales in a ref so event handler closures always see the latest const scalesRef = useRef({ xScale, yScale, xViewport, innerW: INNER_W }); - scalesRef.current = { xScale, yScale, xViewport, innerW: INNER_W }; + useEffect(() => { + scalesRef.current = { xScale, yScale, xViewport, innerW: INNER_W }; + }); const xTicks = xScale.ticks(8); const yTicks = yScale.ticks(6); // ─── Zoom factor (for toolbar slider) ──────────────────────────────────── const [fullExtentMin, fullExtentMax] = voltExtent(pts); - const fullWidth = (fullExtentMax - fullExtentMin) + 80; + const fullWidth = fullExtentMax - fullExtentMin + 80; const currentWidth = xViewport[1] - xViewport[0]; const zoomFactor = Math.max(1, Math.min(10, fullWidth / currentWidth)); @@ -165,8 +199,14 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag const center = (xViewport[0] + xViewport[1]) / 2; let newMin = center - newWidth / 2; let newMax = center + newWidth / 2; - if (newMin < fullExtentMin - pad) { newMin = fullExtentMin - pad; newMax = newMin + newWidth; } - if (newMax > fullExtentMax + pad) { newMax = fullExtentMax + pad; newMin = newMax - newWidth; } + if (newMin < fullExtentMin - pad) { + newMin = fullExtentMin - pad; + newMax = newMin + newWidth; + } + if (newMax > fullExtentMax + pad) { + newMax = fullExtentMax + pad; + newMin = newMax - newWidth; + } setXViewport([newMin, newMax]); } @@ -177,22 +217,33 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag }); const baseLine = visiblePts - .map((p) => `${xScale(p.volt_mv).toFixed(1)},${yScale(refBaseMhz(p)).toFixed(1)}`) - .join(' '); + .map( + (p) => + `${xScale(p.volt_mv).toFixed(1)},${yScale(refBaseMhz(p)).toFixed(1)}`, + ) + .join(" "); const effectiveLine = visiblePts - .map((p) => `${xScale(p.volt_mv).toFixed(1)},${yScale(p.freq_mhz).toFixed(1)}`) - .join(' '); + .map( + (p) => `${xScale(p.volt_mv).toFixed(1)},${yScale(p.freq_mhz).toFixed(1)}`, + ) + .join(" "); const hasPending = pendingDeltas.size > 0; const pendingLine = hasPending ? visiblePts - .map((p) => `${xScale(p.volt_mv).toFixed(1)},${yScale(effectiveMhz(p)).toFixed(1)}`) - .join(' ') + .map( + (p) => + `${xScale(p.volt_mv).toFixed(1)},${yScale(effectiveMhz(p)).toFixed(1)}`, + ) + .join(" ") : null; // ─── Coordinate helpers ────────────────────────────────────────────────── - function svgToContainer(svgX: number, svgY: number): { x: number; y: number } | null { + function svgToContainer( + svgX: number, + svgY: number, + ): { x: number; y: number } | null { const svg = svgRef.current; const container = containerRef.current; if (!svg || !container) return null; @@ -206,7 +257,10 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag }; } - function clientToInner(clientX: number, clientY: number): { x: number; y: number } | null { + function clientToInner( + clientX: number, + clientY: number, + ): { x: number; y: number } | null { const svg = svgRef.current; if (!svg) return null; const rect = svg.getBoundingClientRect(); @@ -219,12 +273,12 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag // ─── SVG cursor ────────────────────────────────────────────────────────── function handleSvgMouseMove(e: React.MouseEvent) { if (isPanning.current) return; - const next = e.shiftKey ? 'crosshair' : 'default'; - setSvgCursor(c => c === next ? c : next); + const next = e.shiftKey ? "crosshair" : "default"; + setSvgCursor((c) => (c === next ? c : next)); } function handleSvgMouseLeave() { - if (!isPanning.current) setSvgCursor('default'); + if (!isPanning.current) setSvgCursor("default"); } // ─── Point hover ───────────────────────────────────────────────────────── @@ -252,7 +306,11 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag const isBoxSelecting = useRef(false); // ─── Pan refs ───────────────────────────────────────────────────────────── - const panState = useRef<{ startX: number; startY: number; startViewport: [number, number] } | null>(null); + const panState = useRef<{ + startX: number; + startY: number; + startViewport: [number, number]; + } | null>(null); const isPanning = useRef(false); // ─── Point mousedown → start drag ──────────────────────────────────────── @@ -274,9 +332,12 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag if (selectedPoints.has(p.index) && selectedPoints.size > 1) { // Feature: Dragging a point in a multi-selection moves the whole selection for (const index of selectedPoints) { - const pt = pts.find(x => x.index === index); + const pt = pts.find((x) => x.index === index); if (pt) { - initialDeltas.set(index, pendingDeltas.has(index) ? pendingDeltas.get(index)! : pt.delta_khz); + initialDeltas.set( + index, + pendingDeltas.has(index) ? pendingDeltas.get(index)! : pt.delta_khz, + ); } } } else { @@ -289,7 +350,7 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag pointIndex: p.index, startY: inner.y, startDeltaKhz: currentDelta, - pointInitialDeltas: initialDeltas + pointInitialDeltas: initialDeltas, }; dragMoved.current = false; } @@ -309,8 +370,12 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag } else { // Plain drag = pan isPanning.current = true; - setSvgCursor('grabbing'); - panState.current = { startX: inner.x, startY: inner.y, startViewport: [...scalesRef.current.xViewport] as [number, number] }; + setSvgCursor("grabbing"); + panState.current = { + startX: inner.x, + startY: inner.y, + startViewport: [...scalesRef.current.xViewport] as [number, number], + }; } } @@ -326,15 +391,27 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag const mouseVolt = sx.invert(inner.x); const [vpMin, vpMax] = vp; const currentWidth = vpMax - vpMin; - const newWidth = Math.max(20, Math.min(voltExtent(pts)[1] - voltExtent(pts)[0] + 40, currentWidth / zoomFactor)); + const newWidth = Math.max( + 20, + Math.min( + voltExtent(pts)[1] - voltExtent(pts)[0] + 40, + currentWidth / zoomFactor, + ), + ); const anchorFrac = (mouseVolt - vpMin) / currentWidth; let newMin = mouseVolt - anchorFrac * newWidth; let newMax = mouseVolt + (1 - anchorFrac) * newWidth; // Clamp to full extent const [fullMin, fullMax] = voltExtent(pts); const pad = 20; - if (newMin < fullMin - pad) { newMax += (fullMin - pad - newMin); newMin = fullMin - pad; } - if (newMax > fullMax + pad) { newMin -= (newMax - fullMax - pad); newMax = fullMax + pad; } + if (newMin < fullMin - pad) { + newMax += fullMin - pad - newMin; + newMin = fullMin - pad; + } + if (newMax > fullMax + pad) { + newMin -= newMax - fullMax - pad; + newMax = fullMax + pad; + } setXViewport([newMin, newMax]); } @@ -354,12 +431,18 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag const { yScale: ys } = scalesRef.current; const [yRangeBottom, yRangeTop] = ys.range() as [number, number]; const [yDomainBottom, yDomainTop] = ys.domain() as [number, number]; - const pxPerMhz = (yRangeBottom - yRangeTop) / (yDomainTop - yDomainBottom); + const pxPerMhz = + (yRangeBottom - yRangeTop) / (yDomainTop - yDomainBottom); const deltaMhz = -dyPx / pxPerMhz; // Calculate the absolute delta for the primary dragged point - const primaryNewDeltaKhz = Math.round(ds.startDeltaKhz + deltaMhz * 1000); - const primaryClamped = Math.max(-1000_000, Math.min(1000_000, primaryNewDeltaKhz)); + const primaryNewDeltaKhz = Math.round( + ds.startDeltaKhz + deltaMhz * 1000, + ); + const primaryClamped = Math.max( + -1000_000, + Math.min(1000_000, primaryNewDeltaKhz), + ); // Difference to apply to all other points in the selection const validDeltaDiff = primaryClamped - ds.startDeltaKhz; @@ -368,7 +451,10 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag const edits = new Map(); ds.pointInitialDeltas.forEach((initialDelta, index) => { const newDelta = initialDelta + validDeltaDiff; - const clampedNewDelta = Math.max(-1000_000, Math.min(1000_000, newDelta)); + const clampedNewDelta = Math.max( + -1000_000, + Math.min(1000_000, newDelta), + ); edits.set(index, clampedNewDelta); }); stageMultiEdit(edits); @@ -381,8 +467,15 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag if (point) { const cx = scalesRef.current.xScale(point.volt_mv); const deltaChange = primaryClamped - point.delta_khz; - const cy = scalesRef.current.yScale(point.freq_mhz + deltaChange / 1000); - setDragInfo({ pointIndex: ds.pointIndex, currentDeltaKhz: primaryClamped, cx, cy }); + const cy = scalesRef.current.yScale( + point.freq_mhz + deltaChange / 1000, + ); + setDragInfo({ + pointIndex: ds.pointIndex, + currentDeltaKhz: primaryClamped, + cx, + cy, + }); } return; } @@ -393,7 +486,9 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag if (!inner) return; const ps = panState.current; // How many volts does 1 SVG-inner-px correspond to? - const voltPerPx = (ps.startViewport[1] - ps.startViewport[0]) / scalesRef.current.innerW; + const voltPerPx = + (ps.startViewport[1] - ps.startViewport[0]) / + scalesRef.current.innerW; const dxVolt = (inner.x - ps.startX) * voltPerPx; const [fullMin, fullMax] = voltExtent(pts); const pad = 20; @@ -401,8 +496,14 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag let newMin = ps.startViewport[0] - dxVolt; let newMax = ps.startViewport[1] - dxVolt; // Clamp so we don't pan completely outside - if (newMin < fullMin - pad) { newMin = fullMin - pad; newMax = newMin + vpWidth; } - if (newMax > fullMax + pad) { newMax = fullMax + pad; newMin = newMax - vpWidth; } + if (newMin < fullMin - pad) { + newMin = fullMin - pad; + newMax = newMin + vpWidth; + } + if (newMax > fullMax + pad) { + newMax = fullMax + pad; + newMin = newMax - vpWidth; + } setXViewport([newMin, newMax]); return; } @@ -431,7 +532,7 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag // --- End pan --- if (isPanning.current) { isPanning.current = false; - setSvgCursor('default'); + setSvgCursor("default"); if (panState.current) { const inner = clientToInner(e.clientX, e.clientY); if (inner) { @@ -462,8 +563,9 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag const maxX = Math.max(finalRect.x0, finalRect.x1); const minY = Math.min(finalRect.y0, finalRect.y1); const maxY = Math.max(finalRect.y0, finalRect.y1); - const sizeTrivial = Math.abs(finalRect.x1 - finalRect.x0) < 4 - && Math.abs(finalRect.y1 - finalRect.y0) < 4; + const sizeTrivial = + Math.abs(finalRect.x1 - finalRect.x0) < 4 && + Math.abs(finalRect.y1 - finalRect.y0) < 4; if (!sizeTrivial) { const { xScale: sx, yScale: ys } = scalesRef.current; @@ -484,32 +586,46 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag } } - window.addEventListener('mousemove', onMove); - window.addEventListener('mouseup', onUp); + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); return () => { - window.removeEventListener('mousemove', onMove); - window.removeEventListener('mouseup', onUp); + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); }; // Only re-register when structural deps change; scales are accessed via scalesRef // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pts, pendingDeltas, stageEdit, selectRange, clearSelection, effectiveMhz]); + }, [ + pts, + pendingDeltas, + stageEdit, + selectRange, + clearSelection, + effectiveMhz, + ]); return (
{/* Header: title + domain toggle + action buttons */}
- V/F Curve + + V/F Curve + {/* Domain toggle */}
- {([{label: 'GPU', value: 'gpu'}, {label: 'Memory', value: 'memory'}] as const).map((d) => ( + {( + [ + { label: "GPU", value: "gpu" }, + { label: "Memory", value: "memory" }, + ] as const + ).map((d) => ( +
)} {/* Confirm dialogs */} - {dialog === 'apply' && ( + {dialog === "apply" && ( setDialog(null)} /> )} - {dialog === 'reset' && ( + {dialog === "reset" && ( setXViewport(voltExtent(pts))} - isZoomed={Math.abs((xViewport[1] - xViewport[0]) - (voltExtent(pts)[1] - voltExtent(pts)[0])) > 5} + isZoomed={ + Math.abs( + xViewport[1] - + xViewport[0] - + (voltExtent(pts)[1] - voltExtent(pts)[0]), + ) > 5 + } readOnly={readOnly} zoomFactor={zoomFactor} onZoomChange={handleZoomChange} @@ -610,36 +738,39 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag
{/* Need tabIndex=0 to capture keyboard events */} -
{ if (inlineInput) { - if (e.key === 'Escape') setInlineInput(null); + if (e.key === "Escape") setInlineInput(null); return; // Let the input handle it } - if (e.key === 'a' && (e.ctrlKey || e.metaKey)) { + if (e.key === "a" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); - selectRange(pts.map(p => p.index)); + selectRange(pts.map((p) => p.index)); return; } - if (e.key === 'Escape') { + if (e.key === "Escape") { e.preventDefault(); clearSelection(); return; } - if (e.key === 'Tab') { + if (e.key === "Tab") { e.preventDefault(); if (pts.length === 0) return; const currentSelected = Array.from(selectedPoints); if (currentSelected.length === 0) { selectPoint(pts[0].index); } else { - const lastSelected = e.shiftKey ? Math.min(...currentSelected) : Math.max(...currentSelected); - const idx = pts.findIndex(p => p.index === lastSelected); + const lastSelected = e.shiftKey + ? Math.min(...currentSelected) + : Math.max(...currentSelected); + const idx = pts.findIndex((p) => p.index === lastSelected); if (idx >= 0) { let nextIdx = e.shiftKey ? idx - 1 : idx + 1; if (nextIdx < 0) nextIdx = pts.length - 1; @@ -650,19 +781,24 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag return; } - if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + if (e.key === "ArrowUp" || e.key === "ArrowDown") { e.preventDefault(); if (selectedPoints.size === 0) return; - const changeMhz = e.key === 'ArrowUp' ? 1 : -1; - const multiplier = (e.ctrlKey || e.metaKey) ? 10 : 1; + const changeMhz = e.key === "ArrowUp" ? 1 : -1; + const multiplier = e.ctrlKey || e.metaKey ? 10 : 1; const changeKhz = changeMhz * multiplier * 1000; const edits = new Map(); for (const idx of selectedPoints) { - const p = pts.find(pt => pt.index === idx); + const p = pts.find((pt) => pt.index === idx); if (p) { - const current = pendingDeltas.has(idx) ? pendingDeltas.get(idx)! : p.delta_khz; - const clamped = Math.max(-500_000, Math.min(500_000, current + changeKhz)); + const current = pendingDeltas.has(idx) + ? pendingDeltas.get(idx)! + : p.delta_khz; + const clamped = Math.max( + -500_000, + Math.min(500_000, current + changeKhz), + ); edits.set(idx, clamped); } } @@ -670,13 +806,16 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag return; } - if (e.key === 'Enter') { + if (e.key === "Enter") { e.preventDefault(); if (selectedPoints.size === 1) { const idx = Array.from(selectedPoints)[0]; - const p = pts.find(pt => pt.index === idx); + const p = pts.find((pt) => pt.index === idx); if (p) { - const initVal = (pendingDeltas.has(idx) ? pendingDeltas.get(idx)! : p.delta_khz) / 1000; + const initVal = + (pendingDeltas.has(idx) + ? pendingDeltas.get(idx)! + : p.delta_khz) / 1000; setInlineInput({ pointIndex: idx, value: initVal.toFixed(1) }); // We'll focus the input in an effect } @@ -691,7 +830,11 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag width="100%" height={svgH} className="block" - style={{ fontFamily: 'monospace', cursor: svgCursor, userSelect: 'none' }} + style={{ + fontFamily: "monospace", + cursor: svgCursor, + userSelect: "none", + }} onMouseDown={handleSvgMouseDown} onMouseMove={handleSvgMouseMove} onMouseLeave={handleSvgMouseLeave} @@ -706,17 +849,35 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag {/* Grid */} {xTicks.map((t) => ( - + ))} {yTicks.map((t) => ( - + ))} {/* X axis */} {xTicks.map((t) => ( - {t.toFixed(0)} + + {t.toFixed(0)} + ))} @@ -724,23 +885,69 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag {yTicks.map((t) => ( - {t.toFixed(0)} + + {t.toFixed(0)} + ))} {/* Axis labels */} - Voltage (mV) - Frequency (MHz) + + Voltage (mV) + + + Frequency (MHz) + {/* Border */} - + {/* Base curve */} - + {/* Confirmed effective curve */} - + {/* Pending effective curve */} {pendingLine && ( @@ -756,23 +963,43 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag )} {/* Current voltage / clock crosshairs — GPU domain only */} - {!readOnly && currentVoltageMv != null && xScale(currentVoltageMv) >= 0 && xScale(currentVoltageMv) <= INNER_W && ( - - )} - {!readOnly && currentClockMhz != null && yScale(currentClockMhz) >= 0 && yScale(currentClockMhz) <= INNER_H && ( - - )} - {!readOnly && currentVoltageMv != null && currentClockMhz != null && - xScale(currentVoltageMv) >= 0 && xScale(currentVoltageMv) <= INNER_W && - yScale(currentClockMhz) >= 0 && yScale(currentClockMhz) <= INNER_H && ( + {!readOnly && + currentVoltageMv != null && + xScale(currentVoltageMv) >= 0 && + xScale(currentVoltageMv) <= INNER_W && ( + + )} + {!readOnly && + currentClockMhz != null && + yScale(currentClockMhz) >= 0 && + yScale(currentClockMhz) <= INNER_H && ( + + )} + {!readOnly && + currentVoltageMv != null && + currentClockMhz != null && + xScale(currentVoltageMv) >= 0 && + xScale(currentVoltageMv) <= INNER_W && + yScale(currentClockMhz) >= 0 && + yScale(currentClockMhz) <= INNER_H && ( { - if (p.volt_mv < xViewport[0] - 5 || p.volt_mv > xViewport[1] + 5) return null; + if ( + p.volt_mv < xViewport[0] - 5 || + p.volt_mv > xViewport[1] + 5 + ) + return null; const cx = xScale(p.volt_mv); /** Confirmed position (hardware state — VFP effective frequency) */ @@ -803,34 +1034,69 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag const isHovered = hoveredPoint?.index === p.index; const isSelected = selectedPoints.has(p.index); const isDragging = dragInfo?.pointIndex === p.index; - const isAnchor = isSelected && selectedPoints.size >= 2 && p.index === anchorPoint; + const isAnchor = + isSelected && + selectedPoints.size >= 2 && + p.index === anchorPoint; - let fill = readOnly ? '#6366f1' : '#34d399'; - if (!readOnly && hasPendingEdit && !isSelected) fill = '#22d3ee'; - if (!readOnly && isSelected) fill = '#22d3ee'; + let fill = readOnly ? "#6366f1" : "#34d399"; + if (!readOnly && hasPendingEdit && !isSelected) + fill = "#22d3ee"; + if (!readOnly && isSelected) fill = "#22d3ee"; // Tweaked radius for less bloated flat regions - const r = isDragging ? 5.5 : isHovered || isSelected || hasPendingEdit ? 4.5 : 2.5; + const r = isDragging + ? 5.5 + : isHovered || isSelected || hasPendingEdit + ? 4.5 + : 2.5; return ( {/* Anchor ring — amber outer halo marking the flatten reference point */} {isAnchor && ( - + )} {/* Dim ring at confirmed position (only visible when there's a pending edit) */} - {!readOnly && ghostCy !== null && Math.abs(ghostCy - mainCy) > 0.5 && ( - - )} + {!readOnly && + ghostCy !== null && + Math.abs(ghostCy - mainCy) > 0.5 && ( + + )} {/* Drop-line from confirmed → pending while dragging */} - {!readOnly && isDragging && ghostCy !== null && Math.abs(ghostCy - mainCy) > 1 && ( - - )} + {!readOnly && + isDragging && + ghostCy !== null && + Math.abs(ghostCy - mainCy) > 1 && ( + + )} {/* Main circle */} handleMouseEnter(p)} onMouseLeave={handleMouseLeave} - onMouseDown={readOnly ? undefined : (e) => handlePointMouseDown(e, p)} - onClick={readOnly ? undefined : (e) => { - if (dragMoved.current) return; - selectPoint(p.index, e.shiftKey); - }} + onMouseDown={ + readOnly ? undefined : (e) => handlePointMouseDown(e, p) + } + onClick={ + readOnly + ? undefined + : (e) => { + if (dragMoved.current) return; + if (e.shiftKey) { + togglePoint(p.index); + } else { + selectPoint(p.index); + } + } + } /> ); @@ -862,8 +1145,12 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag y={Math.min(boxRect.y0, boxRect.y1)} width={Math.abs(boxRect.x1 - boxRect.x0)} height={Math.abs(boxRect.y1 - boxRect.y0)} - fill="#22d3ee" fillOpacity={0.08} - stroke="#22d3ee" strokeWidth={1} strokeDasharray="4 2" strokeOpacity={0.6} + fill="#22d3ee" + fillOpacity={0.08} + stroke="#22d3ee" + strokeWidth={1} + strokeDasharray="4 2" + strokeOpacity={0.6} pointerEvents="none" /> )} @@ -871,60 +1158,73 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag {/* Inline Input Overlay */} - {inlineInput && (() => { - const pt = pts.find(p => p.index === inlineInput.pointIndex); - if (!pt) return null; - const cx = scalesRef.current.xScale(pt.volt_mv); - const currentDelta = pendingDeltas.has(pt.index) ? pendingDeltas.get(pt.index)! : pt.delta_khz; - const deltaChange = currentDelta - pt.delta_khz; - const cy = scalesRef.current.yScale(pt.freq_mhz + deltaChange / 1000); - const pos = svgToContainer(cx, cy); - if (!pos) return null; - return ( -
- setInlineInput({ ...inlineInput, value: e.target.value })} - onBlur={() => setInlineInput(null)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - const mhz = parseFloat(inlineInput.value); - if (!isNaN(mhz) && isFinite(mhz)) { - const clamped = Math.max(-500_000, Math.min(500_000, Math.round(mhz * 1000))); - stageEdit(pt.index, clamped); - } - setInlineInput(null); - // Refocus the container - containerRef.current?.focus(); - } - if (e.key === 'Escape') { - setInlineInput(null); - containerRef.current?.focus(); - } + {/* Overlay position is measured from live DOM geometry + current scales + on every render — refs are required here and always fresh. */} + {/* eslint-disable-next-line react-hooks/refs */} + {inlineInput && + (() => { + const pt = pts.find((p) => p.index === inlineInput.pointIndex); + if (!pt) return null; + const cx = xScale(pt.volt_mv); + const currentDelta = pendingDeltas.has(pt.index) + ? pendingDeltas.get(pt.index)! + : pt.delta_khz; + const deltaChange = currentDelta - pt.delta_khz; + const cy = yScale(pt.freq_mhz + deltaChange / 1000); + const pos = svgToContainer(cx, cy); + if (!pos) return null; + return ( +
-
- ); - })()} + > + + setInlineInput({ ...inlineInput, value: e.target.value }) + } + onBlur={() => setInlineInput(null)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + const mhz = parseFloat(inlineInput.value); + if (!isNaN(mhz) && isFinite(mhz)) { + const clamped = Math.max( + -500_000, + Math.min(500_000, Math.round(mhz * 1000)), + ); + stageEdit(pt.index, clamped); + } + setInlineInput(null); + // Refocus the container + containerRef.current?.focus(); + } + if (e.key === "Escape") { + setInlineInput(null); + containerRef.current?.focus(); + } + }} + className="w-20 bg-zinc-800 text-cyan-300 rounded px-1.5 py-0.5 border-2 border-cyan-500 shadow-lg outline-none text-xs font-mono text-center" + /> +
+ ); + })()} {/* Hover / Active tooltip */} {(() => { if (dragInfo) return null; let tooltipPt = hoveredPoint; if (!tooltipPt && selectedPoints.size === 1) { - tooltipPt = pts.find(p => p.index === Array.from(selectedPoints)[0]) || null; + tooltipPt = + pts.find((p) => p.index === Array.from(selectedPoints)[0]) || + null; } if (!tooltipPt) return null; @@ -938,37 +1238,33 @@ export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltag })()} {/* Drag tooltip — shown while actively dragging */} - {dragInfo && (() => { - const point = pts.find((p) => p.index === dragInfo.pointIndex); - if (!point) return null; - const deltaMhz = dragInfo.currentDeltaKhz / 1000; - const deltaChange = dragInfo.currentDeltaKhz - point.delta_khz; - const effMhz = point.freq_mhz + deltaChange / 1000; - return ( -
-
- ↕ Point {point.index} - {point.volt_mv.toFixed(0)} mV + {dragInfo && + (() => { + const point = pts.find((p) => p.index === dragInfo.pointIndex); + if (!point) return null; + const deltaMhz = dragInfo.currentDeltaKhz / 1000; + const deltaChange = dragInfo.currentDeltaKhz - point.delta_khz; + const effMhz = point.freq_mhz + deltaChange / 1000; + return ( +
+
+ ↕ Point {point.index} + + {point.volt_mv.toFixed(0)} mV + +
+
0 ? "text-cyan-400" : deltaMhz < 0 ? "text-orange-400" : "text-zinc-400"}`} + > + Δ {deltaMhz > 0 ? "+" : ""} + {deltaMhz.toFixed(1)} MHz +
+
+ → {effMhz.toFixed(0)} MHz +
-
0 ? 'text-cyan-400' : deltaMhz < 0 ? 'text-orange-400' : 'text-zinc-400'}`}> - Δ {deltaMhz > 0 ? '+' : ''}{deltaMhz.toFixed(1)} MHz -
-
- → {effMhz.toFixed(0)} MHz -
-
- ); - })()} + ); + })()}
); diff --git a/frontend/src/components/CurveEditor/CurveToolbar.tsx b/frontend/src/components/CurveEditor/CurveToolbar.tsx index d169ffb..63d339c 100644 --- a/frontend/src/components/CurveEditor/CurveToolbar.tsx +++ b/frontend/src/components/CurveEditor/CurveToolbar.tsx @@ -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 (
{/* Zoom control */} -
+
onZoomChange(Number(e.target.value))} className="w-20 h-1 cursor-pointer accent-cyan-400" /> - + {zoomFactor.toFixed(1)}× {isZoomed && ( @@ -75,7 +101,9 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF {/* Global offset slider — GPU only */} {!readOnly && uniformDeltaMhz !== null && (
- Global Offset + + Global Offset + handleOffsetChange(Number(e.target.value))} className="w-32 accent-cyan-400" - title={`${offsetMhz > 0 ? '+' : ''}${offsetMhz} MHz`} + title={`${offsetMhz > 0 ? "+" : ""}${offsetMhz} MHz`} /> 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
)} {/* 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 ( - - ); - })()} + {!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 ( + + ); + })()} {/* Legend — right-aligned */}
- effective - pending - current + + {" "} + effective + + + {" "} + pending + + + {" "} + current +
); diff --git a/frontend/src/components/CurveEditor/CurveTooltip.tsx b/frontend/src/components/CurveEditor/CurveTooltip.tsx index 6d4e752..e46ec58 100644 --- a/frontend/src/components/CurveEditor/CurveTooltip.tsx +++ b/frontend/src/components/CurveEditor/CurveTooltip.tsx @@ -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 (
Point {point.index}
diff --git a/frontend/src/components/Dashboard/Dashboard.tsx b/frontend/src/components/Dashboard/Dashboard.tsx index a2cb73a..bf62fc8 100644 --- a/frontend/src/components/Dashboard/Dashboard.tsx +++ b/frontend/src/components/Dashboard/Dashboard.tsx @@ -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; diff --git a/frontend/src/components/Fans/FanCurveEditor.tsx b/frontend/src/components/Fans/FanCurveEditor.tsx index 2b99143..4018682 100644 --- a/frontend/src/components/Fans/FanCurveEditor.tsx +++ b/frontend/src/components/Fans/FanCurveEditor.tsx @@ -1,10 +1,10 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { Check, X, RotateCcw, Plus } from "lucide-react"; -import { api } from "../../api/client"; -import { useCurveStore } from "../../store/curveStore"; -import type { FanPoint, FanState } from "../../types"; +import { 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); diff --git a/frontend/src/components/Limits/PerformancePanel.tsx b/frontend/src/components/Limits/PerformancePanel.tsx index 61c3b7d..0628f22 100644 --- a/frontend/src/components/Limits/PerformancePanel.tsx +++ b/frontend/src/components/Limits/PerformancePanel.tsx @@ -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; +type Pending = Pick; 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 ( <>
- {/* ── Header ─────────────────────────────────────────────────────── */}
- Performance + + Performance + {hasPending && ( @@ -117,7 +123,10 @@ export function PerformancePanel() { Apply +
)}
- {/* ── Board Power Limit ─────────────────────────────────────────── */}
- Board Power Limit + + Board Power Limit +
{ + 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() {
- {pwrMin} + + {pwrMin} + 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" /> - {pwrMax} + + {pwrMax} +
{/* ── Memory Clock Offset ───────────────────────────────────────── */}
- Memory Clock Offset + + Memory Clock Offset +
{ + 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() {
- {memMin} + + {memMin} + 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" /> - +{memMax} + + +{memMax} +
-
diff --git a/frontend/src/components/Monitor/FanMonitor.tsx b/frontend/src/components/Monitor/FanMonitor.tsx index da3d8bc..28c7462 100644 --- a/frontend/src/components/Monitor/FanMonitor.tsx +++ b/frontend/src/components/Monitor/FanMonitor.tsx @@ -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( 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 (
- Live Monitor + + Live Monitor + {fanCurveActive && ( Curve Active @@ -73,7 +85,7 @@ export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props {fanCurveActive && (
diff --git a/frontend/src/components/Monitor/LiveMonitor.tsx b/frontend/src/components/Monitor/LiveMonitor.tsx index 7724a1f..ce02354 100644 --- a/frontend/src/components/Monitor/LiveMonitor.tsx +++ b/frontend/src/components/Monitor/LiveMonitor.tsx @@ -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) {
- Live Monitor + + Live Monitor +
- - - - -
+ + + +
+ +
-
); } diff --git a/frontend/src/components/Monitor/PerformanceMonitor.tsx b/frontend/src/components/Monitor/PerformanceMonitor.tsx index 37319ea..2c53104 100644 --- a/frontend/src/components/Monitor/PerformanceMonitor.tsx +++ b/frontend/src/components/Monitor/PerformanceMonitor.tsx @@ -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( 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 (
- Live Monitor + + Live Monitor +
diff --git a/frontend/src/components/Monitor/StatusBar.tsx b/frontend/src/components/Monitor/StatusBar.tsx index f753c45..e603d2f 100644 --- a/frontend/src/components/Monitor/StatusBar.tsx +++ b/frontend/src/components/Monitor/StatusBar.tsx @@ -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 { diff --git a/frontend/src/components/PointTable/PointRow.tsx b/frontend/src/components/PointTable/PointRow.tsx index 9c2179d..03ab6f7 100644 --- a/frontend/src/components/PointTable/PointRow.tsx +++ b/frontend/src/components/PointTable/PointRow.tsx @@ -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(null); const trRef = useRef(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 { if (editing) return; onMouseDown?.(e); @@ -80,7 +103,13 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta {fmt.mv(point.volt_mv, 0)} {/* Offset — click to edit inline */} - { e.stopPropagation(); startEdit(); }}> + { + e.stopPropagation(); + startEdit(); + }} + > {editing ? ( 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" /> ) : ( {hasPending && ✎} - {displayDeltaKhz > 0 ? '+' : ''}{displayDeltaMhz.toFixed(1)} MHz + {displayDeltaKhz > 0 ? "+" : ""} + {displayDeltaMhz.toFixed(1)} MHz )} {/* Eff. Freq */} - + {fmt.mhz(displayEffMhz, 0)} {isClamped && !hasPending && ( ⇡ + > + ⇡ + )} diff --git a/frontend/src/components/PointTable/PointTable.tsx b/frontend/src/components/PointTable/PointTable.tsx index 50b23d2..56935c4 100644 --- a/frontend/src/components/PointTable/PointTable.tsx +++ b/frontend/src/components/PointTable/PointTable.tsx @@ -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(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 (
- Points + + Points + {!readOnly && pendingDeltas.size > 0 && ( {pendingDeltas.size} staged @@ -35,13 +48,17 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) { {readOnly && ( read-only )} - {points.length} points + + {points.length} points + {!readOnly && selectedPoints.size === 1 && (
diff --git a/frontend/src/hooks/useCurve.ts b/frontend/src/hooks/useCurve.ts index 4cde202..4a64f5c 100644 --- a/frontend/src/hooks/useCurve.ts +++ b/frontend/src/hooks/useCurve.ts @@ -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 | null>(null); useEffect(() => { @@ -15,7 +17,7 @@ export function useCurve() { // Subscribe to /ws/curve for push updates after writes wsRef.current = createWsConnection( - '/ws/curve', + "/ws/curve", (data) => setCurve(data), setWsStatus, selectedGpuIndex, diff --git a/frontend/src/hooks/useDashboard.ts b/frontend/src/hooks/useDashboard.ts index f12322f..14e7718 100644 --- a/frontend/src/hooks/useDashboard.ts +++ b/frontend/src/hooks/useDashboard.ts @@ -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; }; diff --git a/frontend/src/hooks/useGpu.ts b/frontend/src/hooks/useGpu.ts index 665d3fb..8b2b211 100644 --- a/frontend/src/hooks/useGpu.ts +++ b/frontend/src/hooks/useGpu.ts @@ -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); diff --git a/frontend/src/hooks/useMonitor.ts b/frontend/src/hooks/useMonitor.ts index e8ada76..4c434f4 100644 --- a/frontend/src/hooks/useMonitor.ts +++ b/frontend/src/hooks/useMonitor.ts @@ -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 | null>(null); useEffect(() => { wsRef.current = createWsConnection( - '/ws/monitor', + "/ws/monitor", pushMonitor, setWsStatus, selectedGpuIndex, diff --git a/frontend/src/store/curveStore.ts b/frontend/src/store/curveStore.ts index b20a6ab..a0cd284 100644 --- a/frontend/src/store/curveStore.ts +++ b/frontend/src/store/curveStore.ts @@ -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; // 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()((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()((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()((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()((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 = {}; - pendingDeltas.forEach((v, k) => { deltas[k] = v; }); + const deltas: Record = 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()((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()((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(); 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); } diff --git a/frontend/src/utils/curveHelpers.ts b/frontend/src/utils/curveHelpers.ts index 9aa9aaa..817992d 100644 --- a/frontend/src/utils/curveHelpers.ts +++ b/frontend/src/utils/curveHelpers.ts @@ -1,4 +1,4 @@ -import type { VFPoint } from '../types'; +import type { VFPoint } from '../types.js'; /** * Approximate reference frequency (MHz) for a point: effective − delta. diff --git a/nvcurve/cli.py b/nvcurve/cli.py index 9bf3b00..09c4090 100644 --- a/nvcurve/cli.py +++ b/nvcurve/cli.py @@ -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( diff --git a/nvcurve/daemon.py b/nvcurve/daemon.py index 52f0439..bd81388 100644 --- a/nvcurve/daemon.py +++ b/nvcurve/daemon.py @@ -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) diff --git a/nvcurve/hal/fans.py b/nvcurve/hal/fans.py index c32793f..75ab803 100644 --- a/nvcurve/hal/fans.py +++ b/nvcurve/hal/fans.py @@ -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). diff --git a/nvcurve/hal/gpu.py b/nvcurve/hal/gpu.py index d206ab7..bfce12c 100644 --- a/nvcurve/hal/gpu.py +++ b/nvcurve/hal/gpu.py @@ -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 diff --git a/nvcurve/hal/limits.py b/nvcurve/hal/limits.py index 6971823..f681d95 100644 --- a/nvcurve/hal/limits.py +++ b/nvcurve/hal/limits.py @@ -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 - - diff --git a/nvcurve/hal/snapshot.py b/nvcurve/hal/snapshot.py index e71c44f..a58711c 100644 --- a/nvcurve/hal/snapshot.py +++ b/nvcurve/hal/snapshot.py @@ -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 diff --git a/nvcurve/profiles/apply.py b/nvcurve/profiles/apply.py index a3068c6..d2e324e 100644 --- a/nvcurve/profiles/apply.py +++ b/nvcurve/profiles/apply.py @@ -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() diff --git a/nvcurve/profiles/native.py b/nvcurve/profiles/native.py index 4f11c31..90d3baa 100644 --- a/nvcurve/profiles/native.py +++ b/nvcurve/profiles/native.py @@ -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 diff --git a/nvcurve/server.py b/nvcurve/server.py index 6321c26..2f158d3 100644 --- a/nvcurve/server.py +++ b/nvcurve/server.py @@ -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():