- 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
356 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|