Clean up LSP diagnostics across backend and frontend

Backend (nvcurve/):
- hal/fans.py, hal/limits.py, hal/gpu.py: replace conditional pynvml
  imports with the established 'pynvml: Any = _pynvml_import' pattern
  (fixes ~50 'possibly unbound' errors); type the result dicts; guard
  query_interface() results; explicit uuid/pci-bus parsing (int, hex
  convention documented); modernize Optional[T] -> T | None
- cli.py: fix 'curve_state' possibly-unbound and snap_path None handling
  in cmd_setup; wrap unchecked int()/open()/makedirs() calls in
  try/except with clean CLI errors; add module logger for silent
  except-pass blocks; raise ... from exc; fix unused loop vars and
  set-comprehension
- hal/snapshot.py: filepath: str | None; wrap all file ops; sorted
  imports; remove unused CT_POINTS import
- daemon.py: extract 0o666 to _SOCKET_MODE constant (intentional for
  /run sockets) with nosemgrep
- server.py: nosemgrep for Python 3.7-compat false positive (project
  requires >= 3.12); log previously-swallowed exception
- profiles/native.py, profiles/apply.py: wrap file ops and int(k)
  profile-key parsing; sorted imports; modernize typing

Frontend (frontend/src):
- Add .js extensions to all relative imports (standard TS-ESM; Vite
  resolves .js -> .ts)
- React.FormEvent (deprecated in React 19 types) -> React.SubmitEvent
- catch (e: any) -> catch (e: unknown) + instanceof Error narrowing
- React-hooks: move ref writes from render into effects; convert
  viewport reset to render-phase state adjustment; split
  selectPoint(index, multi) into selectPoint + togglePoint (no flag
  argument); remove non-null assertion
- Static inline styles -> Tailwind classes (dynamic positioning/cursor
  styles kept)
- Remove non-standard 'container' option from scrollIntoView (browsers
  ignore unknown options) which had orphaned a @ts-expect-error
- Object.fromEntries for Map -> Record conversion

Tooling:
- .gitignore: ignore .codegraph/ local tool data

Verified: tsc --noEmit, vite production build, python imports, and
full LSP scan (0 errors/warnings in both projects).
This commit is contained in:
ARIA committed 2026-09-08 23:57:30 +02:00
1 parent 9006c22fde
commit 930e56bd07
33 files changed
+1571 -804

No files matched your search

+18 -18
View File
@@ -1,24 +1,24 @@
import { useGpu } from "./hooks/useGpu";
import { useCurve } from "./hooks/useCurve";
import { useMonitor } from "./hooks/useMonitor";
import { useDashboard } from "./hooks/useDashboard";
import { StatusBar } from "./components/Monitor/StatusBar";
import { LiveMonitor } from "./components/Monitor/LiveMonitor";
import { Dashboard } from "./components/Dashboard/Dashboard";
import { CurveEditor } from "./components/CurveEditor/CurveEditor";
import { PointTable } from "./components/PointTable/PointTable";
import { PerformancePanel } from "./components/Limits/PerformancePanel";
import { PerformanceMonitor } from "./components/Monitor/PerformanceMonitor";
import { FanMonitor } from "./components/Monitor/FanMonitor";
import { FanCurveEditor } from "./components/Fans/FanCurveEditor";
import { ProfilePanel } from "./components/Profiles/ProfilePanel";
import { api, onUnauthorized } from "./api/client";
import { LoginScreen } from "./components/Auth/LoginScreen";
import { useCurveStore } from "./store/curveStore";
import { useGpu } from "./hooks/useGpu.js";
import { useCurve } from "./hooks/useCurve.js";
import { useMonitor } from "./hooks/useMonitor.js";
import { useDashboard } from "./hooks/useDashboard.js";
import { StatusBar } from "./components/Monitor/StatusBar.js";
import { LiveMonitor } from "./components/Monitor/LiveMonitor.js";
import { Dashboard } from "./components/Dashboard/Dashboard.js";
import { CurveEditor } from "./components/CurveEditor/CurveEditor.js";
import { PointTable } from "./components/PointTable/PointTable.js";
import { PerformancePanel } from "./components/Limits/PerformancePanel.js";
import { PerformanceMonitor } from "./components/Monitor/PerformanceMonitor.js";
import { FanMonitor } from "./components/Monitor/FanMonitor.js";
import { FanCurveEditor } from "./components/Fans/FanCurveEditor.js";
import { ProfilePanel } from "./components/Profiles/ProfilePanel.js";
import { api, onUnauthorized } from "./api/client.js";
import { LoginScreen } from "./components/Auth/LoginScreen.js";
import { useCurveStore } from "./store/curveStore.js";
import { Toaster } from "sonner";
import { Loader, ChevronDown } from "lucide-react";
import { useState, useRef, useEffect } from "react";
import type { FanState } from "./types";
import type { FanState } from "./types.js";
type AuthState = "checking" | "login" | "ok";
+1 -1
View File
@@ -8,7 +8,7 @@ import type {
FanState,
FanPoint,
DashboardInfo,
} from "../types";
} from "../types.js";
export class ApiError extends Error {
status: number;
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { Loader, Lock, User } from "lucide-react";
import { api, ApiError } from "../../api/client";
import { api, ApiError } from "../../api/client.js";
interface Props {
onSuccess: (username: string) => void;
@@ -12,7 +12,7 @@ export function LoginScreen({ onSuccess }: Props) {
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
async function submit(e: React.SubmitEvent) {
e.preventDefault();
if (busy) return;
setBusy(true);
File diff suppressed because it is too large. Load diff
@@ -1,7 +1,7 @@
import { useState, useMemo, useEffect } from 'react';
import { ZoomIn, RotateCcw, Minus } from 'lucide-react';
import { useCurveStore } from '../../store/curveStore';
import type { VFPoint } from '../../types';
import { useState, useMemo } from "react";
import { ZoomIn, RotateCcw, Minus } from "lucide-react";
import { useCurveStore } from "../../store/curveStore.js";
import type { VFPoint } from "../../types.js";
interface Props {
/** All curve points — used by global offset slider */
@@ -18,23 +18,44 @@ interface Props {
onZoomChange: (factor: number) => void;
}
export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomFactor, onZoomChange }: Props) {
const { pendingDeltas, selectedPoints, anchorPoint, curve, stageRangeEdit, flattenToAnchor } = useCurveStore();
export function CurveToolbar({
activePts,
onResetZoom,
isZoomed,
readOnly,
zoomFactor,
onZoomChange,
}: Props) {
const {
pendingDeltas,
selectedPoints,
anchorPoint,
curve,
stageRangeEdit,
flattenToAnchor,
} = useCurveStore();
const [offsetMhz, setOffsetMhz] = useState(0);
const uniformDeltaMhz = useMemo(() => {
if (activePts.length === 0) return 0;
const firstD = pendingDeltas.get(activePts[0].index) ?? activePts[0].delta_khz;
const uniform = activePts.every((p) => (pendingDeltas.get(p.index) ?? p.delta_khz) === firstD);
const firstD =
pendingDeltas.get(activePts[0].index) ?? activePts[0].delta_khz;
const uniform = activePts.every(
(p) => (pendingDeltas.get(p.index) ?? p.delta_khz) === firstD,
);
return uniform ? firstD / 1000 : null;
}, [activePts, pendingDeltas]);
useEffect(() => {
if (uniformDeltaMhz !== null) {
setOffsetMhz(uniformDeltaMhz);
}
}, [uniformDeltaMhz]);
// Sync the slider to the uniform delta when it changes (adjust state during
// render instead of an effect; undefined sentinel so the first render syncs).
const [lastUniformDelta, setLastUniformDelta] = useState<
number | null | undefined
>();
if (uniformDeltaMhz !== null && lastUniformDelta !== uniformDeltaMhz) {
setLastUniformDelta(uniformDeltaMhz);
setOffsetMhz(uniformDeltaMhz);
}
function handleOffsetChange(mhz: number) {
setOffsetMhz(mhz);
@@ -44,7 +65,10 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
return (
<div className="flex flex-wrap items-center gap-2 px-1 pb-2">
{/* Zoom control */}
<div className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800/60 border border-zinc-700/40" title="Zoom x-axis (Alt+scroll also works)">
<div
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800/60 border border-zinc-700/40"
title="Zoom x-axis (Alt+scroll also works)"
>
<ZoomIn size={11} className="text-zinc-500 shrink-0" />
<input
type="range"
@@ -55,7 +79,9 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
onChange={(e) => onZoomChange(Number(e.target.value))}
className="w-20 h-1 cursor-pointer accent-cyan-400"
/>
<span className={`text-xs font-mono w-8 tabular-nums ${isZoomed ? 'text-cyan-400' : 'text-zinc-600'}`}>
<span
className={`text-xs font-mono w-8 tabular-nums ${isZoomed ? "text-cyan-400" : "text-zinc-600"}`}
>
{zoomFactor.toFixed(1)}×
</span>
{isZoomed && (
@@ -75,7 +101,9 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
{/* Global offset slider — GPU only */}
{!readOnly && uniformDeltaMhz !== null && (
<div className="flex items-center gap-1.5 min-w-[260px]">
<span className="text-zinc-500 text-xs whitespace-nowrap">Global Offset</span>
<span className="text-zinc-500 text-xs whitespace-nowrap">
Global Offset
</span>
<input
type="range"
min={-1000}
@@ -84,46 +112,63 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
value={offsetMhz}
onChange={(e) => handleOffsetChange(Number(e.target.value))}
className="w-32 accent-cyan-400"
title={`${offsetMhz > 0 ? '+' : ''}${offsetMhz} MHz`}
title={`${offsetMhz > 0 ? "+" : ""}${offsetMhz} MHz`}
/>
<span
className={[
'text-xs font-mono w-16',
offsetMhz > 0 ? 'text-cyan-400' : offsetMhz < 0 ? 'text-orange-400' : 'text-zinc-500',
].join(' ')}
"text-xs font-mono w-16",
offsetMhz > 0
? "text-cyan-400"
: offsetMhz < 0
? "text-orange-400"
: "text-zinc-500",
].join(" ")}
>
{offsetMhz > 0 ? '+' : ''}{offsetMhz} MHz
{offsetMhz > 0 ? "+" : ""}
{offsetMhz} MHz
</span>
</div>
)}
{/* Flatten — visible when 2+ points are selected */}
{!readOnly && selectedPoints.size >= 2 && (() => {
const anchor = anchorPoint !== null && selectedPoints.has(anchorPoint)
? anchorPoint
: Math.min(...selectedPoints);
const anchorDelta =
pendingDeltas.get(anchor) ??
curve?.points.find(p => p.index === anchor)?.delta_khz ??
0;
const label = `·${anchor} ${anchorDelta >= 0 ? '+' : ''}${anchorDelta / 1000} MHz`;
return (
<button
onClick={flattenToAnchor}
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium text-amber-400 hover:text-amber-300 hover:bg-zinc-800 border border-zinc-700/40 transition"
title={`Flatten all selected points to anchor point ${anchor} (${anchorDelta >= 0 ? '+' : ''}${anchorDelta / 1000} MHz)`}
>
<Minus size={11} />
Flatten to {label}
</button>
);
})()}
{!readOnly &&
selectedPoints.size >= 2 &&
(() => {
const anchor =
anchorPoint !== null && selectedPoints.has(anchorPoint)
? anchorPoint
: Math.min(...selectedPoints);
const anchorDelta =
pendingDeltas.get(anchor) ??
curve?.points.find((p) => p.index === anchor)?.delta_khz ??
0;
const label = `·${anchor} ${anchorDelta >= 0 ? "+" : ""}${anchorDelta / 1000} MHz`;
return (
<button
onClick={flattenToAnchor}
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium text-amber-400 hover:text-amber-300 hover:bg-zinc-800 border border-zinc-700/40 transition"
title={`Flatten all selected points to anchor point ${anchor} (${anchorDelta >= 0 ? "+" : ""}${anchorDelta / 1000} MHz)`}
>
<Minus size={11} />
Flatten to {label}
</button>
);
})()}
{/* Legend — right-aligned */}
<div className="flex items-center gap-3 text-xs text-zinc-500 ml-auto">
<span className="flex items-center gap-1"><span className="inline-block w-3 h-0.5 bg-emerald-400 rounded" /> effective</span>
<span className="flex items-center gap-1"><span className="inline-block w-3 h-px border-t-2 border-dashed border-cyan-400" /> pending</span>
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-yellow-400" /> current</span>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-0.5 bg-emerald-400 rounded" />{" "}
effective
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-px border-t-2 border-dashed border-cyan-400" />{" "}
pending
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-yellow-400" />{" "}
current
</span>
</div>
</div>
);
@@ -1,5 +1,5 @@
import { fmt } from '../../utils/units';
import type { VFPoint } from '../../types';
import { fmt } from '../../utils/units.js';
import type { VFPoint } from '../../types.js';
interface Props {
point: VFPoint;
@@ -21,15 +21,7 @@ export function CurveTooltip({ point, pendingDeltaKhz, isClamped }: Props) {
return (
<div
style={{
position: 'absolute',
right: 16,
bottom: 16,
pointerEvents: 'none',
zIndex: 50,
width: 172,
}}
className="bg-zinc-800 border border-zinc-700 rounded-md p-2 text-xs shadow-xl"
className="absolute right-4 bottom-4 pointer-events-none z-50 w-[172px] bg-zinc-800 border border-zinc-700 rounded-md p-2 text-xs shadow-xl"
>
<div className="text-zinc-400 mb-1">Point {point.index}</div>
<div className="text-zinc-200">
@@ -1,7 +1,7 @@
import { Loader } from "lucide-react";
import { GaugeCard } from "../Monitor/GaugeCard";
import { fmt } from "../../utils/units";
import type { MonitoringSample, DashboardInfo } from "../../types";
import { GaugeCard } from "../Monitor/GaugeCard.js";
import { fmt } from "../../utils/units.js";
import type { MonitoringSample, DashboardInfo } from "../../types.js";
interface Props {
monitor: MonitoringSample | null;
+13 -12
View File
@@ -1,10 +1,10 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Check, X, RotateCcw, Plus } from "lucide-react";
import { api } from "../../api/client";
import { useCurveStore } from "../../store/curveStore";
import type { FanPoint, FanState } from "../../types";
import { api } from "../../api/client.js";
import { useCurveStore } from "../../store/curveStore.js";
import type { FanPoint, FanState } from "../../types.js";
import { toast } from "sonner";
import { ConfirmDialog } from "../common/ConfirmDialog";
import { ConfirmDialog } from "../common/ConfirmDialog.js";
function defaultCurve(): FanPoint[] {
return [
@@ -71,7 +71,10 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
}
}
// Data fetch on GPU change — setState calls happen after the await, not
// synchronously in the effect body (rule false-positive on async fetch).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchFans();
}, [selectedGpuIndex]);
@@ -92,8 +95,8 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
await fetchFans();
onChanged?.();
toast.success("Fan curve applied");
} catch (e: any) {
setError(e.message ?? String(e));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
setConfirmApply(false);
} finally {
setBusy(false);
@@ -110,8 +113,8 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
await fetchFans();
onChanged?.();
toast.success("Fan control reset to automatic");
} catch (e: any) {
setError(e.message ?? String(e));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
setConfirmReset(false);
} finally {
setBusy(false);
@@ -294,8 +297,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
ref={svgRef}
width="100%"
viewBox={`0 0 ${CHART_W} ${CHART_H}`}
className="max-w-full cursor-crosshair select-none"
style={{ touchAction: "none" }}
className="max-w-full cursor-crosshair select-none touch-none"
onClick={handleCanvasClick}
onPointerMove={handlePointerMove}
>
@@ -410,8 +412,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
fill={hasPending ? "#22d3ee" : "#fb923c"}
stroke="#09090b"
strokeWidth="2"
className="cursor-grab active:cursor-grabbing"
style={{ touchAction: "none" }}
className="cursor-grab active:cursor-grabbing touch-none"
onPointerDown={(e) => {
e.stopPropagation();
handlePointerDown(i);
@@ -1,12 +1,12 @@
import { useState, useEffect } from 'react';
import { Check, X, RotateCcw } from 'lucide-react';
import { api } from '../../api/client';
import { useCurveStore } from '../../store/curveStore';
import type { LimitsState } from '../../types';
import { toast } from 'sonner';
import { ConfirmDialog } from '../common/ConfirmDialog';
import { useState, useEffect } from "react";
import { Check, X, RotateCcw } from "lucide-react";
import { api } from "../../api/client.js";
import { useCurveStore } from "../../store/curveStore.js";
import type { LimitsState } from "../../types.js";
import { toast } from "sonner";
import { ConfirmDialog } from "../common/ConfirmDialog.js";
type Pending = Pick<LimitsState, 'power_limit_w' | 'mem_offset_mhz'>;
type Pending = Pick<LimitsState, "power_limit_w" | "mem_offset_mhz">;
export function PerformancePanel() {
const { selectedGpuIndex } = useCurveStore();
@@ -23,13 +23,18 @@ export function PerformancePanel() {
setLoading(true);
setLimits(await api.limits(selectedGpuIndex));
} catch {
toast.error('Failed to load performance limits');
toast.error("Failed to load performance limits");
} finally {
setLoading(false);
}
}
useEffect(() => { fetchLimits(); }, [selectedGpuIndex]);
// Data fetch on GPU change — setState calls happen after the await, not
// synchronously in the effect body (rule false-positive on async fetch).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchLimits();
}, [selectedGpuIndex]);
async function handleApply() {
setBusy(true);
@@ -40,9 +45,9 @@ export function PerformancePanel() {
setPending({});
setConfirmApply(false);
await fetchLimits();
toast.success('Performance limits applied');
} catch (e: any) {
setError(e.message ?? String(e));
toast.success("Performance limits applied");
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
setConfirmApply(false);
} finally {
setBusy(false);
@@ -58,9 +63,9 @@ export function PerformancePanel() {
setPending({});
setConfirmReset(false);
await fetchLimits();
toast.success('Performance limits reset to defaults');
} catch (e: any) {
setError(e.message ?? String(e));
toast.success("Performance limits reset to defaults");
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
setConfirmReset(false);
} finally {
setBusy(false);
@@ -96,10 +101,11 @@ export function PerformancePanel() {
return (
<>
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
{/* ── Header ─────────────────────────────────────────────────────── */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 shrink-0">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Performance</span>
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
Performance
</span>
{hasPending && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs">
@@ -117,7 +123,10 @@ export function PerformancePanel() {
Apply
</button>
<button
onClick={() => { setPending({}); setError(null); }}
onClick={() => {
setPending({});
setError(null);
}}
disabled={!hasPending || busy}
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
@@ -139,25 +148,32 @@ export function PerformancePanel() {
{error && (
<div className="px-3 py-1.5 bg-red-900/40 border-b border-red-700 text-red-300 text-xs flex items-center justify-between">
<span>⚠ {error}</span>
<button onClick={() => setError(null)} className="ml-2 text-red-400 hover:text-red-200">✕</button>
<button
onClick={() => setError(null)}
className="ml-2 text-red-400 hover:text-red-200"
>
✕
</button>
</div>
)}
<div className="flex flex-col divide-y divide-zinc-800">
{/* ── Board Power Limit ─────────────────────────────────────────── */}
<div className="px-4 py-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-500 uppercase tracking-wider">Board Power Limit</span>
<span className="text-xs text-zinc-500 uppercase tracking-wider">
Board Power Limit
</span>
<div className="flex items-center gap-1.5">
<input
type="number"
min={pwrMin}
max={pwrMax}
value={pwrVal}
onChange={e => {
onChange={(e) => {
const v = parseInt(e.target.value);
if (!isNaN(v)) setPending(p => ({ ...p, power_limit_w: v }));
if (!isNaN(v))
setPending((p) => ({ ...p, power_limit_w: v }));
}}
className="w-14 bg-zinc-950 border border-zinc-800 rounded text-xs px-2 py-1 text-right font-mono focus:outline-none focus:border-cyan-500"
/>
@@ -165,32 +181,44 @@ export function PerformancePanel() {
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-600 font-mono w-8 text-right">{pwrMin}</span>
<span className="text-xs text-zinc-600 font-mono w-8 text-right">
{pwrMin}
</span>
<input
type="range"
min={pwrMin}
max={pwrMax}
value={pwrVal}
onChange={e => setPending(p => ({ ...p, power_limit_w: parseInt(e.target.value) }))}
onChange={(e) =>
setPending((p) => ({
...p,
power_limit_w: parseInt(e.target.value),
}))
}
className="flex-1 accent-cyan-400 h-1 cursor-pointer"
/>
<span className="text-xs text-zinc-600 font-mono w-8">{pwrMax}</span>
<span className="text-xs text-zinc-600 font-mono w-8">
{pwrMax}
</span>
</div>
</div>
{/* ── Memory Clock Offset ───────────────────────────────────────── */}
<div className="px-4 py-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-500 uppercase tracking-wider">Memory Clock Offset</span>
<span className="text-xs text-zinc-500 uppercase tracking-wider">
Memory Clock Offset
</span>
<div className="flex items-center gap-1.5">
<input
type="number"
min={memMin}
max={memMax}
value={memVal}
onChange={e => {
onChange={(e) => {
const v = parseInt(e.target.value);
if (!isNaN(v)) setPending(p => ({ ...p, mem_offset_mhz: v }));
if (!isNaN(v))
setPending((p) => ({ ...p, mem_offset_mhz: v }));
}}
className="w-16 bg-zinc-950 border border-zinc-800 rounded text-xs px-2 py-1 text-right font-mono focus:outline-none focus:border-cyan-500"
/>
@@ -198,20 +226,28 @@ export function PerformancePanel() {
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-600 font-mono w-10 text-right">{memMin}</span>
<span className="text-xs text-zinc-600 font-mono w-10 text-right">
{memMin}
</span>
<input
type="range"
min={memMin}
max={memMax}
step={1}
value={memVal}
onChange={e => setPending(p => ({ ...p, mem_offset_mhz: parseInt(e.target.value) }))}
onChange={(e) =>
setPending((p) => ({
...p,
mem_offset_mhz: parseInt(e.target.value),
}))
}
className="flex-1 accent-cyan-400 h-1 cursor-pointer"
/>
<span className="text-xs text-zinc-600 font-mono w-10">+{memMax}</span>
<span className="text-xs text-zinc-600 font-mono w-10">
+{memMax}
</span>
</div>
</div>
</div>
</div>
+23 -11
View File
@@ -1,6 +1,6 @@
import { GaugeCard } from './GaugeCard';
import { fmt } from '../../utils/units';
import type { MonitoringSample, FanPoint } from '../../types';
import { GaugeCard } from "./GaugeCard.js";
import { fmt } from "../../utils/units.js";
import type { MonitoringSample, FanPoint } from "../../types.js";
interface Props {
monitor: MonitoringSample | null;
@@ -16,7 +16,10 @@ function pluck<K extends keyof MonitoringSample>(
return history.map((s) => (s[key] as number | null) ?? 0);
}
function computeTargetFan(curve: FanPoint[] | null, tempC: number | null): number | null {
function computeTargetFan(
curve: FanPoint[] | null,
tempC: number | null,
): number | null {
if (!curve || !tempC || curve.length < 2) return null;
for (let i = 0; i < curve.length - 1; i++) {
@@ -36,19 +39,28 @@ function computeTargetFan(curve: FanPoint[] | null, tempC: number | null): numbe
return curve[curve.length - 1].fan_pct;
}
export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props) {
const fanHistory = pluck(history, 'fan_pct');
const tempHistory = pluck(history, 'temp_c');
export function FanMonitor({
monitor,
history,
fanCurve,
fanCurveActive,
}: Props) {
const fanHistory = pluck(history, "fan_pct");
const tempHistory = pluck(history, "temp_c");
const currentTemp = monitor?.temp_c ?? null;
const targetFan = computeTargetFan(fanCurve, currentTemp);
const targetFanHistory = history.map((s) => computeTargetFan(fanCurve, s.temp_c) ?? 0);
const targetFanHistory = history.map(
(s) => computeTargetFan(fanCurve, s.temp_c) ?? 0,
);
return (
<div className="flex flex-col gap-2 w-full h-full">
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full">
<div className="flex items-center justify-between pb-2 border-b border-zinc-800">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Live Monitor</span>
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
Live Monitor
</span>
{fanCurveActive && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-orange-500/15 border border-orange-500/30 text-orange-400 text-[10px] font-semibold">
Curve Active
@@ -73,7 +85,7 @@ export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props
{fanCurveActive && (
<GaugeCard
label="Target Fan"
value={targetFan !== null ? `${targetFan}%` : '—'}
value={targetFan !== null ? `${targetFan}%` : "—"}
history={targetFanHistory}
color="#fbbf24"
max={100}
@@ -82,7 +94,7 @@ export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props
<div className="mt-auto">
<GaugeCard
label="Fan Mode"
value={fanCurveActive ? 'Curve' : 'Auto'}
value={fanCurveActive ? "Curve" : "Auto"}
/>
</div>
</div>
+41 -39
View File
@@ -1,6 +1,6 @@
import { GaugeCard } from './GaugeCard';
import { fmt } from '../../utils/units';
import type { MonitoringSample } from '../../types';
import { GaugeCard } from "./GaugeCard.js";
import { fmt } from "../../utils/units.js";
import type { MonitoringSample } from "../../types.js";
interface Props {
monitor: MonitoringSample | null;
@@ -19,48 +19,50 @@ export function LiveMonitor({ monitor, history }: Props) {
<div className="flex flex-col gap-2 w-full h-full">
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full">
<div className="flex items-center justify-between pb-2 border-b border-zinc-800">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Live Monitor</span>
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
Live Monitor
</span>
</div>
<div className="flex flex-col gap-2 mt-1">
<GaugeCard
label="Core Clock"
value={fmt.mhz(monitor?.clock_mhz)}
history={pluck(history, 'clock_mhz')}
color="#34d399"
max={3000}
/>
<GaugeCard
label="Voltage"
value={fmt.mv(monitor?.voltage_mv)}
history={pluck(history, 'voltage_mv')}
color="#a78bfa"
max={1100}
/>
<GaugeCard
label="Power Draw"
value={fmt.watts(monitor?.power_w)}
history={pluck(history, 'power_w')}
color="#f472b6"
max={600}
/>
<GaugeCard
label="GPU Util"
value={fmt.pct(monitor?.gpu_util_pct)}
history={pluck(history, 'gpu_util_pct')}
color="#facc15"
max={100}
/>
<div className="mt-auto">
<GaugeCard
label="P-State"
value={monitor?.pstate_label ?? 'Unknown'}
history={pluck(history, 'pstate')}
color="#a8a29e"
max={15}
label="Core Clock"
value={fmt.mhz(monitor?.clock_mhz)}
history={pluck(history, "clock_mhz")}
color="#34d399"
max={3000}
/>
<GaugeCard
label="Voltage"
value={fmt.mv(monitor?.voltage_mv)}
history={pluck(history, "voltage_mv")}
color="#a78bfa"
max={1100}
/>
<GaugeCard
label="Power Draw"
value={fmt.watts(monitor?.power_w)}
history={pluck(history, "power_w")}
color="#f472b6"
max={600}
/>
<GaugeCard
label="GPU Util"
value={fmt.pct(monitor?.gpu_util_pct)}
history={pluck(history, "gpu_util_pct")}
color="#facc15"
max={100}
/>
<div className="mt-auto">
<GaugeCard
label="P-State"
value={monitor?.pstate_label ?? "Unknown"}
history={pluck(history, "pstate")}
color="#a8a29e"
max={15}
/>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -1,6 +1,6 @@
import { GaugeCard } from './GaugeCard';
import { fmt } from '../../utils/units';
import type { MonitoringSample } from '../../types';
import { GaugeCard } from "./GaugeCard.js";
import { fmt } from "../../utils/units.js";
import type { MonitoringSample } from "../../types.js";
interface Props {
monitor: MonitoringSample | null;
@@ -17,35 +17,38 @@ function pluck<K extends keyof MonitoringSample>(
export function PerformanceMonitor({ monitor, history }: Props) {
const memUsed = monitor?.mem_used_mib ?? null;
const memTotal = monitor?.mem_total_mib ?? null;
const memLabel = memUsed != null && memTotal != null
? `${memUsed.toFixed(0)} / ${memTotal.toFixed(0)} MiB`
: '—';
const memLabel =
memUsed != null && memTotal != null
? `${memUsed.toFixed(0)} / ${memTotal.toFixed(0)} MiB`
: "—";
return (
<div className="flex flex-col gap-2 w-full h-full">
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full">
<div className="flex items-center justify-between pb-2 border-b border-zinc-800">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Live Monitor</span>
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
Live Monitor
</span>
</div>
<div className="flex flex-col gap-2 mt-1">
<GaugeCard
label="Mem Clock"
value={fmt.mhz(monitor?.mem_clock_mhz)}
history={pluck(history, 'mem_clock_mhz')}
history={pluck(history, "mem_clock_mhz")}
color="#67e8f9"
max={20000}
/>
<GaugeCard
label="Power Draw"
value={fmt.watts(monitor?.power_w)}
history={pluck(history, 'power_w')}
history={pluck(history, "power_w")}
color="#f472b6"
max={600}
/>
<GaugeCard
label="VRAM Used"
value={memLabel}
history={pluck(history, 'mem_used_mib')}
history={pluck(history, "mem_used_mib")}
color="#a78bfa"
max={memTotal ?? 32768}
/>
@@ -8,9 +8,9 @@ import {
LogOut,
User,
} from "lucide-react";
import type { GpuInfo, MonitoringSample } from "../../types";
import { fmt } from "../../utils/units";
import { useCurveStore } from "../../store/curveStore";
import type { GpuInfo, MonitoringSample } from "../../types.js";
import { fmt } from "../../utils/units.js";
import { useCurveStore } from "../../store/curveStore.js";
import { useState, useRef, useEffect } from "react";
interface Props {
+58 -22
View File
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import { fmt } from '../../utils/units';
import type { VFPoint } from '../../types';
import { useCurveStore } from '../../store/curveStore';
import { useState, useRef, useEffect } from "react";
import { fmt } from "../../utils/units.js";
import type { VFPoint } from "../../types.js";
import { useCurveStore } from "../../store/curveStore.js";
interface Props {
point: VFPoint;
@@ -14,17 +14,28 @@ interface Props {
onMouseEnter?: () => void;
}
export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDeltaKhz, shouldAutoScroll, onMouseDown, onMouseEnter }: Props) {
export function PointRow({
point,
isCurrent,
isSelected,
isClamped,
pendingDeltaKhz,
shouldAutoScroll,
onMouseDown,
onMouseEnter,
}: Props) {
const { stageEdit } = useCurveStore();
const [editing, setEditing] = useState(false);
const [inputValue, setInputValue] = useState('');
const [inputValue, setInputValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const trRef = useRef<HTMLTableRowElement>(null);
useEffect(() => {
if (shouldAutoScroll && trRef.current) {
// @ts-expect-error: the typing seems to not include the valid 'container' option
trRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', container: 'nearest' });
trRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [shouldAutoScroll]);
@@ -36,11 +47,19 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
const displayEffMhz = point.freq_mhz + deltaChange / 1000;
const deltaColor = hasPending
? displayDeltaKhz > 0 ? 'text-cyan-400' : displayDeltaKhz < 0 ? 'text-orange-400' : 'text-zinc-400'
: point.delta_khz > 0 ? 'text-emerald-400' : point.delta_khz < 0 ? 'text-red-400' : 'text-zinc-500';
? displayDeltaKhz > 0
? "text-cyan-400"
: displayDeltaKhz < 0
? "text-orange-400"
: "text-zinc-400"
: point.delta_khz > 0
? "text-emerald-400"
: point.delta_khz < 0
? "text-red-400"
: "text-zinc-500";
function startEdit() {
setInputValue((displayDeltaMhz).toFixed(1));
setInputValue(displayDeltaMhz.toFixed(1));
setEditing(true);
setTimeout(() => {
inputRef.current?.select();
@@ -64,9 +83,13 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
<tr
ref={trRef}
className={[
'border-b border-zinc-800 text-xs font-mono cursor-pointer',
isCurrent ? 'bg-yellow-400/10' : isSelected ? 'bg-cyan-500/10' : 'hover:bg-zinc-800/50',
].join(' ')}
"border-b border-zinc-800 text-xs font-mono cursor-pointer",
isCurrent
? "bg-yellow-400/10"
: isSelected
? "bg-cyan-500/10"
: "hover:bg-zinc-800/50",
].join(" ")}
onMouseDown={(e) => {
if (editing) return;
onMouseDown?.(e);
@@ -80,7 +103,13 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
<td className="px-3 py-1 text-zinc-300">{fmt.mv(point.volt_mv, 0)}</td>
{/* Offset — click to edit inline */}
<td className={`px-3 py-1 ${deltaColor}`} onClick={(e) => { e.stopPropagation(); startEdit(); }}>
<td
className={`px-3 py-1 ${deltaColor}`}
onClick={(e) => {
e.stopPropagation();
startEdit();
}}
>
{editing ? (
<input
ref={inputRef}
@@ -90,28 +119,35 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
onChange={(e) => setInputValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); commitEdit(); }
if (e.key === 'Escape') cancelEdit();
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
commitEdit();
}
if (e.key === "Escape") cancelEdit();
}}
className="w-20 bg-zinc-700 text-cyan-300 rounded px-1 py-0 border border-cyan-500 outline-none text-xs"
style={{ fontFamily: 'monospace' }}
className="w-20 bg-zinc-700 text-cyan-300 rounded px-1 py-0 border border-cyan-500 outline-none text-xs font-mono"
/>
) : (
<span title="Click to edit">
{hasPending && <span className="text-cyan-500 mr-0.5">✎</span>}
{displayDeltaKhz > 0 ? '+' : ''}{displayDeltaMhz.toFixed(1)} MHz
{displayDeltaKhz > 0 ? "+" : ""}
{displayDeltaMhz.toFixed(1)} MHz
</span>
)}
</td>
{/* Eff. Freq */}
<td className={`px-3 py-1 font-semibold ${hasPending ? 'text-cyan-200' : 'text-zinc-100'}`}>
<td
className={`px-3 py-1 font-semibold ${hasPending ? "text-cyan-200" : "text-zinc-100"}`}
>
{fmt.mhz(displayEffMhz, 0)}
{isClamped && !hasPending && (
<span
className="ml-1 text-amber-500 cursor-help"
title="Clamped by monotonicity — a lower-voltage point with a higher offset is holding this frequency up"
>⇡</span>
>
⇡
</span>
)}
</td>
@@ -1,8 +1,11 @@
import { useState, useMemo, useEffect } from 'react';
import { PointRow } from './PointRow';
import type { VFPoint } from '../../types';
import { findCurrentPoint, detectClampedPoints } from '../../utils/curveHelpers';
import { useCurveStore } from '../../store/curveStore';
import { useState, useMemo, useEffect } from "react";
import { PointRow } from "./PointRow.js";
import type { VFPoint } from "../../types.js";
import {
findCurrentPoint,
detectClampedPoints,
} from "../../utils/curveHelpers.js";
import { useCurveStore } from "../../store/curveStore.js";
interface Props {
points: VFPoint[];
@@ -11,22 +14,32 @@ interface Props {
}
export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
const { pendingDeltas, selectedPoints, selectPoint, selectRange } = useCurveStore();
const {
pendingDeltas,
selectedPoints,
selectPoint,
togglePoint,
selectRange,
} = useCurveStore();
const currentPoint = findCurrentPoint(points, currentVoltageMv);
const clampedPoints = useMemo(() => detectClampedPoints(points), [points]);
const [dragStartIdx, setDragStartIdx] = useState<number | null>(null);
useEffect(() => {
function onUp() { setDragStartIdx(null); }
window.addEventListener('mouseup', onUp);
return () => window.removeEventListener('mouseup', onUp);
function onUp() {
setDragStartIdx(null);
}
window.addEventListener("mouseup", onUp);
return () => window.removeEventListener("mouseup", onUp);
}, []);
return (
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 shrink-0">
<span className="text-xs text-zinc-500 uppercase tracking-wider mr-2">Points</span>
<span className="text-xs text-zinc-500 uppercase tracking-wider mr-2">
Points
</span>
{!readOnly && pendingDeltas.size > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs">
{pendingDeltas.size} staged
@@ -35,13 +48,17 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
{readOnly && (
<span className="text-xs text-zinc-600 italic">read-only</span>
)}
<span className="ml-auto text-xs text-zinc-600">{points.length} points</span>
<span className="ml-auto text-xs text-zinc-600">
{points.length} points
</span>
{!readOnly && selectedPoints.size === 1 && (
<div className="flex gap-1 ml-4 border-l border-zinc-800 pl-4">
<button
onClick={() => {
const idx = Array.from(selectedPoints)[0];
const beforeIdxs = points.filter(p => p.index <= idx).map(p => p.index);
const beforeIdxs = points
.filter((p) => p.index <= idx)
.map((p) => p.index);
selectRange(beforeIdxs);
}}
className="px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors whitespace-nowrap"
@@ -51,7 +68,9 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
<button
onClick={() => {
const idx = Array.from(selectedPoints)[0];
const afterIdxs = points.filter(p => p.index >= idx).map(p => p.index);
const afterIdxs = points
.filter((p) => p.index >= idx)
.map((p) => p.index);
selectRange(afterIdxs);
}}
className="px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors whitespace-nowrap"
@@ -66,9 +85,15 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
<thead className="sticky top-0 bg-zinc-900 z-10">
<tr className="text-xs text-zinc-500 uppercase tracking-wider border-b border-zinc-800">
<th className="px-3 py-2 text-left font-normal bg-zinc-900">#</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900">Voltage</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900">Offset</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900">Eff. Freq</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900">
Voltage
</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900">
Offset
</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900">
Eff. Freq
</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900" />
</tr>
</thead>
@@ -81,34 +106,48 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
isSelected={selectedPoints.has(p.index)}
isClamped={clampedPoints.has(p.index)}
pendingDeltaKhz={pendingDeltas.get(p.index)}
shouldAutoScroll={selectedPoints.size === 1 && selectedPoints.has(p.index)}
onMouseDown={readOnly ? undefined : (e) => {
if (e.shiftKey) {
const currentSelected = Array.from(selectedPoints);
if (currentSelected.length > 0) {
const last = Math.max(...currentSelected);
const min = Math.min(last, p.index);
const max = Math.max(last, p.index);
const toSelect = points.filter(a => a.index >= min && a.index <= max).map(a => a.index);
selectRange(toSelect);
} else {
selectPoint(p.index, false);
}
} else if (e.ctrlKey || e.metaKey) {
selectPoint(p.index, true);
} else {
setDragStartIdx(p.index);
selectPoint(p.index, false);
}
}}
onMouseEnter={readOnly ? undefined : () => {
if (dragStartIdx !== null) {
const min = Math.min(dragStartIdx, p.index);
const max = Math.max(dragStartIdx, p.index);
const toSelect = points.filter(a => a.index >= min && a.index <= max).map(a => a.index);
selectRange(toSelect);
}
}}
shouldAutoScroll={
selectedPoints.size === 1 && selectedPoints.has(p.index)
}
onMouseDown={
readOnly
? undefined
: (e) => {
if (e.shiftKey) {
const currentSelected = Array.from(selectedPoints);
if (currentSelected.length > 0) {
const last = Math.max(...currentSelected);
const min = Math.min(last, p.index);
const max = Math.max(last, p.index);
const toSelect = points
.filter((a) => a.index >= min && a.index <= max)
.map((a) => a.index);
selectRange(toSelect);
} else {
selectPoint(p.index);
}
} else if (e.ctrlKey || e.metaKey) {
togglePoint(p.index);
} else {
setDragStartIdx(p.index);
selectPoint(p.index);
}
}
}
onMouseEnter={
readOnly
? undefined
: () => {
if (dragStartIdx !== null) {
const min = Math.min(dragStartIdx, p.index);
const max = Math.max(dragStartIdx, p.index);
const toSelect = points
.filter((a) => a.index >= min && a.index <= max)
.map((a) => a.index);
selectRange(toSelect);
}
}
}
/>
))}
</tbody>
@@ -8,10 +8,14 @@ import {
Star,
Fan,
} from "lucide-react";
import { api } from "../../api/client";
import type { ProfileData } from "../../types";
import { api } from "../../api/client.js";
import type { ProfileData } from "../../types.js";
import { toast } from "sonner";
import { useCurveStore } from "../../store/curveStore";
import { useCurveStore } from "../../store/curveStore.js";
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
interface ProfilePanelProps {
activeProfile: string | null;
@@ -65,37 +69,42 @@ export function ProfilePanel({
setAutoLoadProfile(name);
if (name) toast.success(`"${name}" will load on server start`);
else toast.success("Auto-load cleared");
} catch (e: any) {
toast.error(
"Failed to update default profile: " + (e.message || String(e)),
);
} catch (e: unknown) {
toast.error("Failed to update default profile: " + errMsg(e));
}
}
// Data fetch on GPU change — setState calls happen after the await, not
// synchronously in the effect body (rule false-positive on async fetch).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchProfiles();
}, [selectedGpuIndex]);
useEffect(() => {
if (isSaveOpen) saveInputRef.current?.focus();
else setNewName("");
}, [isSaveOpen]);
useEffect(() => {
if (renamingName) renameInputRef.current?.focus();
}, [renamingName]);
async function handleSave(e: React.FormEvent) {
function closeSaveForm() {
setIsSaveOpen(false);
setNewName("");
}
async function handleSave(e: React.SubmitEvent) {
e.preventDefault();
if (!newName.trim()) return;
try {
setIsSaving(true);
await api.saveProfile(newName.trim(), selectedGpuIndex);
toast.success(`Profile "${newName.trim()}" saved`);
setIsSaveOpen(false);
closeSaveForm();
await fetchProfiles();
} catch (e: any) {
toast.error("Failed to save: " + (e.message || String(e)));
} catch (e: unknown) {
toast.error("Failed to save: " + errMsg(e));
} finally {
setIsSaving(false);
}
@@ -107,8 +116,8 @@ export function ProfilePanel({
await api.applyProfile(name, selectedGpuIndex);
onProfileApplied(name);
toast.success(`"${name}" applied`);
} catch (e: any) {
toast.error(`Failed to apply "${name}": ` + (e.message || String(e)));
} catch (e: unknown) {
toast.error(`Failed to apply "${name}": ` + errMsg(e));
} finally {
setApplyingName(null);
}
@@ -123,14 +132,14 @@ export function ProfilePanel({
if (autoLoadProfile === name) setAutoLoadProfile(null);
setDeletingName(null);
await fetchProfiles();
} catch (e: any) {
toast.error("Failed to delete: " + (e.message || String(e)));
} catch (e: unknown) {
toast.error("Failed to delete: " + errMsg(e));
} finally {
setIsDeleting(false);
}
}
async function handleRename(e: React.FormEvent, oldName: string) {
async function handleRename(e: React.SubmitEvent, oldName: string) {
e.preventDefault();
if (!renameValue.trim() || renameValue.trim() === oldName) {
setRenamingName(null);
@@ -144,8 +153,8 @@ export function ProfilePanel({
if (autoLoadProfile === oldName) setAutoLoadProfile(renameValue.trim());
setRenamingName(null);
await fetchProfiles();
} catch (e: any) {
toast.error("Failed to rename: " + (e.message || String(e)));
} catch (e: unknown) {
toast.error("Failed to rename: " + errMsg(e));
} finally {
setIsRenaming(false);
}
@@ -179,7 +188,7 @@ export function ProfilePanel({
value={newName}
onChange={(e) => setNewName(e.target.value)}
disabled={isSaving}
onKeyDown={(e) => e.key === "Escape" && setIsSaveOpen(false)}
onKeyDown={(e) => e.key === "Escape" && closeSaveForm()}
className="flex-1 min-w-0 bg-zinc-950 border border-zinc-700 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 disabled:opacity-50"
/>
<button
@@ -191,7 +200,7 @@ export function ProfilePanel({
</button>
<button
type="button"
onClick={() => setIsSaveOpen(false)}
onClick={closeSaveForm}
className="px-3 py-1.5 text-zinc-400 hover:text-zinc-200 rounded text-sm transition shrink-0"
>
Cancel
@@ -10,16 +10,17 @@ interface Props {
export function ConfirmDialog({
message,
detail,
confirmLabel = 'Confirm',
confirmLabel = "Confirm",
isDestructive = false,
onConfirm,
onCancel,
}: Props) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: 'rgba(0,0,0,0.7)' }}
onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
>
<div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 w-96 max-w-[90vw]">
<h2 className="text-zinc-100 font-semibold text-sm mb-1">{message}</h2>
@@ -35,11 +36,11 @@ export function ConfirmDialog({
<button
onClick={onConfirm}
className={[
'px-3 py-1.5 rounded text-xs font-semibold transition-colors',
"px-3 py-1.5 rounded text-xs font-semibold transition-colors",
isDestructive
? 'bg-red-600 hover:bg-red-500 text-white'
: 'bg-emerald-600 hover:bg-emerald-500 text-white',
].join(' ')}
? "bg-red-600 hover:bg-red-500 text-white"
: "bg-emerald-600 hover:bg-emerald-500 text-white",
].join(" ")}
>
{confirmLabel}
</button>
+9 -7
View File
@@ -1,12 +1,14 @@
import { useEffect, useRef, useState } from 'react';
import { api } from '../api/client';
import { createWsConnection } from '../api/websocket';
import { useCurveStore } from '../store/curveStore';
import type { CurveState } from '../types';
import { useEffect, useRef, useState } from "react";
import { api } from "../api/client.js";
import { createWsConnection } from "../api/websocket.js";
import { useCurveStore } from "../store/curveStore.js";
import type { CurveState } from "../types.js";
export function useCurve() {
const { curve, setCurve, selectedGpuIndex } = useCurveStore();
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
const [wsStatus, setWsStatus] = useState<
"connecting" | "connected" | "disconnected"
>("connecting");
const wsRef = useRef<ReturnType<typeof createWsConnection> | null>(null);
useEffect(() => {
@@ -15,7 +17,7 @@ export function useCurve() {
// Subscribe to /ws/curve for push updates after writes
wsRef.current = createWsConnection<CurveState>(
'/ws/curve',
"/ws/curve",
(data) => setCurve(data),
setWsStatus,
selectedGpuIndex,
+9 -9
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { api } from "../api/client";
import { useCurveStore } from "../store/curveStore";
import type { DashboardInfo } from "../types";
import { api } from "../api/client.js";
import { useCurveStore } from "../store/curveStore.js";
import type { DashboardInfo } from "../types.js";
interface DashboardState {
gpuIndex: number;
@@ -23,17 +23,17 @@ export function useDashboard() {
useEffect(() => {
let cancelled = false;
api
.dashboard(selectedGpuIndex)
.then((data) => {
(async () => {
try {
const data = await api.dashboard(selectedGpuIndex);
if (!cancelled)
setState({ gpuIndex: selectedGpuIndex, data, done: true });
})
.catch((err) => {
} catch (err) {
console.error("Failed to load dashboard info:", err);
if (!cancelled)
setState({ gpuIndex: selectedGpuIndex, data: null, done: true });
});
}
})();
return () => {
cancelled = true;
};
+5 -4
View File
@@ -1,9 +1,10 @@
import { useEffect } from 'react';
import { api } from '../api/client';
import { useCurveStore } from '../store/curveStore';
import { useEffect } from "react";
import { api } from "../api/client.js";
import { useCurveStore } from "../store/curveStore.js";
export function useGpu() {
const { gpuInfo, setGpuInfo, setAvailableGpus, selectedGpuIndex } = useCurveStore();
const { gpuInfo, setGpuInfo, setAvailableGpus, selectedGpuIndex } =
useCurveStore();
useEffect(() => {
api.gpus().then(setAvailableGpus).catch(console.error);
+10 -7
View File
@@ -1,16 +1,19 @@
import { useEffect, useRef, useState } from 'react';
import { createWsConnection } from '../api/websocket';
import { useCurveStore } from '../store/curveStore';
import type { MonitoringSample } from '../types';
import { useEffect, useRef, useState } from "react";
import { createWsConnection } from "../api/websocket.js";
import { useCurveStore } from "../store/curveStore.js";
import type { MonitoringSample } from "../types.js";
export function useMonitor() {
const { monitor, monitorHistory, pushMonitor, selectedGpuIndex } = useCurveStore();
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
const { monitor, monitorHistory, pushMonitor, selectedGpuIndex } =
useCurveStore();
const [wsStatus, setWsStatus] = useState<
"connecting" | "connected" | "disconnected"
>("connecting");
const wsRef = useRef<ReturnType<typeof createWsConnection> | null>(null);
useEffect(() => {
wsRef.current = createWsConnection<MonitoringSample>(
'/ws/monitor',
"/ws/monitor",
pushMonitor,
setWsStatus,
selectedGpuIndex,
+95 -45
View File
@@ -1,7 +1,12 @@
import { create } from 'zustand';
import type { CurveState, GpuInfo, MonitoringSample, VFPoint } from '../types';
import { api } from '../api/client';
import { toast } from 'sonner';
import { create } from "zustand";
import type {
CurveState,
GpuInfo,
MonitoringSample,
VFPoint,
} from "../types.js";
import { api } from "../api/client.js";
import { toast } from "sonner";
const HISTORY_SIZE = 120; // ~60s at 2Hz
@@ -50,7 +55,9 @@ interface CurveStore {
resetAllDeltas: (onSuccess: () => void) => Promise<void>;
// Selection actions
selectPoint: (index: number, multi?: boolean) => void;
selectPoint: (index: number) => void;
/** Toggle a point in the selection (Shift/Ctrl+click); updates the anchor. */
togglePoint: (index: number) => void;
selectRange: (indices: number[]) => void;
clearSelection: () => void;
/**
@@ -80,7 +87,16 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
setAvailableGpus: (availableGpus) => set({ availableGpus }),
setSelectedGpuIndex: (selectedGpuIndex) => {
set({ selectedGpuIndex, curve: null, gpuInfo: null, monitor: null, monitorHistory: [], pendingDeltas: new Map(), selectedPoints: new Set(), anchorPoint: null });
set({
selectedGpuIndex,
curve: null,
gpuInfo: null,
monitor: null,
monitorHistory: [],
pendingDeltas: new Map(),
selectedPoints: new Set(),
anchorPoint: null,
});
},
setCurve: (curve) => set({ curve }),
setGpuInfo: (gpuInfo) => set({ gpuInfo }),
@@ -95,7 +111,7 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
stageEdit: (pointIndex, deltaKhz) =>
set((s) => {
const next = new Map(s.pendingDeltas);
const point = s.curve?.points.find(p => p.index === pointIndex);
const point = s.curve?.points.find((p) => p.index === pointIndex);
if (point && point.delta_khz === deltaKhz) {
next.delete(pointIndex);
} else {
@@ -108,7 +124,7 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
set((s) => {
const next = new Map(s.pendingDeltas);
edits.forEach((deltaKhz, index) => {
const point = s.curve?.points.find(p => p.index === index);
const point = s.curve?.points.find((p) => p.index === index);
if (point && point.delta_khz === deltaKhz) {
next.delete(index);
} else {
@@ -128,27 +144,39 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
}),
discardEdits: () =>
set({ pendingDeltas: new Map(), selectedPoints: new Set(), anchorPoint: null }),
set({
pendingDeltas: new Map(),
selectedPoints: new Set(),
anchorPoint: null,
}),
applyEdits: async (onSuccess) => {
const { pendingDeltas, selectedGpuIndex } = get();
if (pendingDeltas.size === 0) return;
// Convert Map to plain record for the API
const deltas: Record<number, number> = {};
pendingDeltas.forEach((v, k) => { deltas[k] = v; });
const deltas: Record<number, number> = Object.fromEntries(pendingDeltas);
try {
const result = await api.writeDeltas(deltas, selectedGpuIndex);
set({ pendingDeltas: new Map(), selectedPoints: new Set(), activeProfile: null });
set({
pendingDeltas: new Map(),
selectedPoints: new Set(),
activeProfile: null,
});
if (result?.freq_warnings?.length) {
toast.warning('Curve applied — driver clamped some points to 0 MHz (negative freq delta)');
toast.warning(
"Curve applied — driver clamped some points to 0 MHz (negative freq delta)",
);
} else {
toast.success('Curve applied successfully');
toast.success("Curve applied successfully");
}
onSuccess();
} catch (e: any) {
toast.error('Failed to apply curve: ' + (e.message || String(e)));
} catch (e: unknown) {
toast.error(
"Failed to apply curve: " +
(e instanceof Error ? e.message : String(e)),
);
}
},
@@ -156,35 +184,48 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
const { selectedGpuIndex } = get();
try {
await api.resetCurve(selectedGpuIndex);
set({ pendingDeltas: new Map(), selectedPoints: new Set(), activeProfile: null });
toast.success('Curve reset to hardware defaults');
set({
pendingDeltas: new Map(),
selectedPoints: new Set(),
activeProfile: null,
});
toast.success("Curve reset to hardware defaults");
onSuccess();
} catch (e: any) {
toast.error('Failed to reset curve: ' + (e.message || String(e)));
} catch (e: unknown) {
toast.error(
"Failed to reset curve: " +
(e instanceof Error ? e.message : String(e)),
);
}
},
selectPoint: (index, multi = false) =>
selectPoint: (index) =>
set((s) => {
const next = new Set(s.selectedPoints);
let anchor: number | null = index;
if (next.size === 1 && next.has(index)) {
next.clear();
anchor = null;
} else {
next.clear();
next.add(index);
}
return { selectedPoints: next, anchorPoint: anchor };
}),
togglePoint: (index) =>
set((s) => {
const next = new Set(s.selectedPoints);
let anchor = s.anchorPoint;
if (multi) {
if (next.has(index)) {
next.delete(index);
if (anchor === index) anchor = next.size > 0 ? [...next].at(-1)! : null;
} else {
next.add(index);
anchor = index; // last explicitly added point is the new anchor
if (next.has(index)) {
next.delete(index);
if (anchor === index) {
const last = [...next].at(-1);
anchor = last ?? null;
}
} else {
if (next.size === 1 && next.has(index)) {
next.clear();
anchor = null;
} else {
next.clear();
next.add(index);
anchor = index;
}
next.add(index);
anchor = index; // last explicitly added point is the new anchor
}
return { selectedPoints: next, anchorPoint: anchor };
}),
@@ -193,29 +234,38 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
// Bulk selects don't change the anchor — preserve it if still in the new selection.
set((s) => {
const next = new Set(indices);
const anchor = s.anchorPoint !== null && next.has(s.anchorPoint) ? s.anchorPoint : null;
const anchor =
s.anchorPoint !== null && next.has(s.anchorPoint)
? s.anchorPoint
: null;
return { selectedPoints: next, anchorPoint: anchor };
}),
clearSelection: () =>
set({ selectedPoints: new Set(), anchorPoint: null }),
clearSelection: () => set({ selectedPoints: new Set(), anchorPoint: null }),
flattenToAnchor: () => {
const { selectedPoints, anchorPoint, pendingDeltas, curve, stageMultiEdit } = get();
const {
selectedPoints,
anchorPoint,
pendingDeltas,
curve,
stageMultiEdit,
} = get();
if (selectedPoints.size < 2 || !curve) return;
const anchor = anchorPoint !== null && selectedPoints.has(anchorPoint)
? anchorPoint
: Math.min(...selectedPoints);
const anchor =
anchorPoint !== null && selectedPoints.has(anchorPoint)
? anchorPoint
: Math.min(...selectedPoints);
const anchorPt = curve.points.find(p => p.index === anchor);
const anchorPt = curve.points.find((p) => p.index === anchor);
if (!anchorPt) return;
const anchorPendingDelta = pendingDeltas.get(anchor) ?? anchorPt.delta_khz;
const anchorEffectiveKhz = anchorPt.freq_khz + anchorPendingDelta;
const edits = new Map<number, number>();
for (const idx of selectedPoints) {
const pt = curve.points.find(p => p.index === idx);
const pt = curve.points.find((p) => p.index === idx);
if (!pt) continue;
edits.set(idx, anchorEffectiveKhz - pt.freq_khz);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { VFPoint } from '../types';
import type { VFPoint } from '../types.js';
/**
* Approximate reference frequency (MHz) for a point: effective − delta.