nvcurve with some fixes and better limits
This commit is contained in:
commit
024dcbceb0
70 files changed
+18890
No files matched your search
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useGpu } from './hooks/useGpu';
|
||||
import { useCurve } from './hooks/useCurve';
|
||||
import { useMonitor } from './hooks/useMonitor';
|
||||
import { StatusBar } from './components/Monitor/StatusBar';
|
||||
import { LiveMonitor } from './components/Monitor/LiveMonitor';
|
||||
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 { ProfilePanel } from './components/Profiles/ProfilePanel';
|
||||
import { api } from './api/client';
|
||||
import { useCurveStore } from './store/curveStore';
|
||||
import { Toaster } from 'sonner';
|
||||
import { Loader, ChevronDown } from 'lucide-react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
export default function App() {
|
||||
const gpuInfo = useGpu();
|
||||
const { curve, wsStatus: curveWsStatus } = useCurve();
|
||||
const { monitor, monitorHistory, wsStatus: monitorWsStatus } = useMonitor();
|
||||
const { setCurve, activeProfile, setActiveProfile, selectedGpuIndex } = useCurveStore();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'curve' | 'performance'>('curve');
|
||||
const [activeDomain, setActiveDomain] = useState<'gpu' | 'memory'>('gpu');
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||
const profileRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentVoltageMv = monitor?.voltage_mv ?? null;
|
||||
|
||||
function handleRefresh() {
|
||||
api.curve(selectedGpuIndex).then(setCurve).catch(console.error);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (profileRef.current && !profileRef.current.contains(event.target as Node)) {
|
||||
setIsProfileOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
|
||||
// Fetch initial active profile
|
||||
api.profiles(selectedGpuIndex).then(data => setActiveProfile(data.active)).catch(console.error);
|
||||
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [selectedGpuIndex, setActiveProfile]);
|
||||
|
||||
// Worst connection status wins
|
||||
const wsStatus =
|
||||
monitorWsStatus === 'disconnected' || curveWsStatus === 'disconnected'
|
||||
? 'disconnected'
|
||||
: monitorWsStatus === 'connecting' || curveWsStatus === 'connecting'
|
||||
? 'connecting'
|
||||
: 'connected';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-100 flex flex-col">
|
||||
<Toaster theme="dark" position="top-right" />
|
||||
<StatusBar gpuInfo={gpuInfo} wsStatus={wsStatus} monitor={monitor} />
|
||||
|
||||
{wsStatus !== 'connected' && (
|
||||
<div className="bg-amber-500/10 border-b border-amber-500/20 text-amber-500 px-4 py-2 text-center text-sm font-medium flex justify-center items-center gap-2">
|
||||
<Loader size={14} className="animate-spin" />
|
||||
{wsStatus === 'connecting' ? 'Reconnecting to backend...' : 'Connection lost. Retrying...'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="flex-1 p-4 flex flex-col gap-4 w-full max-w-[1600px] mx-auto">
|
||||
{/* Tab Header and Profile Selector */}
|
||||
<div className="flex justify-between items-end border-b border-zinc-800 pb-2">
|
||||
<div className="flex gap-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('curve')}
|
||||
className={`text-lg font-medium pb-2 -mb-[9px] border-b-2 transition-colors ${activeTab === 'curve' ? 'border-pink-500 text-zinc-100' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`}
|
||||
>
|
||||
Curve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('performance')}
|
||||
className={`text-lg font-medium pb-2 -mb-[9px] border-b-2 transition-colors ${activeTab === 'performance' ? 'border-pink-500 text-zinc-100' : 'border-transparent text-zinc-500 hover:text-zinc-300'}`}
|
||||
>
|
||||
Performance
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative" ref={profileRef}>
|
||||
<button
|
||||
onClick={() => setIsProfileOpen(!isProfileOpen)}
|
||||
className="flex items-center gap-2 text-zinc-400 hover:text-zinc-100 transition-colors text-sm font-medium pb-2 -mb-[9px]"
|
||||
>
|
||||
{activeProfile ? (
|
||||
<>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 shrink-0" />
|
||||
<span className="text-zinc-200">{activeProfile}</span>
|
||||
</>
|
||||
) : (
|
||||
'Profiles'
|
||||
)}
|
||||
<ChevronDown size={14} className={isProfileOpen ? "rotate-180 transition-transform" : "transition-transform"} />
|
||||
</button>
|
||||
{isProfileOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-80 z-50 shadow-[0_8px_30px_rgb(0,0,0,0.8)] border border-zinc-700/50 rounded-lg overflow-hidden">
|
||||
<ProfilePanel activeProfile={activeProfile} onProfileApplied={setActiveProfile} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{activeTab === 'curve' ? (
|
||||
<>
|
||||
<div className="flex gap-4 items-stretch w-full">
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
{curve ? (
|
||||
<CurveEditor
|
||||
curve={curve}
|
||||
activeDomain={activeDomain}
|
||||
onDomainChange={setActiveDomain}
|
||||
currentVoltageMv={currentVoltageMv}
|
||||
currentClockMhz={monitor?.clock_mhz ?? null}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-zinc-900 rounded-lg p-8 text-center text-zinc-500 h-full flex items-center justify-center">
|
||||
Loading curve…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-80 shrink-0 flex flex-col">
|
||||
<LiveMonitor monitor={monitor} history={monitorHistory} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{curve && (
|
||||
<div className="w-full">
|
||||
<PointTable
|
||||
points={curve.points.filter(p => p.domain === activeDomain)}
|
||||
currentVoltageMv={activeDomain === 'gpu' ? currentVoltageMv : null}
|
||||
readOnly={activeDomain === 'memory'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex gap-4 items-start w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<PerformancePanel />
|
||||
</div>
|
||||
<div className="w-80 shrink-0 flex flex-col">
|
||||
<PerformanceMonitor monitor={monitor} history={monitorHistory} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { CurveState, GpuInfo, MonitoringSample, SnapshotInfo, LimitsState, ProfileData } from '../types';
|
||||
|
||||
async function get<T>(path: string, gpuIndex?: number): Promise<T> {
|
||||
const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new Error(`GET ${url}: ${res.status} — ${text}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function post<T = void>(path: string, body?: unknown, gpuIndex?: number): Promise<T> {
|
||||
const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: body != null ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body != null ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new Error(`POST ${url}: ${res.status} — ${text}`);
|
||||
}
|
||||
// Some endpoints return no body (204)
|
||||
const ct = res.headers.get('content-type') ?? '';
|
||||
if (ct.includes('application/json')) return res.json() as Promise<T>;
|
||||
return undefined as unknown as T;
|
||||
}
|
||||
|
||||
async function del<T = void>(path: string, gpuIndex?: number): Promise<T> {
|
||||
const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`;
|
||||
const res = await fetch(url, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new Error(`DELETE ${url}: ${res.status} — ${text}`);
|
||||
}
|
||||
const ct = res.headers.get('content-type') ?? '';
|
||||
if (ct.includes('application/json')) return res.json() as Promise<T>;
|
||||
return undefined as unknown as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
gpus: () => get<GpuInfo[]>('/gpus'),
|
||||
gpu: (gpuIndex: number) => get<GpuInfo>('/gpu', gpuIndex),
|
||||
curve: (gpuIndex: number) => get<CurveState>('/curve', gpuIndex),
|
||||
ranges: (gpuIndex: number) => get<Record<string, { min_khz: number; max_khz: number }>>('/ranges', gpuIndex),
|
||||
voltage: (gpuIndex: number) => get<{ voltage_uv: number; voltage_mv: number }>('/voltage', gpuIndex),
|
||||
monitor: (gpuIndex: number) => get<MonitoringSample>('/monitor', gpuIndex),
|
||||
snapshots: (gpuIndex: number) => get<SnapshotInfo[]>('/snapshots', gpuIndex),
|
||||
|
||||
/** Write per-point frequency deltas. deltas: { pointIndex: deltaKhz } */
|
||||
writeDeltas: (deltas: Record<number, number>, gpuIndex: number) =>
|
||||
post<{ ok: boolean; freq_warnings?: string[] }>('/curve/write', { deltas }, gpuIndex),
|
||||
|
||||
/** Reset all frequency deltas to zero. */
|
||||
resetCurve: (gpuIndex: number) => post('/curve/reset', undefined, gpuIndex),
|
||||
|
||||
/** Get performance limits mapping */
|
||||
limits: (gpuIndex: number) => get<LimitsState>('/limits', gpuIndex),
|
||||
|
||||
/** Set performance limits */
|
||||
updateLimits: (updates: Partial<LimitsState>, gpuIndex: number) =>
|
||||
post('/limits', updates, gpuIndex),
|
||||
|
||||
/** Reset power limit and memory offset to hardware defaults */
|
||||
resetLimits: (gpuIndex: number) => post('/limits/reset', undefined, gpuIndex),
|
||||
|
||||
/** Profile Management */
|
||||
profiles: (gpuIndex: number) => get<{ profiles: ProfileData[], active: string | null, auto_load: string | null }>('/profiles', gpuIndex),
|
||||
saveProfile: (name: string, gpuIndex: number) => post<{ ok: boolean; filepath: string }>('/profiles', { name }, gpuIndex),
|
||||
applyProfile: (name: string, gpuIndex: number) => post(`/profiles/${encodeURIComponent(name)}/apply`, undefined, gpuIndex),
|
||||
deleteProfile: (name: string) => del(`/profiles/${encodeURIComponent(name)}`),
|
||||
renameProfile: (oldName: string, newName: string) =>
|
||||
post(`/profiles/${encodeURIComponent(oldName)}/rename`, { new_name: newName }),
|
||||
|
||||
/** Server config */
|
||||
setAutoLoadProfile: (name: string | null, gpuIndex: number) =>
|
||||
post<{ ok: boolean; auto_load_profile: string | null }>('/config', { auto_load_profile: name, gpu_index: gpuIndex }),
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
type MessageHandler<T> = (data: T) => void;
|
||||
type StatusHandler = (status: 'connecting' | 'connected' | 'disconnected') => void;
|
||||
|
||||
export interface WsHandle {
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export function createWsConnection<T>(
|
||||
path: string,
|
||||
onMessage: MessageHandler<T>,
|
||||
onStatus: StatusHandler,
|
||||
gpuIndex: number = 0,
|
||||
): WsHandle {
|
||||
let ws: WebSocket | null = null;
|
||||
let delay = 1000;
|
||||
let stopped = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function connect() {
|
||||
if (stopped) return;
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const url = `${protocol}://${location.host}${path}`;
|
||||
onStatus('connecting');
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
delay = 1000;
|
||||
onStatus('connected');
|
||||
ws?.send(JSON.stringify({ action: 'subscribe', gpu_index: gpuIndex }));
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
onMessage(JSON.parse(e.data as string) as T);
|
||||
} catch {
|
||||
// ignore malformed
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (stopped) return;
|
||||
onStatus('disconnected');
|
||||
timer = setTimeout(connect, delay);
|
||||
delay = Math.min(delay * 2, 8000);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws?.close();
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return {
|
||||
close() {
|
||||
stopped = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
ws?.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,975 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { Check, X, RotateCcw, RefreshCw } from 'lucide-react';
|
||||
import { scaleLinear } from 'd3';
|
||||
import type { VFPoint, CurveState } from '../../types';
|
||||
import { CurveTooltip } from './CurveTooltip';
|
||||
import { CurveToolbar } from './CurveToolbar';
|
||||
import { ConfirmDialog } from '../common/ConfirmDialog';
|
||||
import { voltExtent, refBaseMhz, detectClampedPoints } from '../../utils/curveHelpers';
|
||||
import { useCurveStore } from '../../store/curveStore';
|
||||
|
||||
interface Props {
|
||||
curve: CurveState;
|
||||
activeDomain: 'gpu' | 'memory';
|
||||
onDomainChange: (d: 'gpu' | 'memory') => void;
|
||||
currentVoltageMv: number | null;
|
||||
currentClockMhz: number | null;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
const MARGIN = { top: 16, right: 24, bottom: 48, left: 64 };
|
||||
|
||||
/** Drag-tooltip info (shown while actively dragging a point) */
|
||||
interface DragInfo {
|
||||
pointIndex: number;
|
||||
/** Current pending delta kHz while dragging */
|
||||
currentDeltaKhz: number;
|
||||
/** SVG inner coords of the point, for tooltip positioning */
|
||||
cx: number;
|
||||
cy: number;
|
||||
}
|
||||
|
||||
/** Box-select rubber-band rect (SVG inner coords) */
|
||||
interface BoxRect { x0: number; y0: number; x1: number; y1: number }
|
||||
|
||||
export function CurveEditor({ curve, activeDomain, onDomainChange, currentVoltageMv, currentClockMhz, onRefresh }: Props) {
|
||||
const readOnly = activeDomain === 'memory';
|
||||
const [hoveredPoint, setHoveredPoint] = useState<VFPoint | null>(null);
|
||||
const [dragInfo, setDragInfo] = useState<DragInfo | null>(null);
|
||||
const [boxRect, setBoxRect] = useState<BoxRect | null>(null);
|
||||
const [inlineInput, setInlineInput] = useState<{ pointIndex: number; value: string } | null>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [svgCursor, setSvgCursor] = useState<string>('default');
|
||||
|
||||
// ─── Dynamic SVG dimensions ──────────────────────────────────────────────
|
||||
const [svgW, setSvgW] = useState(680);
|
||||
const svgWRef = useRef(680);
|
||||
svgWRef.current = svgW;
|
||||
const INNER_W = svgW - MARGIN.left - MARGIN.right;
|
||||
|
||||
const [svgH, setSvgH] = useState(460);
|
||||
const svgHRef = useRef(460);
|
||||
svgHRef.current = svgH;
|
||||
const INNER_H = svgH - MARGIN.top - MARGIN.bottom;
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(entries => {
|
||||
const { width, height } = entries[0].contentRect;
|
||||
const w = Math.round(width);
|
||||
const h = Math.min(Math.round(height), 500, Math.round(window.innerHeight * 0.58));
|
||||
setSvgW(prev => prev === w ? prev : Math.max(300, w));
|
||||
setSvgH(prev => prev === h ? prev : Math.max(200, h));
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Store
|
||||
const {
|
||||
pendingDeltas,
|
||||
selectedPoints,
|
||||
anchorPoint,
|
||||
stageEdit,
|
||||
stageMultiEdit,
|
||||
selectPoint,
|
||||
selectRange,
|
||||
clearSelection,
|
||||
effectiveMhz,
|
||||
applyEdits,
|
||||
discardEdits,
|
||||
resetAllDeltas,
|
||||
hasNegativeFreqWarning,
|
||||
} = useCurveStore();
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [dialog, setDialog] = useState<'apply' | 'reset' | null>(null);
|
||||
|
||||
async function handleApplyConfirm() {
|
||||
setDialog(null);
|
||||
setBusy(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
await applyEdits(onRefresh);
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetConfirm() {
|
||||
setDialog(null);
|
||||
setBusy(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
await resetAllDeltas(onRefresh);
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const pts = useMemo(
|
||||
() => curve.points.filter(p => p.domain === activeDomain),
|
||||
[curve.points, activeDomain],
|
||||
);
|
||||
const clampedPoints = useMemo(() => detectClampedPoints(pts), [pts]);
|
||||
|
||||
// Clear selection and pending when switching domains
|
||||
useEffect(() => {
|
||||
clearSelection();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeDomain]);
|
||||
|
||||
// ─── X-axis zoom / viewport ──────────────────────────────────────────────
|
||||
// Use a slightly larger padding to space things out better
|
||||
const [xViewport, setXViewport] = useState<[number, number]>(() => voltExtent(pts, 40));
|
||||
|
||||
// Reset viewport when the set of points changes (curve refresh)
|
||||
const ptsKey = pts.map((p) => p.index).join(',');
|
||||
useEffect(() => {
|
||||
setXViewport(voltExtent(pts, 40));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ptsKey]);
|
||||
|
||||
// Compute Y domain accounting for pending changes
|
||||
const allEffective = pts.map((p) => effectiveMhz(p));
|
||||
const allBase = pts.map((p) => refBaseMhz(p));
|
||||
const freqMin = Math.min(...allBase, ...allEffective) - 50;
|
||||
const freqMax = Math.max(...allBase, ...allEffective) + 50;
|
||||
|
||||
const xScale = scaleLinear().domain(xViewport).range([0, INNER_W]);
|
||||
const yScale = scaleLinear().domain([freqMin, freqMax]).range([INNER_H, 0]);
|
||||
|
||||
// Keep scales in a ref so event handler closures always see the latest
|
||||
const scalesRef = useRef({ xScale, yScale, xViewport, innerW: INNER_W });
|
||||
scalesRef.current = { xScale, yScale, xViewport, innerW: INNER_W };
|
||||
|
||||
const xTicks = xScale.ticks(8);
|
||||
const yTicks = yScale.ticks(6);
|
||||
|
||||
// ─── Zoom factor (for toolbar slider) ────────────────────────────────────
|
||||
const [fullExtentMin, fullExtentMax] = voltExtent(pts);
|
||||
const fullWidth = (fullExtentMax - fullExtentMin) + 80;
|
||||
const currentWidth = xViewport[1] - xViewport[0];
|
||||
const zoomFactor = Math.max(1, Math.min(10, fullWidth / currentWidth));
|
||||
|
||||
function handleZoomChange(factor: number) {
|
||||
const pad = 40;
|
||||
const newWidth = Math.max(20, fullWidth / factor);
|
||||
const center = (xViewport[0] + xViewport[1]) / 2;
|
||||
let newMin = center - newWidth / 2;
|
||||
let newMax = center + newWidth / 2;
|
||||
if (newMin < fullExtentMin - pad) { newMin = fullExtentMin - pad; newMax = newMin + newWidth; }
|
||||
if (newMax > fullExtentMax + pad) { newMax = fullExtentMax + pad; newMin = newMax - newWidth; }
|
||||
setXViewport([newMin, newMax]);
|
||||
}
|
||||
|
||||
// Polylines
|
||||
const visiblePts = pts.filter((p) => {
|
||||
const v = p.volt_mv;
|
||||
return v >= xViewport[0] && v <= xViewport[1];
|
||||
});
|
||||
|
||||
const baseLine = visiblePts
|
||||
.map((p) => `${xScale(p.volt_mv).toFixed(1)},${yScale(refBaseMhz(p)).toFixed(1)}`)
|
||||
.join(' ');
|
||||
|
||||
const effectiveLine = visiblePts
|
||||
.map((p) => `${xScale(p.volt_mv).toFixed(1)},${yScale(p.freq_mhz).toFixed(1)}`)
|
||||
.join(' ');
|
||||
|
||||
const hasPending = pendingDeltas.size > 0;
|
||||
const pendingLine = hasPending
|
||||
? visiblePts
|
||||
.map((p) => `${xScale(p.volt_mv).toFixed(1)},${yScale(effectiveMhz(p)).toFixed(1)}`)
|
||||
.join(' ')
|
||||
: null;
|
||||
|
||||
// ─── Coordinate helpers ──────────────────────────────────────────────────
|
||||
function svgToContainer(svgX: number, svgY: number): { x: number; y: number } | null {
|
||||
const svg = svgRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!svg || !container) return null;
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const scaleX = svgRect.width / svgWRef.current;
|
||||
const scaleY = svgRect.height / svgHRef.current;
|
||||
return {
|
||||
x: svgRect.left - containerRect.left + (svgX + MARGIN.left) * scaleX,
|
||||
y: svgRect.top - containerRect.top + (svgY + MARGIN.top) * scaleY,
|
||||
};
|
||||
}
|
||||
|
||||
function clientToInner(clientX: number, clientY: number): { x: number; y: number } | null {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return null;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
return {
|
||||
x: (clientX - rect.left) * (svgWRef.current / rect.width) - MARGIN.left,
|
||||
y: (clientY - rect.top) * (svgHRef.current / rect.height) - MARGIN.top,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── SVG cursor ──────────────────────────────────────────────────────────
|
||||
function handleSvgMouseMove(e: React.MouseEvent) {
|
||||
if (isPanning.current) return;
|
||||
const next = e.shiftKey ? 'crosshair' : 'default';
|
||||
setSvgCursor(c => c === next ? c : next);
|
||||
}
|
||||
|
||||
function handleSvgMouseLeave() {
|
||||
if (!isPanning.current) setSvgCursor('default');
|
||||
}
|
||||
|
||||
// ─── Point hover ─────────────────────────────────────────────────────────
|
||||
const handleMouseEnter = useCallback((p: VFPoint) => {
|
||||
setHoveredPoint(p);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHoveredPoint(null);
|
||||
}, []);
|
||||
|
||||
// ─── Drag state (refs — survive renders without triggering effects) ───────
|
||||
const dragState = useRef<{
|
||||
active: boolean;
|
||||
pointIndex: number;
|
||||
startY: number;
|
||||
startDeltaKhz: number;
|
||||
pointInitialDeltas: Map<number, number>;
|
||||
} | null>(null);
|
||||
/** True if the mouse moved meaningfully during the current mousedown */
|
||||
const dragMoved = useRef(false);
|
||||
|
||||
// ─── Box select refs ─────────────────────────────────────────────────────
|
||||
const boxStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const isBoxSelecting = useRef(false);
|
||||
|
||||
// ─── Pan refs ─────────────────────────────────────────────────────────────
|
||||
const panState = useRef<{ startX: number; startY: number; startViewport: [number, number] } | null>(null);
|
||||
const isPanning = useRef(false);
|
||||
|
||||
// ─── Point mousedown → start drag ────────────────────────────────────────
|
||||
function handlePointMouseDown(e: React.MouseEvent, p: VFPoint) {
|
||||
if (readOnly) return;
|
||||
containerRef.current?.focus();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const inner = clientToInner(e.clientX, e.clientY);
|
||||
if (!inner) return;
|
||||
|
||||
const currentDelta = pendingDeltas.has(p.index)
|
||||
? pendingDeltas.get(p.index)!
|
||||
: p.delta_khz;
|
||||
|
||||
const initialDeltas = new Map<number, number>();
|
||||
|
||||
if (selectedPoints.has(p.index) && selectedPoints.size > 1) {
|
||||
// Feature: Dragging a point in a multi-selection moves the whole selection
|
||||
for (const index of selectedPoints) {
|
||||
const pt = pts.find(x => x.index === index);
|
||||
if (pt) {
|
||||
initialDeltas.set(index, pendingDeltas.has(index) ? pendingDeltas.get(index)! : pt.delta_khz);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Default: Just drag this point
|
||||
initialDeltas.set(p.index, currentDelta);
|
||||
}
|
||||
|
||||
dragState.current = {
|
||||
active: true,
|
||||
pointIndex: p.index,
|
||||
startY: inner.y,
|
||||
startDeltaKhz: currentDelta,
|
||||
pointInitialDeltas: initialDeltas
|
||||
};
|
||||
dragMoved.current = false;
|
||||
}
|
||||
|
||||
// ─── SVG background mousedown → pan OR shift+drag = box select ───────────
|
||||
function handleSvgMouseDown(e: React.MouseEvent) {
|
||||
containerRef.current?.focus();
|
||||
if (e.button !== 0) return;
|
||||
const inner = clientToInner(e.clientX, e.clientY);
|
||||
if (!inner) return;
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Shift+drag = box select
|
||||
isBoxSelecting.current = true;
|
||||
boxStartRef.current = inner;
|
||||
setBoxRect({ x0: inner.x, y0: inner.y, x1: inner.x, y1: inner.y });
|
||||
} else {
|
||||
// Plain drag = pan
|
||||
isPanning.current = true;
|
||||
setSvgCursor('grabbing');
|
||||
panState.current = { startX: inner.x, startY: inner.y, startViewport: [...scalesRef.current.xViewport] as [number, number] };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Scroll wheel → zoom x-axis around cursor (Alt + scroll) ────────────
|
||||
function handleWheel(e: React.WheelEvent) {
|
||||
if (!e.altKey) return; // plain scroll → let page scroll normally
|
||||
e.preventDefault();
|
||||
const inner = clientToInner(e.clientX, e.clientY);
|
||||
if (!inner) return;
|
||||
|
||||
const { xScale: sx, xViewport: vp } = scalesRef.current;
|
||||
const zoomFactor = e.deltaY < 0 ? 1.15 : 1 / 1.15; // scroll up = zoom in
|
||||
const mouseVolt = sx.invert(inner.x);
|
||||
const [vpMin, vpMax] = vp;
|
||||
const currentWidth = vpMax - vpMin;
|
||||
const newWidth = Math.max(20, Math.min(voltExtent(pts)[1] - voltExtent(pts)[0] + 40, currentWidth / zoomFactor));
|
||||
const anchorFrac = (mouseVolt - vpMin) / currentWidth;
|
||||
let newMin = mouseVolt - anchorFrac * newWidth;
|
||||
let newMax = mouseVolt + (1 - anchorFrac) * newWidth;
|
||||
// Clamp to full extent
|
||||
const [fullMin, fullMax] = voltExtent(pts);
|
||||
const pad = 20;
|
||||
if (newMin < fullMin - pad) { newMax += (fullMin - pad - newMin); newMin = fullMin - pad; }
|
||||
if (newMax > fullMax + pad) { newMin -= (newMax - fullMax - pad); newMax = fullMax + pad; }
|
||||
setXViewport([newMin, newMax]);
|
||||
}
|
||||
|
||||
// ─── Global mousemove + mouseup ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
function onMove(e: MouseEvent) {
|
||||
// --- Point drag ---
|
||||
if (dragState.current?.active) {
|
||||
const inner = clientToInner(e.clientX, e.clientY);
|
||||
if (!inner) return;
|
||||
const ds = dragState.current;
|
||||
const dyPx = inner.y - ds.startY;
|
||||
|
||||
// Consider moved if more than 3px
|
||||
if (Math.abs(dyPx) > 3) dragMoved.current = true;
|
||||
|
||||
const { yScale: ys } = scalesRef.current;
|
||||
const [yRangeBottom, yRangeTop] = ys.range() as [number, number];
|
||||
const [yDomainBottom, yDomainTop] = ys.domain() as [number, number];
|
||||
const pxPerMhz = (yRangeBottom - yRangeTop) / (yDomainTop - yDomainBottom);
|
||||
const deltaMhz = -dyPx / pxPerMhz;
|
||||
|
||||
// Calculate the absolute delta for the primary dragged point
|
||||
const primaryNewDeltaKhz = Math.round(ds.startDeltaKhz + deltaMhz * 1000);
|
||||
const primaryClamped = Math.max(-1000_000, Math.min(1000_000, primaryNewDeltaKhz));
|
||||
|
||||
// Difference to apply to all other points in the selection
|
||||
const validDeltaDiff = primaryClamped - ds.startDeltaKhz;
|
||||
|
||||
if (ds.pointInitialDeltas.size > 1) {
|
||||
const edits = new Map<number, number>();
|
||||
ds.pointInitialDeltas.forEach((initialDelta, index) => {
|
||||
const newDelta = initialDelta + validDeltaDiff;
|
||||
const clampedNewDelta = Math.max(-1000_000, Math.min(1000_000, newDelta));
|
||||
edits.set(index, clampedNewDelta);
|
||||
});
|
||||
stageMultiEdit(edits);
|
||||
} else {
|
||||
stageEdit(ds.pointIndex, primaryClamped);
|
||||
}
|
||||
|
||||
// Update drag tooltip info
|
||||
const point = pts.find((p) => p.index === ds.pointIndex);
|
||||
if (point) {
|
||||
const cx = scalesRef.current.xScale(point.volt_mv);
|
||||
const deltaChange = primaryClamped - point.delta_khz;
|
||||
const cy = scalesRef.current.yScale(point.freq_mhz + deltaChange / 1000);
|
||||
setDragInfo({ pointIndex: ds.pointIndex, currentDeltaKhz: primaryClamped, cx, cy });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Pan ---
|
||||
if (isPanning.current && panState.current) {
|
||||
const inner = clientToInner(e.clientX, e.clientY);
|
||||
if (!inner) return;
|
||||
const ps = panState.current;
|
||||
// How many volts does 1 SVG-inner-px correspond to?
|
||||
const voltPerPx = (ps.startViewport[1] - ps.startViewport[0]) / scalesRef.current.innerW;
|
||||
const dxVolt = (inner.x - ps.startX) * voltPerPx;
|
||||
const [fullMin, fullMax] = voltExtent(pts);
|
||||
const pad = 20;
|
||||
const vpWidth = ps.startViewport[1] - ps.startViewport[0];
|
||||
let newMin = ps.startViewport[0] - dxVolt;
|
||||
let newMax = ps.startViewport[1] - dxVolt;
|
||||
// Clamp so we don't pan completely outside
|
||||
if (newMin < fullMin - pad) { newMin = fullMin - pad; newMax = newMin + vpWidth; }
|
||||
if (newMax > fullMax + pad) { newMax = fullMax + pad; newMin = newMax - vpWidth; }
|
||||
setXViewport([newMin, newMax]);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Box select ---
|
||||
if (isBoxSelecting.current && boxStartRef.current) {
|
||||
const inner = clientToInner(e.clientX, e.clientY);
|
||||
if (!inner) return;
|
||||
setBoxRect({
|
||||
x0: boxStartRef.current.x,
|
||||
y0: boxStartRef.current.y,
|
||||
x1: inner.x,
|
||||
y1: inner.y,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onUp(e: MouseEvent) {
|
||||
// --- End drag ---
|
||||
if (dragState.current?.active) {
|
||||
dragState.current = null;
|
||||
setDragInfo(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- End pan ---
|
||||
if (isPanning.current) {
|
||||
isPanning.current = false;
|
||||
setSvgCursor('default');
|
||||
if (panState.current) {
|
||||
const inner = clientToInner(e.clientX, e.clientY);
|
||||
if (inner) {
|
||||
const dx = Math.abs(inner.x - panState.current.startX);
|
||||
const dy = Math.abs(inner.y - panState.current.startY);
|
||||
if (dx < 3 && dy < 3) {
|
||||
// It was just a click on the background, not a pan, so clear selection
|
||||
clearSelection();
|
||||
}
|
||||
}
|
||||
}
|
||||
panState.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// --- End box select ---
|
||||
if (isBoxSelecting.current && boxStartRef.current) {
|
||||
isBoxSelecting.current = false;
|
||||
const inner = clientToInner(e.clientX, e.clientY);
|
||||
if (inner) {
|
||||
const finalRect = {
|
||||
x0: boxStartRef.current.x,
|
||||
y0: boxStartRef.current.y,
|
||||
x1: inner.x,
|
||||
y1: inner.y,
|
||||
};
|
||||
const minX = Math.min(finalRect.x0, finalRect.x1);
|
||||
const maxX = Math.max(finalRect.x0, finalRect.x1);
|
||||
const minY = Math.min(finalRect.y0, finalRect.y1);
|
||||
const maxY = Math.max(finalRect.y0, finalRect.y1);
|
||||
const sizeTrivial = Math.abs(finalRect.x1 - finalRect.x0) < 4
|
||||
&& Math.abs(finalRect.y1 - finalRect.y0) < 4;
|
||||
|
||||
if (!sizeTrivial) {
|
||||
const { xScale: sx, yScale: ys } = scalesRef.current;
|
||||
const selected = pts
|
||||
.filter((p) => {
|
||||
const cx = sx(p.volt_mv);
|
||||
const cy = ys(effectiveMhz(p));
|
||||
return cx >= minX && cx <= maxX && cy >= minY && cy <= maxY;
|
||||
})
|
||||
.map((p) => p.index);
|
||||
selectRange(selected);
|
||||
} else {
|
||||
clearSelection();
|
||||
}
|
||||
}
|
||||
boxStartRef.current = null;
|
||||
setBoxRect(null);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
// Only re-register when structural deps change; scales are accessed via scalesRef
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pts, pendingDeltas, stageEdit, selectRange, clearSelection, effectiveMhz]);
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col h-full">
|
||||
{/* Header: title + domain toggle + action buttons */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-800 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">V/F Curve</span>
|
||||
{/* Domain toggle */}
|
||||
<div className="flex rounded overflow-hidden border border-zinc-700 text-xs">
|
||||
{([{label: 'GPU', value: 'gpu'}, {label: 'Memory', value: 'memory'}] as const).map((d) => (
|
||||
<button
|
||||
key={d.value}
|
||||
onClick={() => onDomainChange(d.value)}
|
||||
className={`px-2.5 py-0.5 capitalize transition-colors ${
|
||||
activeDomain === d.value
|
||||
? 'bg-zinc-700 text-zinc-100'
|
||||
: 'bg-zinc-900 text-zinc-500 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
{d.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{readOnly && (
|
||||
<span className="text-xs text-zinc-500 italic">read-only</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!readOnly && 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">
|
||||
{pendingDeltas.size} pending
|
||||
</span>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setDialog('apply')}
|
||||
disabled={!hasPending || busy}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded bg-emerald-700 hover:bg-emerald-600 text-white text-xs font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Check size={12} />
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
onClick={() => discardEdits()}
|
||||
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"
|
||||
>
|
||||
<X size={12} />
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDialog('reset')}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-red-900 text-zinc-400 hover:text-red-300 text-xs transition-colors"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
Reset
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
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"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Banners */}
|
||||
{hasNegativeFreqWarning() && (
|
||||
<div className="px-3 py-1.5 bg-amber-900/40 border-b border-amber-700 text-amber-300 text-xs">
|
||||
⚠ One or more pending deltas would produce a negative effective frequency. The driver will clamp to 0 MHz.
|
||||
</div>
|
||||
)}
|
||||
{actionError && (
|
||||
<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>⚠ {actionError}</span>
|
||||
<button onClick={() => setActionError(null)} className="ml-2 text-red-400 hover:text-red-200">✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirm dialogs */}
|
||||
{dialog === 'apply' && (
|
||||
<ConfirmDialog
|
||||
message={`Apply ${pendingDeltas.size} pending change${pendingDeltas.size !== 1 ? 's' : ''} to hardware?`}
|
||||
detail="This will write the staged frequency deltas to the GPU via NvAPI. The changes take effect immediately."
|
||||
confirmLabel="Apply"
|
||||
onConfirm={handleApplyConfirm}
|
||||
onCancel={() => setDialog(null)}
|
||||
/>
|
||||
)}
|
||||
{dialog === 'reset' && (
|
||||
<ConfirmDialog
|
||||
message="Reset all frequency offsets to zero?"
|
||||
detail="This will write zero delta to every V/F point on the GPU. Any pending staged changes will also be discarded."
|
||||
confirmLabel="Reset"
|
||||
isDestructive
|
||||
onConfirm={handleResetConfirm}
|
||||
onCancel={() => setDialog(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="px-3 pt-3">
|
||||
<CurveToolbar
|
||||
activePts={pts}
|
||||
onResetZoom={() => setXViewport(voltExtent(pts))}
|
||||
isZoomed={Math.abs((xViewport[1] - xViewport[0]) - (voltExtent(pts)[1] - voltExtent(pts)[0])) > 5}
|
||||
readOnly={readOnly}
|
||||
zoomFactor={zoomFactor}
|
||||
onZoomChange={handleZoomChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Need tabIndex=0 to capture keyboard events */}
|
||||
<div className="flex-1 min-h-0 relative focus:outline-none px-3 pt-2 pb-3 flex flex-col justify-center"
|
||||
ref={containerRef}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (inlineInput) {
|
||||
if (e.key === 'Escape') setInlineInput(null);
|
||||
return; // Let the input handle it
|
||||
}
|
||||
|
||||
if (e.key === 'a' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
selectRange(pts.map(p => p.index));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
if (pts.length === 0) return;
|
||||
const currentSelected = Array.from(selectedPoints);
|
||||
if (currentSelected.length === 0) {
|
||||
selectPoint(pts[0].index);
|
||||
} else {
|
||||
const lastSelected = e.shiftKey ? Math.min(...currentSelected) : Math.max(...currentSelected);
|
||||
const idx = pts.findIndex(p => p.index === lastSelected);
|
||||
if (idx >= 0) {
|
||||
let nextIdx = e.shiftKey ? idx - 1 : idx + 1;
|
||||
if (nextIdx < 0) nextIdx = pts.length - 1;
|
||||
if (nextIdx >= pts.length) nextIdx = 0;
|
||||
selectPoint(pts[nextIdx].index);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (selectedPoints.size === 0) return;
|
||||
const changeMhz = e.key === 'ArrowUp' ? 1 : -1;
|
||||
const multiplier = (e.ctrlKey || e.metaKey) ? 10 : 1;
|
||||
const changeKhz = changeMhz * multiplier * 1000;
|
||||
|
||||
const edits = new Map<number, number>();
|
||||
for (const idx of selectedPoints) {
|
||||
const p = pts.find(pt => pt.index === idx);
|
||||
if (p) {
|
||||
const current = pendingDeltas.has(idx) ? pendingDeltas.get(idx)! : p.delta_khz;
|
||||
const clamped = Math.max(-500_000, Math.min(500_000, current + changeKhz));
|
||||
edits.set(idx, clamped);
|
||||
}
|
||||
}
|
||||
stageMultiEdit(edits);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (selectedPoints.size === 1) {
|
||||
const idx = Array.from(selectedPoints)[0];
|
||||
const p = pts.find(pt => pt.index === idx);
|
||||
if (p) {
|
||||
const initVal = (pendingDeltas.has(idx) ? pendingDeltas.get(idx)! : p.delta_khz) / 1000;
|
||||
setInlineInput({ pointIndex: idx, value: initVal.toFixed(1) });
|
||||
// We'll focus the input in an effect
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${svgW} ${svgH}`}
|
||||
width="100%"
|
||||
height={svgH}
|
||||
className="block"
|
||||
style={{ fontFamily: 'monospace', cursor: svgCursor, userSelect: 'none' }}
|
||||
onMouseDown={handleSvgMouseDown}
|
||||
onMouseMove={handleSvgMouseMove}
|
||||
onMouseLeave={handleSvgMouseLeave}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<g transform={`translate(${MARGIN.left},${MARGIN.top})`}>
|
||||
<defs>
|
||||
<clipPath id="chart-clip">
|
||||
<rect x={0} y={0} width={INNER_W + 1} height={INNER_H + 1} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
{/* Grid */}
|
||||
{xTicks.map((t) => (
|
||||
<line key={t} x1={xScale(t)} x2={xScale(t)} y1={0} y2={INNER_H} stroke="#27272a" strokeWidth={1} />
|
||||
))}
|
||||
{yTicks.map((t) => (
|
||||
<line key={t} x1={0} x2={INNER_W} y1={yScale(t)} y2={yScale(t)} stroke="#27272a" strokeWidth={1} />
|
||||
))}
|
||||
|
||||
{/* X axis */}
|
||||
{xTicks.map((t) => (
|
||||
<g key={t} transform={`translate(${xScale(t)},${INNER_H})`}>
|
||||
<line y2={5} stroke="#52525b" />
|
||||
<text y={18} textAnchor="middle" fontSize={10} fill="#71717a">{t.toFixed(0)}</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Y axis */}
|
||||
{yTicks.map((t) => (
|
||||
<g key={t} transform={`translate(0,${yScale(t)})`}>
|
||||
<line x2={-5} stroke="#52525b" />
|
||||
<text x={-10} textAnchor="end" dominantBaseline="middle" fontSize={10} fill="#71717a">{t.toFixed(0)}</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Axis labels */}
|
||||
<text x={INNER_W / 2} y={INNER_H + 40} textAnchor="middle" fontSize={11} fill="#52525b">Voltage (mV)</text>
|
||||
<text x={-INNER_H / 2} y={-50} textAnchor="middle" fontSize={11} fill="#52525b" transform="rotate(-90)">Frequency (MHz)</text>
|
||||
|
||||
{/* Border */}
|
||||
<rect x={0} y={0} width={INNER_W} height={INNER_H} fill="none" stroke="#3f3f46" strokeWidth={1} />
|
||||
|
||||
<g clipPath="url(#chart-clip)">
|
||||
{/* Base curve */}
|
||||
<polyline points={baseLine} fill="none" stroke="#7c3aed" strokeWidth={1.5} strokeOpacity={0.4} strokeLinejoin="round" />
|
||||
|
||||
{/* Confirmed effective curve */}
|
||||
<polyline points={effectiveLine} fill="none" stroke="#34d399" strokeWidth={2} strokeLinejoin="round" />
|
||||
|
||||
{/* Pending effective curve */}
|
||||
{pendingLine && (
|
||||
<polyline
|
||||
points={pendingLine}
|
||||
fill="none"
|
||||
stroke="#22d3ee"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="6 3"
|
||||
strokeLinejoin="round"
|
||||
strokeOpacity={0.85}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Current voltage / clock crosshairs — GPU domain only */}
|
||||
{!readOnly && currentVoltageMv != null && xScale(currentVoltageMv) >= 0 && xScale(currentVoltageMv) <= INNER_W && (
|
||||
<line
|
||||
x1={xScale(currentVoltageMv)} x2={xScale(currentVoltageMv)}
|
||||
y1={0} y2={INNER_H}
|
||||
stroke="#facc15" strokeWidth={0.5} strokeDasharray="4 4" strokeOpacity={0.3}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && currentClockMhz != null && yScale(currentClockMhz) >= 0 && yScale(currentClockMhz) <= INNER_H && (
|
||||
<line
|
||||
x1={0} x2={INNER_W}
|
||||
y1={yScale(currentClockMhz)} y2={yScale(currentClockMhz)}
|
||||
stroke="#facc15" strokeWidth={0.5} strokeDasharray="4 4" strokeOpacity={0.3}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && currentVoltageMv != null && currentClockMhz != null &&
|
||||
xScale(currentVoltageMv) >= 0 && xScale(currentVoltageMv) <= INNER_W &&
|
||||
yScale(currentClockMhz) >= 0 && yScale(currentClockMhz) <= INNER_H && (
|
||||
<circle
|
||||
cx={xScale(currentVoltageMv)}
|
||||
cy={yScale(currentClockMhz)}
|
||||
r={3.5}
|
||||
fill="#fde047"
|
||||
stroke="#18181b"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pts.map((p) => {
|
||||
if (p.volt_mv < xViewport[0] - 5 || p.volt_mv > xViewport[1] + 5) return null;
|
||||
|
||||
const cx = xScale(p.volt_mv);
|
||||
/** Confirmed position (hardware state — VFP effective frequency) */
|
||||
const confirmedCy = yScale(p.freq_mhz);
|
||||
/** Staged/pending position (what will be applied) */
|
||||
const pendingCy = yScale(effectiveMhz(p));
|
||||
const hasPendingEdit = pendingDeltas.has(p.index);
|
||||
/**
|
||||
* The "main" interactive circle sits at the pending position when
|
||||
* there's a staged edit — that's the point the user is working with.
|
||||
* Otherwise it sits at the confirmed position.
|
||||
*/
|
||||
const mainCy = hasPendingEdit ? pendingCy : confirmedCy;
|
||||
const ghostCy = hasPendingEdit ? confirmedCy : null; // dim ring showing where it was
|
||||
|
||||
const isHovered = hoveredPoint?.index === p.index;
|
||||
const isSelected = selectedPoints.has(p.index);
|
||||
const isDragging = dragInfo?.pointIndex === p.index;
|
||||
const isAnchor = isSelected && selectedPoints.size >= 2 && p.index === anchorPoint;
|
||||
|
||||
let fill = readOnly ? '#6366f1' : '#34d399';
|
||||
if (!readOnly && hasPendingEdit && !isSelected) fill = '#22d3ee';
|
||||
if (!readOnly && isSelected) fill = '#22d3ee';
|
||||
|
||||
// Tweaked radius for less bloated flat regions
|
||||
const r = isDragging ? 5.5 : isHovered || isSelected || hasPendingEdit ? 4.5 : 2.5;
|
||||
|
||||
return (
|
||||
<g key={p.index}>
|
||||
{/* Anchor ring — amber outer halo marking the flatten reference point */}
|
||||
{isAnchor && (
|
||||
<circle cx={cx} cy={mainCy} r={r + 3.5}
|
||||
fill="none" stroke="#f59e0b" strokeWidth={1.5} strokeOpacity={0.85} />
|
||||
)}
|
||||
|
||||
{/* Dim ring at confirmed position (only visible when there's a pending edit) */}
|
||||
{!readOnly && ghostCy !== null && Math.abs(ghostCy - mainCy) > 0.5 && (
|
||||
<circle cx={cx} cy={ghostCy} r={3}
|
||||
fill="none" stroke="#34d399" strokeWidth={1} strokeOpacity={0.4} />
|
||||
)}
|
||||
|
||||
{/* Drop-line from confirmed → pending while dragging */}
|
||||
{!readOnly && isDragging && ghostCy !== null && Math.abs(ghostCy - mainCy) > 1 && (
|
||||
<line x1={cx} y1={ghostCy} x2={cx} y2={mainCy}
|
||||
stroke="#22d3ee" strokeWidth={1} strokeDasharray="3 2" strokeOpacity={0.5} />
|
||||
)}
|
||||
|
||||
{/* Main circle */}
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={mainCy}
|
||||
r={r}
|
||||
fill={fill}
|
||||
fillOpacity={readOnly ? 0.7 : 1}
|
||||
stroke={!readOnly && (isDragging || isSelected || isHovered) ? '#fff' : 'none'}
|
||||
strokeWidth={isDragging ? 2 : isSelected ? 2 : 1}
|
||||
style={{ cursor: readOnly ? 'default' : 'ns-resize', transition: 'r 0.08s' }}
|
||||
onMouseEnter={() => handleMouseEnter(p)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onMouseDown={readOnly ? undefined : (e) => handlePointMouseDown(e, p)}
|
||||
onClick={readOnly ? undefined : (e) => {
|
||||
if (dragMoved.current) return;
|
||||
selectPoint(p.index, e.shiftKey);
|
||||
}}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* Box-select rubber band */}
|
||||
{boxRect && (
|
||||
<rect
|
||||
x={Math.min(boxRect.x0, boxRect.x1)}
|
||||
y={Math.min(boxRect.y0, boxRect.y1)}
|
||||
width={Math.abs(boxRect.x1 - boxRect.x0)}
|
||||
height={Math.abs(boxRect.y1 - boxRect.y0)}
|
||||
fill="#22d3ee" fillOpacity={0.08}
|
||||
stroke="#22d3ee" strokeWidth={1} strokeDasharray="4 2" strokeOpacity={0.6}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Inline Input Overlay */}
|
||||
{inlineInput && (() => {
|
||||
const pt = pts.find(p => p.index === inlineInput.pointIndex);
|
||||
if (!pt) return null;
|
||||
const cx = scalesRef.current.xScale(pt.volt_mv);
|
||||
const currentDelta = pendingDeltas.has(pt.index) ? pendingDeltas.get(pt.index)! : pt.delta_khz;
|
||||
const deltaChange = currentDelta - pt.delta_khz;
|
||||
const cy = scalesRef.current.yScale(pt.freq_mhz + deltaChange / 1000);
|
||||
const pos = svgToContainer(cx, cy);
|
||||
if (!pos) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: pos.x - 40,
|
||||
top: pos.y - 12,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
type="number"
|
||||
step="1"
|
||||
value={inlineInput.value}
|
||||
onChange={(e) => setInlineInput({ ...inlineInput, value: e.target.value })}
|
||||
onBlur={() => setInlineInput(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const mhz = parseFloat(inlineInput.value);
|
||||
if (!isNaN(mhz) && isFinite(mhz)) {
|
||||
const clamped = Math.max(-500_000, Math.min(500_000, Math.round(mhz * 1000)));
|
||||
stageEdit(pt.index, clamped);
|
||||
}
|
||||
setInlineInput(null);
|
||||
// Refocus the container
|
||||
containerRef.current?.focus();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setInlineInput(null);
|
||||
containerRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
className="w-20 bg-zinc-800 text-cyan-300 rounded px-1.5 py-0.5 border-2 border-cyan-500 shadow-lg outline-none text-xs font-mono text-center"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Hover / Active tooltip */}
|
||||
{(() => {
|
||||
if (dragInfo) return null;
|
||||
let tooltipPt = hoveredPoint;
|
||||
if (!tooltipPt && selectedPoints.size === 1) {
|
||||
tooltipPt = pts.find(p => p.index === Array.from(selectedPoints)[0]) || null;
|
||||
}
|
||||
if (!tooltipPt) return null;
|
||||
|
||||
return (
|
||||
<CurveTooltip
|
||||
point={tooltipPt}
|
||||
pendingDeltaKhz={pendingDeltas.get(tooltipPt.index)}
|
||||
isClamped={clampedPoints.has(tooltipPt.index)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Drag tooltip — shown while actively dragging */}
|
||||
{dragInfo && (() => {
|
||||
const point = pts.find((p) => p.index === dragInfo.pointIndex);
|
||||
if (!point) return null;
|
||||
const deltaMhz = dragInfo.currentDeltaKhz / 1000;
|
||||
const deltaChange = dragInfo.currentDeltaKhz - point.delta_khz;
|
||||
const effMhz = point.freq_mhz + deltaChange / 1000;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 50,
|
||||
width: 164,
|
||||
}}
|
||||
className="bg-zinc-800 border border-cyan-500/50 rounded-md p-2 text-xs shadow-xl"
|
||||
>
|
||||
<div className="text-zinc-400 mb-1 flex items-center gap-1">
|
||||
<span className="text-cyan-400">↕</span> Point {point.index}
|
||||
<span className="ml-auto text-zinc-500">{point.volt_mv.toFixed(0)} mV</span>
|
||||
</div>
|
||||
<div className={`font-semibold ${deltaMhz > 0 ? 'text-cyan-400' : deltaMhz < 0 ? 'text-orange-400' : 'text-zinc-400'}`}>
|
||||
Δ {deltaMhz > 0 ? '+' : ''}{deltaMhz.toFixed(1)} MHz
|
||||
</div>
|
||||
<div className="text-cyan-200 font-semibold">
|
||||
→ {effMhz.toFixed(0)} MHz
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { ZoomIn, RotateCcw, Minus } from 'lucide-react';
|
||||
import { useCurveStore } from '../../store/curveStore';
|
||||
import type { VFPoint } from '../../types';
|
||||
|
||||
interface Props {
|
||||
/** All curve points — used by global offset slider */
|
||||
activePts: VFPoint[];
|
||||
/** Called to reset the x-axis zoom to default */
|
||||
onResetZoom: () => void;
|
||||
/** True if the chart is currently zoomed in */
|
||||
isZoomed: boolean;
|
||||
/** True when viewing a read-only domain (memory) */
|
||||
readOnly?: boolean;
|
||||
/** Current zoom factor (1 = no zoom) */
|
||||
zoomFactor: number;
|
||||
/** Called when user changes zoom via slider */
|
||||
onZoomChange: (factor: number) => void;
|
||||
}
|
||||
|
||||
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);
|
||||
return uniform ? firstD / 1000 : null;
|
||||
}, [activePts, pendingDeltas]);
|
||||
|
||||
useEffect(() => {
|
||||
if (uniformDeltaMhz !== null) {
|
||||
setOffsetMhz(uniformDeltaMhz);
|
||||
}
|
||||
}, [uniformDeltaMhz]);
|
||||
|
||||
function handleOffsetChange(mhz: number) {
|
||||
setOffsetMhz(mhz);
|
||||
stageRangeEdit(activePts, Math.round(mhz * 1000));
|
||||
}
|
||||
|
||||
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)">
|
||||
<ZoomIn size={11} className="text-zinc-500 shrink-0" />
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={zoomFactor}
|
||||
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'}`}>
|
||||
{zoomFactor.toFixed(1)}×
|
||||
</span>
|
||||
{isZoomed && (
|
||||
<button
|
||||
onClick={onResetZoom}
|
||||
title="Reset zoom"
|
||||
className="text-zinc-500 hover:text-zinc-300 transition-colors ml-0.5"
|
||||
>
|
||||
<RotateCcw size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<span className="w-px h-6 bg-zinc-800 mx-1" />
|
||||
|
||||
{/* 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>
|
||||
<input
|
||||
type="range"
|
||||
min={-1000}
|
||||
max={1000}
|
||||
step={5}
|
||||
value={offsetMhz}
|
||||
onChange={(e) => handleOffsetChange(Number(e.target.value))}
|
||||
className="w-32 accent-cyan-400"
|
||||
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(' ')}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { fmt } from '../../utils/units';
|
||||
import type { VFPoint } from '../../types';
|
||||
|
||||
interface Props {
|
||||
point: VFPoint;
|
||||
/** Pending delta in kHz (from store), if any */
|
||||
pendingDeltaKhz?: number;
|
||||
/** True if this point is being held up by monotonicity enforcement */
|
||||
isClamped?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML tooltip rendered as an absolutely-positioned overlay on top of the
|
||||
* SVG so it's never clipped by the SVG viewport.
|
||||
*/
|
||||
export function CurveTooltip({ point, pendingDeltaKhz, isClamped }: Props) {
|
||||
const hasPending = pendingDeltaKhz !== undefined;
|
||||
const pendingMhz = hasPending ? pendingDeltaKhz! / 1000 : 0;
|
||||
const deltaChange = hasPending ? pendingDeltaKhz! - point.delta_khz : 0;
|
||||
const pendingEffMhz = hasPending ? point.freq_mhz + deltaChange / 1000 : null;
|
||||
|
||||
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"
|
||||
>
|
||||
<div className="text-zinc-400 mb-1">Point {point.index}</div>
|
||||
<div className="text-zinc-200">
|
||||
<span className="text-zinc-400">Volt: </span>{fmt.mv(point.volt_mv, 1)}
|
||||
</div>
|
||||
<div className="text-zinc-200">
|
||||
<span className="text-zinc-400">Offset: </span>
|
||||
<span className={point.delta_khz > 0 ? 'text-emerald-400' : point.delta_khz < 0 ? 'text-red-400' : 'text-zinc-400'}>
|
||||
{point.delta_khz > 0 ? '+' : ''}{fmt.mhz(point.delta_mhz, 1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-emerald-300 font-semibold">
|
||||
<span className="text-zinc-400">Eff.: </span>{fmt.mhz(point.freq_mhz, 0)}
|
||||
{isClamped && <span className="text-amber-500 ml-1">⇡</span>}
|
||||
</div>
|
||||
{isClamped && (
|
||||
<div className="text-amber-500/80 text-[10px] mt-0.5">
|
||||
Clamped by lower-voltage point
|
||||
</div>
|
||||
)}
|
||||
{hasPending && (
|
||||
<>
|
||||
<div className="border-t border-zinc-700 mt-1.5 pt-1.5">
|
||||
<div className="text-zinc-200">
|
||||
<span className="text-zinc-400">Pending: </span>
|
||||
<span className={pendingMhz > 0 ? 'text-cyan-400' : pendingMhz < 0 ? 'text-orange-400' : 'text-zinc-400'}>
|
||||
{pendingMhz > 0 ? '+' : ''}{pendingMhz.toFixed(1)} MHz
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-cyan-300 font-semibold">
|
||||
<span className="text-zinc-400">→ Eff.: </span>{fmt.mhz(pendingEffMhz, 0)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
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';
|
||||
|
||||
type Pending = Pick<LimitsState, 'power_limit_w' | 'mem_offset_mhz'>;
|
||||
|
||||
export function PerformancePanel() {
|
||||
const { selectedGpuIndex } = useCurveStore();
|
||||
const [limits, setLimits] = useState<LimitsState | null>(null);
|
||||
const [pending, setPending] = useState<Partial<Pending>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmApply, setConfirmApply] = useState(false);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
|
||||
async function fetchLimits() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setLimits(await api.limits(selectedGpuIndex));
|
||||
} catch {
|
||||
toast.error('Failed to load performance limits');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchLimits(); }, [selectedGpuIndex]);
|
||||
|
||||
async function handleApply() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.updateLimits(pending, selectedGpuIndex);
|
||||
useCurveStore.getState().setActiveProfile(null);
|
||||
setPending({});
|
||||
setConfirmApply(false);
|
||||
await fetchLimits();
|
||||
toast.success('Performance limits applied');
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? String(e));
|
||||
setConfirmApply(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.resetLimits(selectedGpuIndex);
|
||||
useCurveStore.getState().setActiveProfile(null);
|
||||
setPending({});
|
||||
setConfirmReset(false);
|
||||
await fetchLimits();
|
||||
toast.success('Performance limits reset to defaults');
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? String(e));
|
||||
setConfirmReset(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !limits) {
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col animate-pulse">
|
||||
<div className="px-3 py-2 border-b border-zinc-800 h-9" />
|
||||
<div className="p-4 flex flex-col gap-6">
|
||||
<div className="h-12 bg-zinc-800 rounded" />
|
||||
<div className="h-12 bg-zinc-800 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!limits) return null;
|
||||
|
||||
const hasPending = Object.keys(pending).length > 0;
|
||||
|
||||
// Power
|
||||
const pwrVal = pending.power_limit_w ?? limits.power_limit_w ?? 0;
|
||||
const pwrMin = limits.min_power_limit_w ?? 100;
|
||||
const pwrMax = limits.max_power_limit_w ?? 600;
|
||||
|
||||
// Memory offset
|
||||
const memVal = pending.mem_offset_mhz ?? limits.mem_offset_mhz ?? 0;
|
||||
const memMin = limits.min_mem_offset_mhz ?? -2000;
|
||||
const memMax = Math.min(limits.max_mem_offset_mhz ?? 3000, 3000);
|
||||
|
||||
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>
|
||||
|
||||
{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">
|
||||
{Object.keys(pending).length} pending
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setConfirmApply(true)}
|
||||
disabled={!hasPending || busy}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded bg-emerald-700 hover:bg-emerald-600 text-white text-xs font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Check size={12} />
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<X size={12} />
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmReset(true)}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-red-900 text-zinc-500 hover:text-red-300 text-xs transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RotateCcw size={11} />
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Error banner ────────────────────────────────────────────────── */}
|
||||
{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>
|
||||
</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>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="number"
|
||||
min={pwrMin}
|
||||
max={pwrMax}
|
||||
value={pwrVal}
|
||||
onChange={e => {
|
||||
const v = parseInt(e.target.value);
|
||||
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"
|
||||
/>
|
||||
<span className="text-xs text-zinc-600 w-3">W</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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) }))}
|
||||
className="flex-1 accent-cyan-400 h-1 cursor-pointer"
|
||||
/>
|
||||
<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>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="number"
|
||||
min={memMin}
|
||||
max={memMax}
|
||||
value={memVal}
|
||||
onChange={e => {
|
||||
const v = parseInt(e.target.value);
|
||||
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"
|
||||
/>
|
||||
<span className="text-xs text-zinc-600 w-6">MHz</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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) }))}
|
||||
className="flex-1 accent-cyan-400 h-1 cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs text-zinc-600 font-mono w-10">+{memMax}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{confirmApply && (
|
||||
<ConfirmDialog
|
||||
message="Apply performance limits to hardware?"
|
||||
detail="Power limit and memory clock offset take effect immediately."
|
||||
confirmLabel="Apply"
|
||||
onConfirm={handleApply}
|
||||
onCancel={() => setConfirmApply(false)}
|
||||
/>
|
||||
)}
|
||||
{confirmReset && (
|
||||
<ConfirmDialog
|
||||
message="Reset performance limits to defaults?"
|
||||
detail="Power limit will be restored to the hardware default and memory clock offset set to 0."
|
||||
confirmLabel="Reset"
|
||||
isDestructive
|
||||
onConfirm={handleReset}
|
||||
onCancel={() => setConfirmReset(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
interface Props {
|
||||
label: string;
|
||||
value: string;
|
||||
history: number[];
|
||||
unit?: string;
|
||||
color?: string; // tailwind color class for the sparkline stroke
|
||||
max?: number;
|
||||
}
|
||||
|
||||
const SPARKLINE_H = 32;
|
||||
const SPARKLINE_W = 120;
|
||||
|
||||
function Sparkline({ data, color = '#a78bfa', max }: { data: number[]; color?: string; max?: number }) {
|
||||
const points = data.length >= 2 ? (() => {
|
||||
const dataMax = max ?? Math.max(...data, 1);
|
||||
const dataMin = Math.min(...data, 0);
|
||||
const range = dataMax - dataMin || 1;
|
||||
return data.map((v, i) => {
|
||||
const x = (i / (data.length - 1)) * SPARKLINE_W;
|
||||
const y = SPARKLINE_H - ((v - dataMin) / range) * SPARKLINE_H;
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
})() : null;
|
||||
|
||||
return (
|
||||
<svg width="100%" height={SPARKLINE_H} viewBox={`0 0 ${SPARKLINE_W} ${SPARKLINE_H}`} preserveAspectRatio="none" className="overflow-visible">
|
||||
{points && (
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
opacity="0.8"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GaugeCard({ label, value, history, color = '#a78bfa', max }: Props) {
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-1 min-w-0">
|
||||
<div className="text-xs text-zinc-500 uppercase tracking-wider">{label}</div>
|
||||
<div className="text-xl font-mono font-semibold text-zinc-100">{value}</div>
|
||||
<div className="mt-1 w-full">
|
||||
<Sparkline data={history} color={color} max={max} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { GaugeCard } from './GaugeCard';
|
||||
import { fmt } from '../../utils/units';
|
||||
import type { MonitoringSample } from '../../types';
|
||||
|
||||
interface Props {
|
||||
monitor: MonitoringSample | null;
|
||||
history: MonitoringSample[];
|
||||
}
|
||||
|
||||
function pluck<K extends keyof MonitoringSample>(
|
||||
history: MonitoringSample[],
|
||||
key: K,
|
||||
): number[] {
|
||||
return history.map((s) => (s[key] as number | null) ?? 0);
|
||||
}
|
||||
|
||||
export function LiveMonitor({ monitor, history }: Props) {
|
||||
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>
|
||||
</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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { GaugeCard } from './GaugeCard';
|
||||
import { fmt } from '../../utils/units';
|
||||
import type { MonitoringSample } from '../../types';
|
||||
|
||||
interface Props {
|
||||
monitor: MonitoringSample | null;
|
||||
history: MonitoringSample[];
|
||||
}
|
||||
|
||||
function pluck<K extends keyof MonitoringSample>(
|
||||
history: MonitoringSample[],
|
||||
key: K,
|
||||
): number[] {
|
||||
return history.map((s) => (s[key] as number | null) ?? 0);
|
||||
}
|
||||
|
||||
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`
|
||||
: '—';
|
||||
|
||||
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>
|
||||
</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')}
|
||||
color="#67e8f9"
|
||||
max={20000}
|
||||
/>
|
||||
<GaugeCard
|
||||
label="Power Draw"
|
||||
value={fmt.watts(monitor?.power_w)}
|
||||
history={pluck(history, 'power_w')}
|
||||
color="#f472b6"
|
||||
max={600}
|
||||
/>
|
||||
<GaugeCard
|
||||
label="VRAM Used"
|
||||
value={memLabel}
|
||||
history={pluck(history, 'mem_used_mib')}
|
||||
color="#a78bfa"
|
||||
max={memTotal ?? 32768}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Cpu, Wifi, WifiOff, Loader, Terminal, ChevronDown } from 'lucide-react';
|
||||
import type { GpuInfo, MonitoringSample } from '../../types';
|
||||
import { fmt } from '../../utils/units';
|
||||
import { useCurveStore } from '../../store/curveStore';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
gpuInfo: GpuInfo | null;
|
||||
wsStatus: 'connecting' | 'connected' | 'disconnected';
|
||||
monitor: MonitoringSample | null;
|
||||
}
|
||||
|
||||
const statusIcon = {
|
||||
connected: <Wifi size={14} className="text-emerald-400" />,
|
||||
connecting: <Loader size={14} className="text-yellow-400 animate-spin" />,
|
||||
disconnected: <WifiOff size={14} className="text-red-400" />,
|
||||
};
|
||||
|
||||
const statusText = {
|
||||
connected: 'Connected',
|
||||
connecting: 'Connecting…',
|
||||
disconnected: 'Disconnected',
|
||||
};
|
||||
|
||||
export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) {
|
||||
const { availableGpus, selectedGpuIndex, setSelectedGpuIndex } = useCurveStore();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="bg-zinc-900 border-b border-zinc-800 text-sm">
|
||||
<div className="flex items-center gap-4 px-4 py-2 w-full max-w-[1600px] mx-auto min-w-0">
|
||||
{/* Left: GPU name */}
|
||||
<div className="flex items-center gap-2 min-w-0 shrink-0">
|
||||
<img src="/logo.svg" alt="NVCurve" className="w-5 h-5 shrink-0" />
|
||||
<span className="font-bold text-transparent bg-clip-text bg-gradient-to-r from-violet-400 to-fuchsia-400 tracking-tight mr-1">
|
||||
NVCurve
|
||||
</span>
|
||||
<div className="w-px h-4 bg-zinc-700 mx-1 hidden sm:block"></div>
|
||||
|
||||
{availableGpus.length > 1 ? (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-1.5 px-2 py-1 -ml-2 rounded hover:bg-zinc-800 transition-colors group"
|
||||
>
|
||||
<Cpu size={14} className="text-zinc-400 group-hover:text-zinc-300 transition-colors" />
|
||||
<span className="font-medium text-zinc-200 truncate group-hover:text-white transition-colors">
|
||||
{gpuInfo?.name ?? 'No Device'}
|
||||
</span>
|
||||
<ChevronDown size={14} className={`text-zinc-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 w-64 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl overflow-hidden z-50 py-1">
|
||||
{availableGpus.map((gpu) => (
|
||||
<button
|
||||
key={gpu.index}
|
||||
onClick={() => {
|
||||
setSelectedGpuIndex(gpu.index);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm flex items-center gap-2 hover:bg-zinc-700 transition-colors ${
|
||||
gpu.index === selectedGpuIndex ? 'text-cyan-400 font-medium bg-zinc-700/50' : 'text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<Cpu size={14} className={gpu.index === selectedGpuIndex ? 'text-cyan-400' : 'text-zinc-500'} />
|
||||
<span className="truncate flex-1">{gpu.name}</span>
|
||||
<span className="text-xs text-zinc-500 font-mono">GPU {gpu.index}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Cpu size={16} className="shrink-0 text-zinc-400 ml-1 hidden sm:block" />
|
||||
<span className="font-medium text-zinc-200 truncate">
|
||||
{gpuInfo?.name ?? 'No Device'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{gpuInfo?.driver_version && (
|
||||
<div className="flex items-center gap-1.5 text-zinc-400 shrink-0 hidden sm:flex bg-zinc-800/80 border border-zinc-700/50 px-2 py-0.5 rounded text-xs ml-2">
|
||||
<Terminal size={12} className="text-zinc-500" />
|
||||
<span>{gpuInfo.driver_version}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Centre: live stats */}
|
||||
<div className="flex-1 flex items-center justify-center gap-5 min-w-0 hidden lg:flex">
|
||||
<StatPill label="Temp" value={fmt.celsius(monitor?.temp_c)} color="text-orange-400" />
|
||||
<StatPill label="Power" value={fmt.watts(monitor?.power_w)} color="text-pink-400" />
|
||||
<StatPill label="Fan" value={fmt.pct(monitor?.fan_pct)} color="text-blue-400" />
|
||||
<StatPill label="GPU Util" value={fmt.pct(monitor?.gpu_util_pct)} color="text-yellow-400" />
|
||||
<StatPill label="Mem Util" value={fmt.pct(monitor?.mem_util_pct)} color="text-sky-400" />
|
||||
</div>
|
||||
|
||||
{/* Right: connection status */}
|
||||
<div className="flex items-center gap-1.5 text-zinc-400 shrink-0">
|
||||
{statusIcon[wsStatus]}
|
||||
<span className="hidden sm:inline">{statusText[wsStatus]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function StatPill({ label, value, color }: { label: string; value: string; color: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-zinc-500 text-xs">{label}</span>
|
||||
<span className={`font-mono font-semibold text-sm ${color}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { fmt } from '../../utils/units';
|
||||
import type { VFPoint } from '../../types';
|
||||
import { useCurveStore } from '../../store/curveStore';
|
||||
|
||||
interface Props {
|
||||
point: VFPoint;
|
||||
isCurrent: boolean;
|
||||
isSelected: boolean;
|
||||
isClamped: boolean;
|
||||
pendingDeltaKhz?: number;
|
||||
shouldAutoScroll?: boolean;
|
||||
onMouseDown?: (e: React.MouseEvent) => void;
|
||||
onMouseEnter?: () => void;
|
||||
}
|
||||
|
||||
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 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' });
|
||||
}
|
||||
}, [shouldAutoScroll]);
|
||||
|
||||
const hasPending = pendingDeltaKhz !== undefined;
|
||||
const displayDeltaMhz = hasPending ? pendingDeltaKhz / 1000 : point.delta_mhz;
|
||||
const displayDeltaKhz = hasPending ? pendingDeltaKhz : point.delta_khz;
|
||||
// Effective = current effective + delta change
|
||||
const deltaChange = hasPending ? pendingDeltaKhz - point.delta_khz : 0;
|
||||
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';
|
||||
|
||||
function startEdit() {
|
||||
setInputValue((displayDeltaMhz).toFixed(1));
|
||||
setEditing(true);
|
||||
setTimeout(() => {
|
||||
inputRef.current?.select();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function commitEdit() {
|
||||
const mhz = parseFloat(inputValue);
|
||||
if (!isNaN(mhz) && isFinite(mhz)) {
|
||||
const clamped = Math.max(-1000, Math.min(1000, mhz));
|
||||
stageEdit(point.index, Math.round(clamped * 1000));
|
||||
}
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<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(' ')}
|
||||
onMouseDown={(e) => {
|
||||
if (editing) return;
|
||||
onMouseDown?.(e);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (editing) return;
|
||||
onMouseEnter?.();
|
||||
}}
|
||||
>
|
||||
<td className="px-3 py-1 text-zinc-500">{point.index}</td>
|
||||
<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(); }}>
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
step="1"
|
||||
value={inputValue}
|
||||
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();
|
||||
}}
|
||||
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' }}
|
||||
/>
|
||||
) : (
|
||||
<span title="Click to edit">
|
||||
{hasPending && <span className="text-cyan-500 mr-0.5">✎</span>}
|
||||
{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'}`}>
|
||||
{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>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="px-3 py-1">
|
||||
{isCurrent && <span className="text-yellow-400">◀ now</span>}
|
||||
{isSelected && !isCurrent && <span className="text-cyan-500">●</span>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
points: VFPoint[];
|
||||
currentVoltageMv: number | null;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
|
||||
const { pendingDeltas, selectedPoints, selectPoint, 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);
|
||||
}, []);
|
||||
|
||||
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>
|
||||
{!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
|
||||
</span>
|
||||
)}
|
||||
{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>
|
||||
{!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);
|
||||
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"
|
||||
>
|
||||
Select Before
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const idx = Array.from(selectedPoints)[0];
|
||||
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"
|
||||
>
|
||||
Select After
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[32rem] select-none">
|
||||
<table className="w-full">
|
||||
<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" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{points.map((p) => (
|
||||
<PointRow
|
||||
key={p.index}
|
||||
point={p}
|
||||
isCurrent={currentPoint?.index === p.index}
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Save, Trash2, Check, ChevronRight, Pencil, Star } from 'lucide-react';
|
||||
import { api } from '../../api/client';
|
||||
import type { ProfileData } from '../../types';
|
||||
import { toast } from 'sonner';
|
||||
import { useCurveStore } from '../../store/curveStore';
|
||||
|
||||
interface ProfilePanelProps {
|
||||
activeProfile: string | null;
|
||||
onProfileApplied: (name: string | null) => void;
|
||||
}
|
||||
|
||||
export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelProps) {
|
||||
const { selectedGpuIndex, gpuInfo } = useCurveStore();
|
||||
const [profiles, setProfiles] = useState<ProfileData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [autoLoadProfile, setAutoLoadProfile] = useState<string | null>(null);
|
||||
|
||||
// Save form
|
||||
const [isSaveOpen, setIsSaveOpen] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const saveInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Inline delete confirmation
|
||||
const [deletingName, setDeletingName] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Inline rename
|
||||
const [renamingName, setRenamingName] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Apply in-flight
|
||||
const [applyingName, setApplyingName] = useState<string | null>(null);
|
||||
|
||||
async function fetchProfiles() {
|
||||
try {
|
||||
const data = await api.profiles(selectedGpuIndex);
|
||||
setProfiles(data.profiles);
|
||||
onProfileApplied(data.active);
|
||||
setAutoLoadProfile(data.auto_load);
|
||||
} catch {
|
||||
toast.error('Failed to load profiles');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetAutoLoad(name: string | null) {
|
||||
try {
|
||||
await api.setAutoLoadProfile(name, selectedGpuIndex);
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchProfiles(); }, [selectedGpuIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSaveOpen) saveInputRef.current?.focus();
|
||||
else setNewName('');
|
||||
}, [isSaveOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (renamingName) renameInputRef.current?.focus();
|
||||
}, [renamingName]);
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newName.trim()) return;
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await api.saveProfile(newName.trim(), selectedGpuIndex);
|
||||
toast.success(`Profile "${newName.trim()}" saved`);
|
||||
setIsSaveOpen(false);
|
||||
await fetchProfiles();
|
||||
} catch (e: any) {
|
||||
toast.error('Failed to save: ' + (e.message || String(e)));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApply(name: string) {
|
||||
try {
|
||||
setApplyingName(name);
|
||||
await api.applyProfile(name, selectedGpuIndex);
|
||||
onProfileApplied(name);
|
||||
toast.success(`"${name}" applied`);
|
||||
} catch (e: any) {
|
||||
toast.error(`Failed to apply "${name}": ` + (e.message || String(e)));
|
||||
} finally {
|
||||
setApplyingName(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteConfirm(name: string) {
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await api.deleteProfile(name);
|
||||
toast.success(`"${name}" deleted`);
|
||||
if (activeProfile === name) onProfileApplied(null);
|
||||
if (autoLoadProfile === name) setAutoLoadProfile(null);
|
||||
setDeletingName(null);
|
||||
await fetchProfiles();
|
||||
} catch (e: any) {
|
||||
toast.error('Failed to delete: ' + (e.message || String(e)));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRename(e: React.FormEvent, oldName: string) {
|
||||
e.preventDefault();
|
||||
if (!renameValue.trim() || renameValue.trim() === oldName) {
|
||||
setRenamingName(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsRenaming(true);
|
||||
await api.renameProfile(oldName, renameValue.trim());
|
||||
toast.success(`Renamed to "${renameValue.trim()}"`);
|
||||
if (activeProfile === oldName) onProfileApplied(renameValue.trim());
|
||||
if (autoLoadProfile === oldName) setAutoLoadProfile(renameValue.trim());
|
||||
setRenamingName(null);
|
||||
await fetchProfiles();
|
||||
} catch (e: any) {
|
||||
toast.error('Failed to rename: ' + (e.message || String(e)));
|
||||
} finally {
|
||||
setIsRenaming(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && profiles.length === 0) {
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-lg p-6 flex flex-col gap-4 animate-pulse">
|
||||
<div className="h-6 w-32 bg-zinc-800 rounded" />
|
||||
<div className="h-20 w-full bg-zinc-800 rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-zinc-800 bg-zinc-950/50 flex items-center gap-2">
|
||||
<Save size={16} className="text-pink-500" />
|
||||
<h2 className="text-zinc-100 font-medium">Profiles</h2>
|
||||
</div>
|
||||
|
||||
{/* Save section */}
|
||||
<div className="px-4 py-3 border-b border-zinc-800">
|
||||
{isSaveOpen ? (
|
||||
<form onSubmit={handleSave} className="flex gap-2">
|
||||
<input
|
||||
ref={saveInputRef}
|
||||
type="text"
|
||||
placeholder="Profile name..."
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
disabled={isSaving}
|
||||
onKeyDown={e => e.key === 'Escape' && setIsSaveOpen(false)}
|
||||
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
|
||||
type="submit"
|
||||
disabled={!newName.trim() || isSaving}
|
||||
className="px-3 py-1.5 bg-pink-600 hover:bg-pink-500 disabled:bg-zinc-800 disabled:text-zinc-500 text-white rounded text-sm font-medium transition shrink-0"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSaveOpen(false)}
|
||||
className="px-3 py-1.5 text-zinc-400 hover:text-zinc-200 rounded text-sm transition shrink-0"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsSaveOpen(true)}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-1.5 rounded text-sm font-medium text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 transition border border-zinc-800 hover:border-zinc-700"
|
||||
>
|
||||
<Save size={14} />
|
||||
Save Current State
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Profile list */}
|
||||
<div className="overflow-y-auto max-h-72 py-2 scrollbar-thin scrollbar-thumb-zinc-700 scrollbar-track-transparent">
|
||||
{profiles.length === 0 ? (
|
||||
<p className="text-center text-zinc-500 text-sm py-8 px-4">
|
||||
No profiles saved yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5 px-2">
|
||||
{profiles.map(p => {
|
||||
const pts = Object.keys(p.curve_deltas).length;
|
||||
const badges = [
|
||||
pts > 0 ? `${pts} pts` : null,
|
||||
p.power_limit_w != null ? `${p.power_limit_w}W` : null,
|
||||
p.mem_offset_mhz != null ? `${p.mem_offset_mhz > 0 ? '+' : ''}${p.mem_offset_mhz} MHz mem` : null,
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
const isActive = activeProfile === p.name;
|
||||
const isAutoLoad = autoLoadProfile === p.name;
|
||||
const isApplying = applyingName === p.name;
|
||||
const isConfirmingDelete = deletingName === p.name;
|
||||
const isRenaming_ = renamingName === p.name;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={p.name}
|
||||
className={`group rounded-md transition ${isActive ? 'bg-zinc-800/60' : 'hover:bg-zinc-800/50'}`}
|
||||
>
|
||||
{isConfirmingDelete ? (
|
||||
<div className="flex items-center justify-between px-3 py-2 gap-2">
|
||||
<span className="text-sm text-zinc-300 truncate min-w-0">Delete "{p.name}"?</span>
|
||||
<div className="flex gap-1.5 shrink-0">
|
||||
<button
|
||||
onClick={() => handleDeleteConfirm(p.name)}
|
||||
disabled={isDeleting}
|
||||
className="px-2 py-1 text-xs rounded bg-red-600 hover:bg-red-500 text-white font-medium transition disabled:opacity-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingName(null)}
|
||||
className="px-2 py-1 text-xs rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : isRenaming_ ? (
|
||||
<form onSubmit={e => handleRename(e, p.name)} className="flex items-center gap-2 px-3 py-2">
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={e => setRenameValue(e.target.value)}
|
||||
disabled={isRenaming}
|
||||
onKeyDown={e => e.key === 'Escape' && setRenamingName(null)}
|
||||
className="flex-1 min-w-0 bg-zinc-950 border border-zinc-600 rounded px-2 py-1 text-sm focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!renameValue.trim() || isRenaming}
|
||||
className="px-2 py-1 text-xs rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-200 font-medium transition disabled:opacity-50 shrink-0"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenamingName(null)}
|
||||
className="px-2 py-1 text-xs rounded text-zinc-500 hover:text-zinc-300 transition shrink-0"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<div className="flex items-center gap-2 min-w-0 pr-2">
|
||||
{isActive
|
||||
? <Check size={13} className="text-emerald-400 shrink-0" />
|
||||
: <span className="w-[13px] shrink-0" />
|
||||
}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className={`text-sm font-medium truncate ${isActive ? 'text-zinc-100' : 'text-zinc-300'}`}>
|
||||
{p.name}
|
||||
</p>
|
||||
{isAutoLoad && (
|
||||
<Star size={11} className="text-sky-400 shrink-0" fill="currentColor" />
|
||||
)}
|
||||
{gpuInfo && p.gpu_name !== gpuInfo.name && (
|
||||
<span className="text-xs text-zinc-600 truncate shrink-0" title={`Saved from ${p.gpu_name}`}>
|
||||
{p.gpu_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{badges && <p className="text-xs text-zinc-500">{badges}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 shrink-0 opacity-100 lg:opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleApply(p.name)}
|
||||
disabled={isApplying}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs rounded text-zinc-400 hover:text-emerald-400 hover:bg-zinc-700 transition disabled:opacity-50 font-medium"
|
||||
>
|
||||
{isApplying
|
||||
? <span className="w-3 h-3 border border-zinc-500 border-t-emerald-400 rounded-full animate-spin" />
|
||||
: <ChevronRight size={13} />
|
||||
}
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSetAutoLoad(isAutoLoad ? null : p.name)}
|
||||
className={`p-1.5 rounded transition hover:bg-zinc-700 ${isAutoLoad ? 'text-sky-400 hover:text-sky-300' : 'text-zinc-600 hover:text-sky-400'}`}
|
||||
title={isAutoLoad ? 'Clear default profile' : 'Set as default profile'}
|
||||
>
|
||||
<Star size={13} fill={isAutoLoad ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setRenamingName(p.name); setRenameValue(p.name); }}
|
||||
className="p-1.5 text-zinc-600 hover:text-zinc-300 hover:bg-zinc-700 rounded transition"
|
||||
title="Rename"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingName(p.name)}
|
||||
className="p-1.5 text-zinc-600 hover:text-red-400 hover:bg-zinc-700 rounded transition"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
interface Props {
|
||||
message: string;
|
||||
detail?: string;
|
||||
confirmLabel?: string;
|
||||
isDestructive?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
message,
|
||||
detail,
|
||||
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(); }}
|
||||
>
|
||||
<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>
|
||||
{detail && <p className="text-zinc-400 text-xs mb-4">{detail}</p>}
|
||||
{!detail && <div className="mb-4" />}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-3 py-1.5 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={[
|
||||
'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(' ')}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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';
|
||||
|
||||
export function useCurve() {
|
||||
const { curve, setCurve, selectedGpuIndex } = useCurveStore();
|
||||
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
|
||||
const wsRef = useRef<ReturnType<typeof createWsConnection> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial fetch
|
||||
api.curve(selectedGpuIndex).then(setCurve).catch(console.error);
|
||||
|
||||
// Subscribe to /ws/curve for push updates after writes
|
||||
wsRef.current = createWsConnection<CurveState>(
|
||||
'/ws/curve',
|
||||
(data) => setCurve(data),
|
||||
setWsStatus,
|
||||
selectedGpuIndex,
|
||||
);
|
||||
|
||||
return () => wsRef.current?.close();
|
||||
}, [setCurve, selectedGpuIndex]);
|
||||
|
||||
return { curve, wsStatus };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { useCurveStore } from '../store/curveStore';
|
||||
|
||||
export function useGpu() {
|
||||
const { gpuInfo, setGpuInfo, setAvailableGpus, selectedGpuIndex } = useCurveStore();
|
||||
|
||||
useEffect(() => {
|
||||
api.gpus().then(setAvailableGpus).catch(console.error);
|
||||
}, [setAvailableGpus]);
|
||||
|
||||
useEffect(() => {
|
||||
api.gpu(selectedGpuIndex).then(setGpuInfo).catch(console.error);
|
||||
}, [setGpuInfo, selectedGpuIndex]);
|
||||
|
||||
return gpuInfo;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createWsConnection } from '../api/websocket';
|
||||
import { useCurveStore } from '../store/curveStore';
|
||||
import type { MonitoringSample } from '../types';
|
||||
|
||||
export function useMonitor() {
|
||||
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',
|
||||
pushMonitor,
|
||||
setWsStatus,
|
||||
selectedGpuIndex,
|
||||
);
|
||||
return () => wsRef.current?.close();
|
||||
}, [pushMonitor, selectedGpuIndex]);
|
||||
|
||||
return { monitor, monitorHistory, wsStatus };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #09090b; /* zinc-950 */
|
||||
color: #f4f4f5; /* zinc-100 */
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Tailwind v4 dark mode via media (fallback) */
|
||||
@media (prefers-color-scheme: light) {
|
||||
body {
|
||||
background-color: #09090b;
|
||||
color: #f4f4f5;
|
||||
}
|
||||
}
|
||||
|
||||
/* scrollbar styling for dark theme */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #18181b;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #3f3f46;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #52525b;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,249 @@
|
||||
import { create } from 'zustand';
|
||||
import type { CurveState, GpuInfo, MonitoringSample, VFPoint } from '../types';
|
||||
import { api } from '../api/client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const HISTORY_SIZE = 120; // ~60s at 2Hz
|
||||
|
||||
interface CurveStore {
|
||||
// Hardware state (from API)
|
||||
availableGpus: GpuInfo[];
|
||||
selectedGpuIndex: number;
|
||||
curve: CurveState | null;
|
||||
gpuInfo: GpuInfo | null;
|
||||
monitor: MonitoringSample | null;
|
||||
monitorHistory: MonitoringSample[];
|
||||
|
||||
activeProfile: string | null;
|
||||
|
||||
// Phase 4 — edit state
|
||||
/** point index → pending delta in kHz (overrides curve.points[i].delta_khz for display) */
|
||||
pendingDeltas: Map<number, number>;
|
||||
selectedPoints: Set<number>;
|
||||
/**
|
||||
* The anchor point for multi-point operations (e.g. flatten).
|
||||
* Set to the last explicitly clicked point. Bulk selects (box, range, Ctrl+A)
|
||||
* leave it unchanged; clearSelection resets it to null.
|
||||
* If null or not in selectedPoints, operations fall back to the lowest selected index.
|
||||
*/
|
||||
anchorPoint: number | null;
|
||||
|
||||
// Hardware state setters
|
||||
setAvailableGpus: (gpus: GpuInfo[]) => void;
|
||||
setSelectedGpuIndex: (index: number) => void;
|
||||
setCurve: (c: CurveState) => void;
|
||||
setGpuInfo: (g: GpuInfo) => void;
|
||||
pushMonitor: (s: MonitoringSample) => void;
|
||||
setActiveProfile: (name: string | null) => void;
|
||||
|
||||
// Edit actions
|
||||
stageEdit: (pointIndex: number, deltaKhz: number) => void;
|
||||
stageMultiEdit: (edits: Map<number, number>) => void;
|
||||
stageRangeEdit: (points: VFPoint[], deltaKhz: number) => void;
|
||||
discardEdits: () => void;
|
||||
/**
|
||||
* POST pending deltas to the backend, then call onSuccess (which re-fetches
|
||||
* the curve) and clear the pending state.
|
||||
*/
|
||||
applyEdits: (onSuccess: () => void) => Promise<void>;
|
||||
/** POST /api/curve/reset, call onSuccess, clear pending state. */
|
||||
resetAllDeltas: (onSuccess: () => void) => Promise<void>;
|
||||
|
||||
// Selection actions
|
||||
selectPoint: (index: number, multi?: boolean) => void;
|
||||
selectRange: (indices: number[]) => void;
|
||||
clearSelection: () => void;
|
||||
/**
|
||||
* Stage all selected points to the anchor point's current effective delta.
|
||||
* Falls back to the lowest selected index if anchorPoint is null or deselected.
|
||||
* No-op if fewer than 2 points are selected.
|
||||
*/
|
||||
flattenToAnchor: () => void;
|
||||
|
||||
// Derived helper — effective MHz for a point including any pending delta
|
||||
effectiveMhz: (point: VFPoint) => number;
|
||||
// True if any pending delta would produce a negative effective frequency
|
||||
hasNegativeFreqWarning: () => boolean;
|
||||
}
|
||||
|
||||
export const useCurveStore = create<CurveStore>()((set, get) => ({
|
||||
availableGpus: [],
|
||||
selectedGpuIndex: 0,
|
||||
curve: null,
|
||||
gpuInfo: null,
|
||||
monitor: null,
|
||||
monitorHistory: [],
|
||||
activeProfile: null,
|
||||
pendingDeltas: new Map(),
|
||||
selectedPoints: new Set(),
|
||||
anchorPoint: null,
|
||||
|
||||
setAvailableGpus: (availableGpus) => set({ availableGpus }),
|
||||
setSelectedGpuIndex: (selectedGpuIndex) => {
|
||||
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 }),
|
||||
setActiveProfile: (activeProfile) => set({ activeProfile }),
|
||||
pushMonitor: (sample) =>
|
||||
set((s) => {
|
||||
const history = [...s.monitorHistory, sample];
|
||||
if (history.length > HISTORY_SIZE) history.shift();
|
||||
return { monitor: sample, monitorHistory: history };
|
||||
}),
|
||||
|
||||
stageEdit: (pointIndex, deltaKhz) =>
|
||||
set((s) => {
|
||||
const next = new Map(s.pendingDeltas);
|
||||
const point = s.curve?.points.find(p => p.index === pointIndex);
|
||||
if (point && point.delta_khz === deltaKhz) {
|
||||
next.delete(pointIndex);
|
||||
} else {
|
||||
next.set(pointIndex, deltaKhz);
|
||||
}
|
||||
return { pendingDeltas: next };
|
||||
}),
|
||||
|
||||
stageMultiEdit: (edits) =>
|
||||
set((s) => {
|
||||
const next = new Map(s.pendingDeltas);
|
||||
edits.forEach((deltaKhz, index) => {
|
||||
const point = s.curve?.points.find(p => p.index === index);
|
||||
if (point && point.delta_khz === deltaKhz) {
|
||||
next.delete(index);
|
||||
} else {
|
||||
next.set(index, deltaKhz);
|
||||
}
|
||||
});
|
||||
return { pendingDeltas: next };
|
||||
}),
|
||||
|
||||
stageRangeEdit: (points, deltaKhz) =>
|
||||
set((s) => {
|
||||
const next = new Map(s.pendingDeltas);
|
||||
for (const p of points) {
|
||||
next.set(p.index, deltaKhz);
|
||||
}
|
||||
return { pendingDeltas: next };
|
||||
}),
|
||||
|
||||
discardEdits: () =>
|
||||
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; });
|
||||
|
||||
try {
|
||||
const result = await api.writeDeltas(deltas, selectedGpuIndex);
|
||||
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)');
|
||||
} else {
|
||||
toast.success('Curve applied successfully');
|
||||
}
|
||||
onSuccess();
|
||||
} catch (e: any) {
|
||||
toast.error('Failed to apply curve: ' + (e.message || String(e)));
|
||||
}
|
||||
},
|
||||
|
||||
resetAllDeltas: async (onSuccess) => {
|
||||
const { selectedGpuIndex } = get();
|
||||
try {
|
||||
await api.resetCurve(selectedGpuIndex);
|
||||
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)));
|
||||
}
|
||||
},
|
||||
|
||||
selectPoint: (index, multi = false) =>
|
||||
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
|
||||
}
|
||||
} else {
|
||||
if (next.size === 1 && next.has(index)) {
|
||||
next.clear();
|
||||
anchor = null;
|
||||
} else {
|
||||
next.clear();
|
||||
next.add(index);
|
||||
anchor = index;
|
||||
}
|
||||
}
|
||||
return { selectedPoints: next, anchorPoint: anchor };
|
||||
}),
|
||||
|
||||
selectRange: (indices) =>
|
||||
// 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;
|
||||
return { selectedPoints: next, anchorPoint: anchor };
|
||||
}),
|
||||
|
||||
clearSelection: () =>
|
||||
set({ selectedPoints: new Set(), anchorPoint: null }),
|
||||
|
||||
flattenToAnchor: () => {
|
||||
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 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);
|
||||
if (!pt) continue;
|
||||
edits.set(idx, anchorEffectiveKhz - pt.freq_khz);
|
||||
}
|
||||
stageMultiEdit(edits);
|
||||
},
|
||||
|
||||
effectiveMhz: (point) => {
|
||||
const { pendingDeltas } = get();
|
||||
if (pendingDeltas.has(point.index)) {
|
||||
// freq_mhz is the current effective (VFP freq, already includes applied delta).
|
||||
// Adjust by the change in delta: (pending - current_delta).
|
||||
const pendingKhz = pendingDeltas.get(point.index)!;
|
||||
const deltaChange = pendingKhz - point.delta_khz;
|
||||
return point.freq_mhz + deltaChange / 1000;
|
||||
}
|
||||
return point.freq_mhz;
|
||||
},
|
||||
|
||||
hasNegativeFreqWarning: () => {
|
||||
const { pendingDeltas, curve } = get();
|
||||
if (!curve || pendingDeltas.size === 0) return false;
|
||||
for (const [index, pendingKhz] of pendingDeltas) {
|
||||
const p = curve.points.find((pt) => pt.index === index);
|
||||
if (!p || p.freq_khz === 0) continue;
|
||||
// freq_khz is current effective; true base = freq_khz - delta_khz
|
||||
// new effective = true_base + pendingKhz = freq_khz + (pendingKhz - delta_khz)
|
||||
if (p.freq_khz + pendingKhz - p.delta_khz < 0) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,75 @@
|
||||
export interface VFPoint {
|
||||
index: number;
|
||||
freq_khz: number;
|
||||
freq_mhz: number;
|
||||
volt_uv: number;
|
||||
volt_mv: number;
|
||||
delta_khz: number;
|
||||
delta_mhz: number;
|
||||
effective_freq_khz: number;
|
||||
effective_freq_mhz: number;
|
||||
domain: 'gpu' | 'memory';
|
||||
}
|
||||
|
||||
export interface CurveState {
|
||||
gpu_name: string;
|
||||
timestamp: number;
|
||||
points: VFPoint[];
|
||||
}
|
||||
|
||||
export interface MonitoringSample {
|
||||
timestamp: number;
|
||||
voltage_uv: number | null;
|
||||
voltage_mv: number | null;
|
||||
clock_mhz: number | null;
|
||||
temp_c: number | null;
|
||||
power_w: number | null;
|
||||
fan_pct: number | null;
|
||||
pstate: number | null;
|
||||
pstate_label: string | null;
|
||||
mem_used_bytes: number | null;
|
||||
mem_total_bytes: number | null;
|
||||
mem_used_mib: number | null;
|
||||
mem_total_mib: number | null;
|
||||
gpu_util_pct: number | null;
|
||||
mem_util_pct: number | null;
|
||||
mem_clock_mhz: number | null;
|
||||
}
|
||||
|
||||
export interface GpuInfo {
|
||||
name: string;
|
||||
index: number;
|
||||
driver_version: string | null;
|
||||
vram_bytes: number | null;
|
||||
vram_gib: number | null;
|
||||
}
|
||||
|
||||
export interface SnapshotInfo {
|
||||
filepath: string;
|
||||
timestamp: string;
|
||||
gpu: string;
|
||||
nonzero_offsets: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface LimitsState {
|
||||
// Power
|
||||
power_limit_w: number | null;
|
||||
default_power_limit_w: number | null;
|
||||
min_power_limit_w: number | null;
|
||||
max_power_limit_w: number | null;
|
||||
// Clock offsets — current values
|
||||
gpc_offset_mhz: number | null;
|
||||
mem_offset_mhz: number | null;
|
||||
// Memory offset — hardware-reported range
|
||||
min_mem_offset_mhz: number | null;
|
||||
max_mem_offset_mhz: number | null;
|
||||
}
|
||||
|
||||
export interface ProfileData {
|
||||
name: string;
|
||||
gpu_name: string;
|
||||
curve_deltas: Record<string, number>;
|
||||
mem_offset_mhz: number | null;
|
||||
power_limit_w: number | null;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { VFPoint } from '../types';
|
||||
|
||||
/**
|
||||
* Approximate reference frequency (MHz) for a point: effective − delta.
|
||||
*
|
||||
* `p.freq_mhz` is the current effective frequency as reported by GetVFPCurve
|
||||
* (already including any applied boost delta). Subtracting the delta gives an
|
||||
* approximation of the hardware base.
|
||||
*
|
||||
* Note: NVIDIA enforces monotonicity across the V/F curve — a large delta on
|
||||
* a lower-voltage point pushes up neighbouring points' effective frequencies
|
||||
* even when those points have zero delta. So for monotonicity-affected points,
|
||||
* `effective - delta` doesn't recover the true unmodified base. It is still
|
||||
* useful as a faint reference line ("where this point would sit with no boost").
|
||||
*/
|
||||
export function refBaseMhz(p: VFPoint): number {
|
||||
return p.freq_mhz - p.delta_mhz;
|
||||
}
|
||||
|
||||
/** Find which VF point the GPU is currently near based on voltage reading */
|
||||
export function findCurrentPoint(
|
||||
points: VFPoint[],
|
||||
voltage_mv: number | null,
|
||||
): VFPoint | null {
|
||||
if (voltage_mv == null || points.length === 0) return null;
|
||||
return points.reduce((best, p) =>
|
||||
Math.abs(p.volt_mv - voltage_mv) < Math.abs(best.volt_mv - voltage_mv) ? p : best,
|
||||
);
|
||||
}
|
||||
|
||||
/** Voltage domain extent, with padding */
|
||||
export function voltExtent(points: VFPoint[], padMv = 20): [number, number] {
|
||||
if (points.length === 0) return [600, 1100];
|
||||
const min = Math.min(...points.map((p) => p.volt_mv));
|
||||
const max = Math.max(...points.map((p) => p.volt_mv));
|
||||
return [min - padMv, max + padMv];
|
||||
}
|
||||
|
||||
/** Frequency domain extent for the effective (boosted) curve, with padding */
|
||||
export function freqExtent(points: VFPoint[], padMhz = 50): [number, number] {
|
||||
if (points.length === 0) return [1000, 3000];
|
||||
const allFreqs = points.flatMap((p) => [p.freq_mhz, refBaseMhz(p)]);
|
||||
const min = Math.min(...allFreqs);
|
||||
const max = Math.max(...points.map((p) => p.freq_mhz));
|
||||
return [min - padMhz, max + padMhz];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect points whose effective frequency is being held up by NVIDIA's
|
||||
* monotonicity enforcement rather than their own offset.
|
||||
*
|
||||
* Walk points in voltage order, tracking the "ceiling" — the highest
|
||||
* effective frequency seen so far and the offset that produced it. A point is
|
||||
* clamped when:
|
||||
* 1. Its effective freq is at or below the ceiling (hasn't moved past it)
|
||||
* 2. Its own offset is lower than the offset that set the ceiling
|
||||
*
|
||||
* This catches cases like: point 103 has +950 MHz → effective 3907 MHz,
|
||||
* point 104 has +315 MHz → effective also 3907 MHz (clamped).
|
||||
*/
|
||||
export function detectClampedPoints(points: VFPoint[]): Set<number> {
|
||||
const clamped = new Set<number>();
|
||||
let ceiling = -Infinity;
|
||||
let ceilingOffset = -Infinity;
|
||||
|
||||
for (const p of points) {
|
||||
if (p.freq_mhz <= ceiling && p.delta_khz < ceilingOffset) {
|
||||
clamped.add(p.index);
|
||||
}
|
||||
if (p.freq_mhz > ceiling) {
|
||||
ceiling = p.freq_mhz;
|
||||
ceilingOffset = p.delta_khz;
|
||||
}
|
||||
}
|
||||
|
||||
return clamped;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export const fmt = {
|
||||
mhz: (v: number | null | undefined, decimals = 0) =>
|
||||
v == null ? '—' : `${v.toFixed(decimals)} MHz`,
|
||||
mv: (v: number | null | undefined, decimals = 0) =>
|
||||
v == null ? '—' : `${v.toFixed(decimals)} mV`,
|
||||
celsius: (v: number | null | undefined, decimals = 0) =>
|
||||
v == null ? '—' : `${v.toFixed(decimals)} °C`,
|
||||
watts: (v: number | null | undefined, decimals = 0) =>
|
||||
v == null ? '—' : `${v.toFixed(decimals)} W`,
|
||||
pct: (v: number | null | undefined, decimals = 0) =>
|
||||
v == null ? '—' : `${v.toFixed(decimals)}%`,
|
||||
mib: (v: number | null | undefined) =>
|
||||
v == null ? '—' : `${v.toFixed(0)} MiB`,
|
||||
};
|
||||
|
||||
export const conv = {
|
||||
khzToMhz: (khz: number) => khz / 1000,
|
||||
uvToMv: (uv: number) => uv / 1000,
|
||||
mvToUv: (mv: number) => mv * 1000,
|
||||
mhzToKhz: (mhz: number) => mhz * 1000,
|
||||
};
|
||||
Reference in new issue
Block a user