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:
1 parent
9006c22fde
commit
930e56bd07
33 files changed
+1571
-804
No files matched your search
+18
-18
@@ -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";
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
FanState,
|
||||
FanPoint,
|
||||
DashboardInfo,
|
||||
} from "../types";
|
||||
} from "../types.js";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import type { VFPoint } from '../types';
|
||||
import type { VFPoint } from '../types.js';
|
||||
|
||||
/**
|
||||
* Approximate reference frequency (MHz) for a point: effective − delta.
|
||||
|
||||
Reference in new issue
Block a user