feat: multi-user authentication (dual mode)

Add optional login protection for the web UI/API, intended for shared
machines (e.g. AI servers). Dual mode: with no users configured the API
and web UI are open (as before); once at least one user exists, every
/api/* and /ws/* endpoint requires a valid session.

- bcrypt password hashing: passwords stored as $2b$ hashes in
  /etc/nvcurve/users.json (0600, root-owned); plaintext never persisted.
- 24-hour sessions: HttpOnly cookie for browsers, Authorization: Bearer
  token for CLI/scripts; in-memory, invalidated on server restart.
- Multi-user: multiple named accounts (no shared-password mode).
- New CLI: nvcurve user add|list|remove|set-password (root for mutating
  ops; password always prompted, never a CLI argument).
- New endpoints: GET /api/ping (public), /api/auth/status|login|logout|users.
- Web UI: sign-in screen when auth is enabled; status bar shows the
  signed-in user with sign-out; expired sessions (401) re-show sign-in.
- Brute-force lockout: 10 failed logins/IP within 5 min -> 15 min lockout.
- New dependency: bcrypt.

Also: LSP config (pyrightconfig.json) pointing at the project .venv, and
small error-handling cleanups in daemon.py/server.py.
This commit is contained in:
ARIA committed 2026-09-02 15:21:35 +02:00
1 parent af23a10f25
commit bbd692ea2e
16 files changed
+2085 -483

No files matched your search

+149 -47
View File
@@ -1,31 +1,102 @@
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 { FanMonitor } from './components/Monitor/FanMonitor';
import { FanCurveEditor } from './components/Fans/FanCurveEditor';
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';
import type { FanState } from './types';
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 { FanMonitor } from "./components/Monitor/FanMonitor";
import { FanCurveEditor } from "./components/Fans/FanCurveEditor";
import { ProfilePanel } from "./components/Profiles/ProfilePanel";
import { api, onUnauthorized } from "./api/client";
import { LoginScreen } from "./components/Auth/LoginScreen";
import { useCurveStore } from "./store/curveStore";
import { Toaster } from "sonner";
import { Loader, ChevronDown } from "lucide-react";
import { useState, useRef, useEffect } from "react";
import type { FanState } from "./types";
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 { setCurve, activeProfile, setActiveProfile, selectedGpuIndex } = useCurveStore();
const { setCurve, activeProfile, setActiveProfile, selectedGpuIndex } =
useCurveStore();
const [activeTab, setActiveTab] = useState<'curve' | 'performance' | 'fans'>('curve');
const [activeTab, setActiveTab] = useState<"curve" | "performance" | "fans">(
"curve",
);
const [fanState, setFanState] = useState<FanState | null>(null);
const [activeDomain, setActiveDomain] = useState<'gpu' | 'memory'>('gpu');
const [activeDomain, setActiveDomain] = useState<"gpu" | "memory">("gpu");
const [isProfileOpen, setIsProfileOpen] = useState(false);
const profileRef = useRef<HTMLDivElement>(null);
@@ -37,14 +108,20 @@ export default function App() {
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (profileRef.current && !profileRef.current.contains(event.target as Node)) {
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);
api
.profiles(selectedGpuIndex)
.then((data) => setActiveProfile(data.active))
.catch(console.error);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [selectedGpuIndex, setActiveProfile]);
@@ -54,28 +131,36 @@ export default function App() {
}
useEffect(() => {
if (activeTab === 'fans') {
if (activeTab === "fans") {
refreshFans();
}
}, [activeTab, selectedGpuIndex]);
// Worst connection status wins
const wsStatus =
monitorWsStatus === 'disconnected' || curveWsStatus === 'disconnected'
? 'disconnected'
: monitorWsStatus === 'connecting' || curveWsStatus === 'connecting'
? 'connecting'
: 'connected';
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} />
<StatusBar
gpuInfo={gpuInfo}
wsStatus={wsStatus}
monitor={monitor}
user={user}
onLogout={onLogout}
/>
{wsStatus !== 'connected' && (
{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...'}
{wsStatus === "connecting"
? "Reconnecting to backend..."
: "Connection lost. Retrying..."}
</div>
)}
@@ -84,20 +169,20 @@ export default function App() {
<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'}`}
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'}`}
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'}`}
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>
@@ -113,13 +198,23 @@ export default function App() {
<span className="text-zinc-200">{activeProfile}</span>
</>
) : (
'Profiles'
"Profiles"
)}
<ChevronDown size={14} className={isProfileOpen ? "rotate-180 transition-transform" : "transition-transform"} />
<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} />
<ProfilePanel
activeProfile={activeProfile}
onProfileApplied={setActiveProfile}
/>
</div>
)}
</div>
@@ -127,7 +222,7 @@ export default function App() {
{/* Main content area */}
<div className="flex flex-col gap-4 w-full">
{activeTab === 'curve' ? (
{activeTab === "curve" ? (
<>
<div className="flex gap-4 items-stretch w-full">
<div className="flex-1 min-w-0 flex flex-col">
@@ -155,20 +250,27 @@ export default function App() {
{curve && (
<div className="w-full">
<PointTable
points={curve.points.filter(p => p.domain === activeDomain)}
currentVoltageMv={activeDomain === 'gpu' ? currentVoltageMv : null}
readOnly={activeDomain === 'memory'}
points={curve.points.filter(
(p) => p.domain === activeDomain,
)}
currentVoltageMv={
activeDomain === "gpu" ? currentVoltageMv : null
}
readOnly={activeDomain === "memory"}
/>
</div>
)}
</>
) : activeTab === 'performance' ? (
) : activeTab === "performance" ? (
<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} />
<PerformanceMonitor
monitor={monitor}
history={monitorHistory}
/>
</div>
</div>
) : (
+123 -36
View File
@@ -1,87 +1,174 @@
import type { CurveState, GpuInfo, MonitoringSample, SnapshotInfo, LimitsState, ProfileData, FanState, FanPoint } from '../types';
import type {
CurveState,
GpuInfo,
MonitoringSample,
SnapshotInfo,
LimitsState,
ProfileData,
FanState,
FanPoint,
} from "../types";
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
// Global handler invoked when any API call returns 401 (not logged in, or the
// 24-hour session expired). The app uses it to re-show the login screen.
let unauthorizedHandler: (() => void) | null = null;
export function onUnauthorized(handler: (() => void) | null): void {
unauthorizedHandler = handler;
}
function handleUnauthorized(res: Response): void {
if (res.status === 401 && unauthorizedHandler) {
unauthorizedHandler();
}
}
async function get<T>(path: string, gpuIndex?: number): Promise<T> {
const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`;
const url =
gpuIndex !== undefined
? `/api${path}?gpu_index=${gpuIndex}`
: `/api${path}`;
const res = await fetch(url);
if (!res.ok) {
handleUnauthorized(res);
const text = await res.text().catch(() => res.statusText);
throw new Error(`GET ${url}: ${res.status} — ${text}`);
throw new ApiError(res.status, `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}`;
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' } : {},
method: "POST",
headers: body != null ? { "Content-Type": "application/json" } : {},
body: body != null ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
handleUnauthorized(res);
const text = await res.text().catch(() => res.statusText);
throw new Error(`POST ${url}: ${res.status} — ${text}`);
throw new ApiError(res.status, `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>;
const ct = res.headers.get("content-type") ?? "";
if (ct.includes("application/json")) return res.json() as Promise<T>;
// SAFETY: non-JSON responses (e.g. 204) carry no body; callers of these
// endpoints use T = void and ignore the result, so undefined is a valid 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' });
const url =
gpuIndex !== undefined
? `/api${path}?gpu_index=${gpuIndex}`
: `/api${path}`;
const res = await fetch(url, { method: "DELETE" });
if (!res.ok) {
handleUnauthorized(res);
const text = await res.text().catch(() => res.statusText);
throw new Error(`DELETE ${url}: ${res.status} — ${text}`);
throw new ApiError(res.status, `DELETE ${url}: ${res.status} — ${text}`);
}
const ct = res.headers.get('content-type') ?? '';
if (ct.includes('application/json')) return res.json() as Promise<T>;
const ct = res.headers.get("content-type") ?? "";
if (ct.includes("application/json")) return res.json() as Promise<T>;
// SAFETY: non-JSON responses (e.g. 204) carry no body; callers of these
// endpoints use T = void and ignore the result, so undefined is a valid T.
return undefined as unknown as T;
}
export interface AuthStatus {
auth_required: boolean;
authenticated: boolean;
username: string | null;
expires_at: number | null;
}
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),
/** Auth */
authStatus: () => get<AuthStatus>("/auth/status"),
login: (username: string, password: string) =>
post<{ ok: boolean; username: string; expires_at: number; token: string }>(
"/auth/login",
{ username, password },
),
logout: () => post("/auth/logout"),
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),
post<{ ok: boolean; freq_warnings?: string[] }>(
"/curve/write",
{ deltas },
gpuIndex,
),
/** Reset all frequency deltas to zero. */
resetCurve: (gpuIndex: number) => post('/curve/reset', undefined, gpuIndex),
resetCurve: (gpuIndex: number) => post("/curve/reset", undefined, gpuIndex),
/** Get performance limits mapping */
limits: (gpuIndex: number) => get<LimitsState>('/limits', gpuIndex),
limits: (gpuIndex: number) => get<LimitsState>("/limits", gpuIndex),
/** Set performance limits */
updateLimits: (updates: Partial<LimitsState>, gpuIndex: number) =>
post('/limits', updates, gpuIndex),
post("/limits", updates, gpuIndex),
/** Reset power limit and memory offset to hardware defaults */
resetLimits: (gpuIndex: number) => post('/limits/reset', undefined, gpuIndex),
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),
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 }),
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 }),
post<{ ok: boolean; auto_load_profile: string | null }>("/config", {
auto_load_profile: name,
gpu_index: gpuIndex,
}),
/** Fan control */
fans: (gpuIndex: number) => get<FanState>('/fans', gpuIndex),
fans: (gpuIndex: number) => get<FanState>("/fans", gpuIndex),
updateFans: (curve: FanPoint[], gpuIndex: number) =>
post('/fans', { curve }, gpuIndex),
resetFans: (gpuIndex: number) => post('/fans/reset', undefined, gpuIndex),
post("/fans", { curve }, gpuIndex),
resetFans: (gpuIndex: number) => post("/fans/reset", undefined, gpuIndex),
setFanSpeed: (fanPct: number, gpuIndex: number) =>
post('/fans/speed', { fan_pct: fanPct }, gpuIndex),
post("/fans/speed", { fan_pct: fanPct }, gpuIndex),
};
@@ -0,0 +1,124 @@
import { useState } from "react";
import { Loader, Lock, User } from "lucide-react";
import { api, ApiError } from "../../api/client";
interface Props {
onSuccess: (username: string) => void;
}
export function LoginScreen({ onSuccess }: Props) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (busy) return;
setBusy(true);
setError(null);
try {
const res = await api.login(username, password);
onSuccess(res.username);
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
setError("Invalid username or password.");
} else if (err instanceof ApiError && err.status === 429) {
setError(
"Too many failed attempts. Please wait a moment and try again.",
);
} else {
setError(err instanceof Error ? err.message : "Login failed.");
}
} finally {
setBusy(false);
}
}
return (
<div className="min-h-screen bg-zinc-950 text-zinc-100 flex items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8">
<img src="/logo.svg" alt="NVCurve" className="w-12 h-12 mb-3" />
<h1 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-violet-400 to-fuchsia-400 tracking-tight">
NVCurve
</h1>
<p className="text-sm text-zinc-500 mt-1">Sign in to continue</p>
</div>
<form
onSubmit={submit}
className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 shadow-[0_8px_30px_rgb(0,0,0,0.6)] flex flex-col gap-4"
>
<div className="flex flex-col gap-1.5">
<label
htmlFor="username"
className="text-xs font-medium text-zinc-400"
>
Username
</label>
<div className="relative">
<User
size={15}
className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500"
/>
<input
id="username"
type="text"
autoComplete="username"
autoFocus
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-700 rounded-lg pl-9 pr-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500/40"
placeholder="username"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="password"
className="text-xs font-medium text-zinc-400"
>
Password
</label>
<div className="relative">
<Lock
size={15}
className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500"
/>
<input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-700 rounded-lg pl-9 pr-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500/40"
placeholder="••••••••"
/>
</div>
</div>
{error && (
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
{error}
</div>
)}
<button
type="submit"
disabled={busy || !username || !password}
className="mt-1 flex items-center justify-center gap-2 bg-violet-600 hover:bg-violet-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg py-2.5 transition-colors"
>
{busy && <Loader size={15} className="animate-spin" />}
{busy ? "Signing in…" : "Sign in"}
</button>
</form>
<p className="text-center text-xs text-zinc-600 mt-4">
Sessions last 24 hours, then you will be asked to sign in again.
</p>
</div>
</div>
);
}
+121 -33
View File
@@ -1,13 +1,24 @@
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';
import {
Cpu,
Wifi,
WifiOff,
Loader,
Terminal,
ChevronDown,
LogOut,
User,
} 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';
wsStatus: "connecting" | "connected" | "disconnected";
monitor: MonitoringSample | null;
user?: string | null;
onLogout?: () => void;
}
const statusIcon = {
@@ -17,19 +28,29 @@ const statusIcon = {
};
const statusText = {
connected: 'Connected',
connecting: 'Connecting…',
disconnected: 'Disconnected',
connected: "Connected",
connecting: "Connecting…",
disconnected: "Disconnected",
};
export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) {
const { availableGpus, selectedGpuIndex, setSelectedGpuIndex } = useCurveStore();
export function StatusBar({
gpuInfo,
wsStatus,
monitor,
user,
onLogout,
}: 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)) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
@@ -47,20 +68,26 @@ export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) {
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" />
<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'}
{gpuInfo?.name ?? "No Device"}
</span>
<ChevronDown size={14} className={`text-zinc-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
<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) => (
@@ -71,12 +98,23 @@ export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) {
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'
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'} />
<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>
<span className="text-xs text-zinc-500 font-mono">
GPU {gpu.index}
</span>
</button>
))}
</div>
@@ -84,9 +122,12 @@ export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) {
</div>
) : (
<>
<Cpu size={16} className="shrink-0 text-zinc-400 ml-1 hidden sm:block" />
<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'}
{gpuInfo?.name ?? "No Device"}
</span>
</>
)}
@@ -101,28 +142,75 @@ export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) {
{/* 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" />
<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>
{/* Right: user + connection status */}
<div className="flex items-center gap-3 shrink-0">
{user && (
<div className="flex items-center gap-1.5 text-zinc-400">
<User size={14} className="text-zinc-500" />
<span className="hidden sm:inline text-zinc-300">{user}</span>
{onLogout && (
<button
onClick={onLogout}
title="Sign out"
className="p-1 rounded hover:bg-zinc-800 hover:text-zinc-200 transition-colors"
>
<LogOut size={14} />
</button>
)}
</div>
)}
<div className="flex items-center gap-1.5 text-zinc-400">
{statusIcon[wsStatus]}
<span className="hidden sm:inline">{statusText[wsStatus]}</span>
</div>
</div>
</div>
</header>
);
}
function StatPill({ label, value, color }: { label: string; value: string; color: string }) {
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>
<span className={`font-mono font-semibold text-sm ${color}`}>
{value}
</span>
</div>
);
}