Files
nvcurve/frontend/src/App.tsx
T
ARIA b69b7502b3 feat: wireview pin-imbalance warning, per-pin rating bars, pin_imbalance_a
- Per-pin bar now scales against the 9.2 A per-pin rating (10 A full
  scale, red at/over rating) instead of the 55 A total connector OCP
- Debounced app-wide warning banner (visible on all tabs) when the
  current between two pins diverges by > 2 A for ~7 s - the signature
  of a bad pin contact that can overheat and melt the 12VHPWR
  connector; 5 s grace period after clearing
- New pin_imbalance_a sample field (max - min pin current) in both the
  serial and hwmon sample builders, plus frontend type
- Docs: README + WireView.md cover the warning and threshold; fresh
  screenshot with the new per-pin labels
- Tests: vitest setup + 9 unit tests for the debounce state machine;
  3 new backend checks for pin_imbalance_a
2026-10-03 22:13:13 +02:00

356 lines
13 KiB
TypeScript

import { useGpu } from "./hooks/useGpu.js";
import { useCurve } from "./hooks/useCurve.js";
import { useMonitor } from "./hooks/useMonitor.js";
import { useDashboard } from "./hooks/useDashboard.js";
import { StatusBar } from "./components/Monitor/StatusBar.js";
import { LiveMonitor } from "./components/Monitor/LiveMonitor.js";
import { Dashboard } from "./components/Dashboard/Dashboard.js";
import { CurveEditor } from "./components/CurveEditor/CurveEditor.js";
import { PointTable } from "./components/PointTable/PointTable.js";
import { PerformancePanel } from "./components/Limits/PerformancePanel.js";
import { PerformanceMonitor } from "./components/Monitor/PerformanceMonitor.js";
import { ProcessList } from "./components/Monitor/ProcessList.js";
import { FanMonitor } from "./components/Monitor/FanMonitor.js";
import { FanCurveEditor } from "./components/Fans/FanCurveEditor.js";
import { WireViewPanel } from "./components/WireView/WireViewPanel.js";
import { ProfilePanel } from "./components/Profiles/ProfilePanel.js";
import { api, onUnauthorized } from "./api/client.js";
import { LoginScreen } from "./components/Auth/LoginScreen.js";
import { useCurveStore } from "./store/curveStore.js";
import { useWireview } from "./hooks/useWireview.js";
import { useWireviewImbalance } from "./hooks/useWireviewImbalance.js";
import { Toaster } from "sonner";
import { Loader, ChevronDown } from "lucide-react";
import { useState, useRef, useEffect } from "react";
import type { FanState } from "./types.js";
type AuthState = "checking" | "login" | "ok";
export default function App() {
const [authState, setAuthState] = useState<AuthState>("checking");
const [authUser, setAuthUser] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
api
.authStatus()
.then((s) => {
if (cancelled) return;
if (s.auth_required && !s.authenticated) {
setAuthState("login");
} else {
setAuthUser(s.username);
setAuthState("ok");
}
})
.catch(() => {
// Server unreachable — fall through to the normal (no-auth) flow.
if (!cancelled) setAuthState("ok");
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
onUnauthorized(() => setAuthState("login"));
return () => onUnauthorized(null);
}, []);
function handleLogout() {
api.logout().catch(() => {});
setAuthUser(null);
setAuthState("login");
}
if (authState === "checking") {
return (
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
<Loader className="animate-spin text-zinc-500" size={28} />
</div>
);
}
if (authState === "login") {
return (
<LoginScreen
onSuccess={(username) => {
setAuthUser(username);
setAuthState("ok");
}}
/>
);
}
return <MainApp user={authUser} onLogout={handleLogout} />;
}
function MainApp({
user,
onLogout,
}: {
user: string | null;
onLogout: () => void;
}) {
const gpuInfo = useGpu();
const { curve, wsStatus: curveWsStatus } = useCurve();
const { monitor, monitorHistory, wsStatus: monitorWsStatus } = useMonitor();
const { dashboard, loading: dashboardLoading } = useDashboard();
const { setCurve, activeProfile, setActiveProfile, selectedGpuIndex } =
useCurveStore();
const wireview = useWireview();
const wireImbalance = useWireviewImbalance(wireview.sample);
const [activeTab, setActiveTab] = useState<
"dashboard" | "curve" | "performance" | "fans" | "wireview"
>("dashboard");
const [fanState, setFanState] = useState<FanState | null>(null);
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]);
function refreshFans() {
api.fans(selectedGpuIndex).then(setFanState).catch(console.error);
}
useEffect(() => {
if (activeTab === "fans") {
refreshFans();
}
}, [activeTab, selectedGpuIndex]);
// 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}
user={user}
onLogout={onLogout}
/>
{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>
)}
{wireImbalance && (
<div className="bg-red-500/15 border-b-2 border-red-500 text-red-300 px-4 py-2.5 flex items-center justify-center gap-3 flex-wrap">
<span className="text-xl leading-none">⚠</span>
<span className="text-base font-bold">
WireView: current imbalance {wireImbalance.diff.toFixed(1)} A —
Pin {wireImbalance.hiPin} ({wireImbalance.maxI.toFixed(1)} A) vs
Pin {wireImbalance.loPin} ({wireImbalance.minI.toFixed(1)} A)
</span>
<span className="text-sm text-red-200/80">
Bad pin contact — reduce load, replace cable/adapter
</span>
<button
onClick={() => setActiveTab("wireview")}
className="text-sm font-semibold text-red-200 hover:text-white underline underline-offset-2"
>
View
</button>
</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("dashboard")}
className={`text-lg font-medium pb-2 -mb-[9px] border-b-2 transition-colors ${activeTab === "dashboard" ? "border-pink-500 text-zinc-100" : "border-transparent text-zinc-500 hover:text-zinc-300"}`}
>
Dashboard
</button>
<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>
<button
onClick={() => setActiveTab("fans")}
className={`text-lg font-medium pb-2 -mb-[9px] border-b-2 transition-colors ${activeTab === "fans" ? "border-pink-500 text-zinc-100" : "border-transparent text-zinc-500 hover:text-zinc-300"}`}
>
Fans
</button>
{wireview.available && (
<button
onClick={() => setActiveTab("wireview")}
className={`text-lg font-medium pb-2 -mb-[9px] border-b-2 transition-colors flex items-center gap-2 ${activeTab === "wireview" ? "border-pink-500 text-zinc-100" : "border-transparent text-zinc-500 hover:text-zinc-300"}`}
>
WireView
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
</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 === "dashboard" ? (
<Dashboard
monitor={monitor}
history={monitorHistory}
dashboard={dashboard}
loading={dashboardLoading}
/>
) : 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>
)}
</>
) : activeTab === "performance" ? (
<div className="flex flex-col gap-4 w-full">
<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>
<ProcessList />
</div>
) : activeTab === "wireview" ? (
<WireViewPanel
info={wireview.info}
sample={wireview.sample}
history={wireview.history}
/>
) : (
<div className="flex gap-4 items-start w-full">
<div className="flex-1 min-w-0">
<FanCurveEditor onChanged={refreshFans} />
</div>
<div className="w-80 shrink-0 flex flex-col">
<FanMonitor
monitor={monitor}
history={monitorHistory}
fanCurve={fanState?.curve ?? null}
fanCurveActive={fanState?.curve_active ?? false}
/>
</div>
</div>
)}
</div>
</main>
</div>
);
}