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:
1 parent
af23a10f25
commit
bbd692ea2e
16 files changed
+2032
-430
No files matched your search
@@ -2,17 +2,35 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Multi-User Authentication (dual mode)**: The server now supports optional login-protected access for shared machines (e.g. AI servers).
|
||||||
|
- **Dual mode**: with no users configured the API/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 are stored as bcrypt (`$2b$`) hashes in `/etc/nvcurve/users.json` (mode `0600`, root-owned). Plaintext is never persisted; login compares the plaintext against the stored hash.
|
||||||
|
- **24-hour sessions**: a successful login creates a session that lasts 24 hours (HttpOnly cookie for browsers, `Authorization: Bearer` token for CLI/scripts). Sessions are in-memory and invalidated on server restart.
|
||||||
|
- **Multi-user**: multiple named accounts are supported (no shared-password mode).
|
||||||
|
- **New CLI**: `nvcurve user add|list|remove|set-password` (root for add/remove/set-password).
|
||||||
|
- **New endpoints**: `GET /api/ping` (public), `GET /api/auth/status`, `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/users`.
|
||||||
|
- **Web UI**: a sign-in screen appears when authentication is enabled; the status bar shows the signed-in user with a sign-out button. Expired sessions (401) re-show the sign-in screen.
|
||||||
|
- **Brute-force lockout**: 10 failed logins from an IP within 5 minutes triggers a 15-minute lockout.
|
||||||
|
- New dependency: `bcrypt`.
|
||||||
|
|
||||||
## [0.5.1] - 2026-05-09
|
## [0.5.1] - 2026-05-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Comprehensive Documentation**: Added structured docs covering overview, installation, usage guide, and tips and tricks. README pruned to essentials with links to docs.
|
- **Comprehensive Documentation**: Added structured docs covering overview, installation, usage guide, and tips and tricks. README pruned to essentials with links to docs.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **VRAM Offset Cap Raised**: Increased the web UI VRAM slider maximum from 1000 MHz to 3000 MHz, matching the NVIDIA driver hard limit. Point 131 now allows the full range.
|
- **VRAM Offset Cap Raised**: Increased the web UI VRAM slider maximum from 1000 MHz to 3000 MHz, matching the NVIDIA driver hard limit. Point 131 now allows the full range.
|
||||||
|
|
||||||
## [0.5.0] - 2026-03-23
|
## [0.5.0] - 2026-03-23
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **CLI Architecture Simplification**: The CLI has been decoupled from the FastAPI server and now operates as a stateless direct-HAL hardware administration tool. It no longer relies on the server for data reading or offset writing.
|
- **CLI Architecture Simplification**: The CLI has been decoupled from the FastAPI server and now operates as a stateless direct-HAL hardware administration tool. It no longer relies on the server for data reading or offset writing.
|
||||||
- **Consistent Privileges**: All CLI commands that interact with the hardware now explicitly require root privileges.
|
- **Consistent Privileges**: All CLI commands that interact with the hardware now explicitly require root privileges.
|
||||||
- **Background Daemon**: Added a new lightweight Unix socket daemon (`nvcurve daemon`) to handle auto-loading profiles on boot and managing the server's lifecycle.
|
- **Background Daemon**: Added a new lightweight Unix socket daemon (`nvcurve daemon`) to handle auto-loading profiles on boot and managing the server's lifecycle.
|
||||||
@@ -21,6 +39,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
## [0.4.0] - 2026-03-17
|
## [0.4.0] - 2026-03-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Multi-GPU Support** *(experimental — untested on real multi-GPU hardware)*: The server now manages all detected NVIDIA GPUs simultaneously under a single process. Each GPU gets its own isolated state (write lock, monitor clients, curve clients, active profile). REST endpoints and WebSocket subscriptions accept a `gpu_index` parameter. A new `/api/gpus` endpoint enumerates all GPUs with name, index, UUID, and PCI bus ID.
|
- **Multi-GPU Support** *(experimental — untested on real multi-GPU hardware)*: The server now manages all detected NVIDIA GPUs simultaneously under a single process. Each GPU gets its own isolated state (write lock, monitor clients, curve clients, active profile). REST endpoints and WebSocket subscriptions accept a `gpu_index` parameter. A new `/api/gpus` endpoint enumerates all GPUs with name, index, UUID, and PCI bus ID.
|
||||||
- **GPU Selector in Web UI**: When multiple GPUs are present, the status bar shows a dropdown to switch the active GPU. Switching resets all pending edits, selection state, and live monitoring for the new target.
|
- **GPU Selector in Web UI**: When multiple GPUs are present, the status bar shows a dropdown to switch the active GPU. Switching resets all pending edits, selection state, and live monitoring for the new target.
|
||||||
- **Default Profile**: Added the ability to designate a profile as the default — it is applied automatically on server startup.
|
- **Default Profile**: Added the ability to designate a profile as the default — it is applied automatically on server startup.
|
||||||
@@ -32,6 +51,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
- **Automated Setup Check**: `nvcurve setup` runs a consolidated 4-step hardware compatibility check: NvAPI function probe → V/F curve baseline read → non-destructive write-verify → automatic state restore. The write-verify defaults to the last GPU-domain point (safe on all GPU generations); override with `--point` and `--delta`. Pass `--full-mask` if writes fail on older GPUs such as Pascal.
|
- **Automated Setup Check**: `nvcurve setup` runs a consolidated 4-step hardware compatibility check: NvAPI function probe → V/F curve baseline read → non-destructive write-verify → automatic state restore. The write-verify defaults to the last GPU-domain point (safe on all GPU generations); override with `--point` and `--delta`. Pass `--full-mask` if writes fail on older GPUs such as Pascal.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Profile CLI syntax**: Profile commands now take the profile name as a positional argument instead of `--name` (e.g. `nvcurve profile apply balanced` instead of `nvcurve profile apply --name balanced`).
|
- **Profile CLI syntax**: Profile commands now take the profile name as a positional argument instead of `--name` (e.g. `nvcurve profile apply balanced` instead of `nvcurve profile apply --name balanced`).
|
||||||
- **Improved Diagnostics**: The `read --diag` engine now reports driver version, VRAM totals, power limits, raw clock offsets, memory offset ranges, and raw boost masks in addition to the NvAPI function probe.
|
- **Improved Diagnostics**: The `read --diag` engine now reports driver version, VRAM totals, power limits, raw clock offsets, memory offset ranges, and raw boost masks in addition to the NvAPI function probe.
|
||||||
- **Offline Snapshots**: `snapshot save`, `restore`, and `list` bypass the server and fall back to direct HAL operations when the daemon is not running.
|
- **Offline Snapshots**: `snapshot save`, `restore`, and `list` bypass the server and fall back to direct HAL operations when the daemon is not running.
|
||||||
@@ -53,6 +53,18 @@ nvcurve # Launch web UI at http://localhost:8042
|
|||||||
nvcurve read # Quick curve read from CLI
|
nvcurve read # Quick curve read from CLI
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Authentication (Multi-User)
|
||||||
|
|
||||||
|
The server runs **open by default**. On a shared machine (e.g. an AI server), add users to require a login — the web UI then shows a sign-in screen and every API/WebSocket call is protected. Passwords are stored as **bcrypt** hashes; sessions last **24 hours**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nvcurve user add alice # add a user (prompts for password)
|
||||||
|
nvcurve user list # list users
|
||||||
|
sudo nvcurve user remove alice # remove a user
|
||||||
|
```
|
||||||
|
|
||||||
|
Adding the first user enables authentication immediately; removing the last user disables it. See the [Usage Guide](docs/Usage-Guide.md#authentication-multi-user) for details.
|
||||||
|
|
||||||
## Systemd Service
|
## Systemd Service
|
||||||
|
|
||||||
Install the daemon for automatic profile loading on boot and optional web server auto-start:
|
Install the daemon for automatic profile loading on boot and optional web server auto-start:
|
||||||
@@ -131,7 +143,7 @@ The daemon reads settings from `/etc/nvcurve/config.json`:
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Setting | Description |
|
| Setting | Description |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| `host` | Web server bind address (`0.0.0.0` for network access) |
|
| `host` | Web server bind address (`0.0.0.0` for network access) |
|
||||||
| `port` | Web server port (default `8042`) |
|
| `port` | Web server port (default `8042`) |
|
||||||
| `auto_serve` | Auto-start web server on boot |
|
| `auto_serve` | Auto-start web server on boot |
|
||||||
|
|||||||
+61
-3
@@ -38,7 +38,7 @@ nvcurve serve stop # Stop the server
|
|||||||
The curve editor displays your GPU's V/F curve as an interactive graph with draggable points.
|
The curve editor displays your GPU's V/F curve as an interactive graph with draggable points.
|
||||||
|
|
||||||
| Action | How |
|
| Action | How |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| Select a point | Click on it |
|
| Select a point | Click on it |
|
||||||
| Multi-select | Shift+click to add/remove points |
|
| Multi-select | Shift+click to add/remove points |
|
||||||
| Select all active points | Ctrl/Cmd+A |
|
| Select all active points | Ctrl/Cmd+A |
|
||||||
@@ -58,7 +58,7 @@ The curve editor displays your GPU's V/F curve as an interactive graph with drag
|
|||||||
The point table provides a spreadsheet-like view of all curve points with their frequency, voltage, and offset values.
|
The point table provides a spreadsheet-like view of all curve points with their frequency, voltage, and offset values.
|
||||||
|
|
||||||
| Action | How |
|
| Action | How |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| Select a point | Click a row |
|
| Select a point | Click a row |
|
||||||
| Toggle selection | Ctrl/Cmd+click |
|
| Toggle selection | Ctrl/Cmd+click |
|
||||||
| Range select | Shift+click or drag across rows |
|
| Range select | Shift+click or drag across rows |
|
||||||
@@ -88,6 +88,64 @@ Data is streamed via WebSocket from the backend at a configurable poll interval
|
|||||||
|
|
||||||
When multiple NVIDIA GPUs are detected, a GPU selector dropdown appears in the status bar. Switching GPUs resets pending edits, selection state, and monitoring for the new target.
|
When multiple NVIDIA GPUs are detected, a GPU selector dropdown appears in the status bar. Switching GPUs resets pending edits, selection state, and monitoring for the new target.
|
||||||
|
|
||||||
|
## Authentication (Multi-User)
|
||||||
|
|
||||||
|
By default the server runs **without** authentication — anyone who can reach the port can use it. This is fine for a single-user workstation, but on a shared AI server you will want to lock it down. nvcurve uses a **dual mode**:
|
||||||
|
|
||||||
|
- **No users configured** → the API and web UI are open, exactly as before.
|
||||||
|
- **One or more users configured** → every API and WebSocket endpoint requires a login. The web UI shows a sign-in screen first.
|
||||||
|
|
||||||
|
There is no "single shared password" mode — once you add a user, each person gets their own account.
|
||||||
|
|
||||||
|
### Managing users
|
||||||
|
|
||||||
|
Users are stored as **bcrypt** hashes in `/etc/nvcurve/users.json` (mode `0600`, root-owned). Plaintext passwords are never written to disk. Manage them with the CLI (root required to add/remove/change):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add a user (prompts for the password twice). Never pass the password as an
|
||||||
|
# argument — it would be visible in the process list and recorded in sudo logs.
|
||||||
|
sudo nvcurve user add alice
|
||||||
|
sudo nvcurve user add bob
|
||||||
|
|
||||||
|
# List users
|
||||||
|
nvcurve user list
|
||||||
|
|
||||||
|
# Change a user's password
|
||||||
|
sudo nvcurve user set-password alice
|
||||||
|
|
||||||
|
# Remove a user
|
||||||
|
sudo nvcurve user remove bob
|
||||||
|
```
|
||||||
|
|
||||||
|
Adding the first user **switches the server into authenticated mode immediately** (no restart needed). Removing the last user switches it back to open mode.
|
||||||
|
|
||||||
|
### Signing in
|
||||||
|
|
||||||
|
- **Web UI:** open the app as usual; if authentication is enabled you will see a sign-in screen. Enter your username and password.
|
||||||
|
- **CLI / scripts:** the Python client can authenticate and reuse the session:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from nvcurve.client import NvCurveClient
|
||||||
|
client = NvCurveClient(base="http://127.0.0.1:8042")
|
||||||
|
client.login("alice", "S3cret!") # stores the session token
|
||||||
|
print(client.gpu()) # subsequent calls are authorized
|
||||||
|
```
|
||||||
|
|
||||||
|
Or pass a token you already have: `NvCurveClient(base=..., token="...")`.
|
||||||
|
|
||||||
|
### Sessions
|
||||||
|
|
||||||
|
- A successful login creates a session that **lasts 24 hours**, after which a new login is required.
|
||||||
|
- Browsers receive the session as an `HttpOnly` cookie; CLI/scripts use the returned token as an `Authorization: Bearer <token>` header.
|
||||||
|
- Sessions are kept in server memory, so a server restart invalidates them (users must sign in again).
|
||||||
|
- A per-IP lockout (10 failed attempts within 5 minutes → 15-minute lockout) slows down brute-force guessing.
|
||||||
|
|
||||||
|
### Security notes
|
||||||
|
|
||||||
|
- The user store file should stay root-owned and `0600` (the CLI enforces this).
|
||||||
|
- The web UI and API are still only as safe as the network path to the server — bind to a trusted interface (`--host`) and/or firewall the port. Authentication protects against casual access, not a determined network attacker.
|
||||||
|
- The `nvcurve user` commands and the user store require root; day-to-day sign-in does not.
|
||||||
|
|
||||||
## CLI Reference
|
## CLI Reference
|
||||||
|
|
||||||
The CLI is designed for scripting, headless use, and quick operations. All write commands support `--dry-run` to preview changes.
|
The CLI is designed for scripting, headless use, and quick operations. All write commands support `--dry-run` to preview changes.
|
||||||
@@ -198,7 +256,7 @@ sudo nvcurve service configure --host 0.0.0.0 --port 8042
|
|||||||
## Configuration Files
|
## Configuration Files
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| `/etc/nvcurve/config.json` | Persistent config (host, port, auto-serve, default profiles) |
|
| `/etc/nvcurve/config.json` | Persistent config (host, port, auto-serve, default profiles) |
|
||||||
| `/etc/nvcurve/profiles/*.json` | Saved profiles |
|
| `/etc/nvcurve/profiles/*.json` | Saved profiles |
|
||||||
| `/var/cache/nvcurve/snapshots/` | Auto-saved snapshots before writes |
|
| `/var/cache/nvcurve/snapshots/` | Auto-saved snapshots before writes |
|
||||||
|
|||||||
+149
-47
@@ -1,31 +1,102 @@
|
|||||||
import { useGpu } from './hooks/useGpu';
|
import { useGpu } from "./hooks/useGpu";
|
||||||
import { useCurve } from './hooks/useCurve';
|
import { useCurve } from "./hooks/useCurve";
|
||||||
import { useMonitor } from './hooks/useMonitor';
|
import { useMonitor } from "./hooks/useMonitor";
|
||||||
import { StatusBar } from './components/Monitor/StatusBar';
|
import { StatusBar } from "./components/Monitor/StatusBar";
|
||||||
import { LiveMonitor } from './components/Monitor/LiveMonitor';
|
import { LiveMonitor } from "./components/Monitor/LiveMonitor";
|
||||||
import { CurveEditor } from './components/CurveEditor/CurveEditor';
|
import { CurveEditor } from "./components/CurveEditor/CurveEditor";
|
||||||
import { PointTable } from './components/PointTable/PointTable';
|
import { PointTable } from "./components/PointTable/PointTable";
|
||||||
import { PerformancePanel } from './components/Limits/PerformancePanel';
|
import { PerformancePanel } from "./components/Limits/PerformancePanel";
|
||||||
import { PerformanceMonitor } from './components/Monitor/PerformanceMonitor';
|
import { PerformanceMonitor } from "./components/Monitor/PerformanceMonitor";
|
||||||
import { FanMonitor } from './components/Monitor/FanMonitor';
|
import { FanMonitor } from "./components/Monitor/FanMonitor";
|
||||||
import { FanCurveEditor } from './components/Fans/FanCurveEditor';
|
import { FanCurveEditor } from "./components/Fans/FanCurveEditor";
|
||||||
import { ProfilePanel } from './components/Profiles/ProfilePanel';
|
import { ProfilePanel } from "./components/Profiles/ProfilePanel";
|
||||||
import { api } from './api/client';
|
import { api, onUnauthorized } from "./api/client";
|
||||||
import { useCurveStore } from './store/curveStore';
|
import { LoginScreen } from "./components/Auth/LoginScreen";
|
||||||
import { Toaster } from 'sonner';
|
import { useCurveStore } from "./store/curveStore";
|
||||||
import { Loader, ChevronDown } from 'lucide-react';
|
import { Toaster } from "sonner";
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { Loader, ChevronDown } from "lucide-react";
|
||||||
import type { FanState } from './types';
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import type { FanState } from "./types";
|
||||||
|
|
||||||
|
type AuthState = "checking" | "login" | "ok";
|
||||||
|
|
||||||
export default function App() {
|
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 gpuInfo = useGpu();
|
||||||
const { curve, wsStatus: curveWsStatus } = useCurve();
|
const { curve, wsStatus: curveWsStatus } = useCurve();
|
||||||
const { monitor, monitorHistory, wsStatus: monitorWsStatus } = useMonitor();
|
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 [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 [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||||
const profileRef = useRef<HTMLDivElement>(null);
|
const profileRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -37,14 +108,20 @@ export default function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: MouseEvent) {
|
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);
|
setIsProfileOpen(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
|
||||||
// Fetch initial active profile
|
// 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);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [selectedGpuIndex, setActiveProfile]);
|
}, [selectedGpuIndex, setActiveProfile]);
|
||||||
@@ -54,28 +131,36 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'fans') {
|
if (activeTab === "fans") {
|
||||||
refreshFans();
|
refreshFans();
|
||||||
}
|
}
|
||||||
}, [activeTab, selectedGpuIndex]);
|
}, [activeTab, selectedGpuIndex]);
|
||||||
|
|
||||||
// Worst connection status wins
|
// Worst connection status wins
|
||||||
const wsStatus =
|
const wsStatus =
|
||||||
monitorWsStatus === 'disconnected' || curveWsStatus === 'disconnected'
|
monitorWsStatus === "disconnected" || curveWsStatus === "disconnected"
|
||||||
? 'disconnected'
|
? "disconnected"
|
||||||
: monitorWsStatus === 'connecting' || curveWsStatus === 'connecting'
|
: monitorWsStatus === "connecting" || curveWsStatus === "connecting"
|
||||||
? 'connecting'
|
? "connecting"
|
||||||
: 'connected';
|
: "connected";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950 text-zinc-100 flex flex-col">
|
<div className="min-h-screen bg-zinc-950 text-zinc-100 flex flex-col">
|
||||||
<Toaster theme="dark" position="top-right" />
|
<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">
|
<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" />
|
<Loader size={14} className="animate-spin" />
|
||||||
{wsStatus === 'connecting' ? 'Reconnecting to backend...' : 'Connection lost. Retrying...'}
|
{wsStatus === "connecting"
|
||||||
|
? "Reconnecting to backend..."
|
||||||
|
: "Connection lost. Retrying..."}
|
||||||
</div>
|
</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 justify-between items-end border-b border-zinc-800 pb-2">
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('curve')}
|
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'}`}
|
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
|
Curve
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('performance')}
|
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'}`}
|
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
|
Performance
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('fans')}
|
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'}`}
|
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
|
Fans
|
||||||
</button>
|
</button>
|
||||||
@@ -113,13 +198,23 @@ export default function App() {
|
|||||||
<span className="text-zinc-200">{activeProfile}</span>
|
<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>
|
</button>
|
||||||
{isProfileOpen && (
|
{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">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -127,7 +222,7 @@ export default function App() {
|
|||||||
|
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<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 gap-4 items-stretch w-full">
|
||||||
<div className="flex-1 min-w-0 flex flex-col">
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
@@ -155,20 +250,27 @@ export default function App() {
|
|||||||
{curve && (
|
{curve && (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<PointTable
|
<PointTable
|
||||||
points={curve.points.filter(p => p.domain === activeDomain)}
|
points={curve.points.filter(
|
||||||
currentVoltageMv={activeDomain === 'gpu' ? currentVoltageMv : null}
|
(p) => p.domain === activeDomain,
|
||||||
readOnly={activeDomain === 'memory'}
|
)}
|
||||||
|
currentVoltageMv={
|
||||||
|
activeDomain === "gpu" ? currentVoltageMv : null
|
||||||
|
}
|
||||||
|
readOnly={activeDomain === "memory"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : activeTab === 'performance' ? (
|
) : activeTab === "performance" ? (
|
||||||
<div className="flex gap-4 items-start w-full">
|
<div className="flex gap-4 items-start w-full">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<PerformancePanel />
|
<PerformancePanel />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-80 shrink-0 flex flex-col">
|
<div className="w-80 shrink-0 flex flex-col">
|
||||||
<PerformanceMonitor monitor={monitor} history={monitorHistory} />
|
<PerformanceMonitor
|
||||||
|
monitor={monitor}
|
||||||
|
history={monitorHistory}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
+123
-36
@@ -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> {
|
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);
|
const res = await fetch(url);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
handleUnauthorized(res);
|
||||||
const text = await res.text().catch(() => res.statusText);
|
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>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function post<T = void>(path: string, body?: unknown, gpuIndex?: number): Promise<T> {
|
async function post<T = void>(
|
||||||
const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`;
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
gpuIndex?: number,
|
||||||
|
): Promise<T> {
|
||||||
|
const url =
|
||||||
|
gpuIndex !== undefined
|
||||||
|
? `/api${path}?gpu_index=${gpuIndex}`
|
||||||
|
: `/api${path}`;
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: body != null ? { 'Content-Type': 'application/json' } : {},
|
headers: body != null ? { "Content-Type": "application/json" } : {},
|
||||||
body: body != null ? JSON.stringify(body) : undefined,
|
body: body != null ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
handleUnauthorized(res);
|
||||||
const text = await res.text().catch(() => res.statusText);
|
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)
|
// Some endpoints return no body (204)
|
||||||
const ct = res.headers.get('content-type') ?? '';
|
const ct = res.headers.get("content-type") ?? "";
|
||||||
if (ct.includes('application/json')) return res.json() as Promise<T>;
|
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;
|
return undefined as unknown as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del<T = void>(path: string, gpuIndex?: number): Promise<T> {
|
async function del<T = void>(path: string, gpuIndex?: number): Promise<T> {
|
||||||
const url = gpuIndex !== undefined ? `/api${path}?gpu_index=${gpuIndex}` : `/api${path}`;
|
const url =
|
||||||
const res = await fetch(url, { method: 'DELETE' });
|
gpuIndex !== undefined
|
||||||
|
? `/api${path}?gpu_index=${gpuIndex}`
|
||||||
|
: `/api${path}`;
|
||||||
|
const res = await fetch(url, { method: "DELETE" });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
handleUnauthorized(res);
|
||||||
const text = await res.text().catch(() => res.statusText);
|
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') ?? '';
|
const ct = res.headers.get("content-type") ?? "";
|
||||||
if (ct.includes('application/json')) return res.json() as Promise<T>;
|
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;
|
return undefined as unknown as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuthStatus {
|
||||||
|
auth_required: boolean;
|
||||||
|
authenticated: boolean;
|
||||||
|
username: string | null;
|
||||||
|
expires_at: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
gpus: () => get<GpuInfo[]>('/gpus'),
|
/** Auth */
|
||||||
gpu: (gpuIndex: number) => get<GpuInfo>('/gpu', gpuIndex),
|
authStatus: () => get<AuthStatus>("/auth/status"),
|
||||||
curve: (gpuIndex: number) => get<CurveState>('/curve', gpuIndex),
|
login: (username: string, password: string) =>
|
||||||
ranges: (gpuIndex: number) => get<Record<string, { min_khz: number; max_khz: number }>>('/ranges', gpuIndex),
|
post<{ ok: boolean; username: string; expires_at: number; token: string }>(
|
||||||
voltage: (gpuIndex: number) => get<{ voltage_uv: number; voltage_mv: number }>('/voltage', gpuIndex),
|
"/auth/login",
|
||||||
monitor: (gpuIndex: number) => get<MonitoringSample>('/monitor', gpuIndex),
|
{ username, password },
|
||||||
snapshots: (gpuIndex: number) => get<SnapshotInfo[]>('/snapshots', gpuIndex),
|
),
|
||||||
|
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 } */
|
/** Write per-point frequency deltas. deltas: { pointIndex: deltaKhz } */
|
||||||
writeDeltas: (deltas: Record<number, number>, gpuIndex: number) =>
|
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. */
|
/** 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 */
|
/** Get performance limits mapping */
|
||||||
limits: (gpuIndex: number) => get<LimitsState>('/limits', gpuIndex),
|
limits: (gpuIndex: number) => get<LimitsState>("/limits", gpuIndex),
|
||||||
|
|
||||||
/** Set performance limits */
|
/** Set performance limits */
|
||||||
updateLimits: (updates: Partial<LimitsState>, gpuIndex: number) =>
|
updateLimits: (updates: Partial<LimitsState>, gpuIndex: number) =>
|
||||||
post('/limits', updates, gpuIndex),
|
post("/limits", updates, gpuIndex),
|
||||||
|
|
||||||
/** Reset power limit and memory offset to hardware defaults */
|
/** 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 */
|
/** Profile Management */
|
||||||
profiles: (gpuIndex: number) => get<{ profiles: ProfileData[], active: string | null, auto_load: string | null }>('/profiles', gpuIndex),
|
profiles: (gpuIndex: number) =>
|
||||||
saveProfile: (name: string, gpuIndex: number) => post<{ ok: boolean; filepath: string }>('/profiles', { name }, gpuIndex),
|
get<{
|
||||||
applyProfile: (name: string, gpuIndex: number) => post(`/profiles/${encodeURIComponent(name)}/apply`, undefined, gpuIndex),
|
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)}`),
|
deleteProfile: (name: string) => del(`/profiles/${encodeURIComponent(name)}`),
|
||||||
renameProfile: (oldName: string, newName: string) =>
|
renameProfile: (oldName: string, newName: string) =>
|
||||||
post(`/profiles/${encodeURIComponent(oldName)}/rename`, { new_name: newName }),
|
post(`/profiles/${encodeURIComponent(oldName)}/rename`, {
|
||||||
|
new_name: newName,
|
||||||
|
}),
|
||||||
|
|
||||||
/** Server config */
|
/** Server config */
|
||||||
setAutoLoadProfile: (name: string | null, gpuIndex: number) =>
|
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 */
|
/** Fan control */
|
||||||
fans: (gpuIndex: number) => get<FanState>('/fans', gpuIndex),
|
fans: (gpuIndex: number) => get<FanState>("/fans", gpuIndex),
|
||||||
updateFans: (curve: FanPoint[], gpuIndex: number) =>
|
updateFans: (curve: FanPoint[], gpuIndex: number) =>
|
||||||
post('/fans', { curve }, gpuIndex),
|
post("/fans", { curve }, gpuIndex),
|
||||||
resetFans: (gpuIndex: number) => post('/fans/reset', undefined, gpuIndex),
|
resetFans: (gpuIndex: number) => post("/fans/reset", undefined, gpuIndex),
|
||||||
setFanSpeed: (fanPct: number, gpuIndex: number) =>
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,24 @@
|
|||||||
import { Cpu, Wifi, WifiOff, Loader, Terminal, ChevronDown } from 'lucide-react';
|
import {
|
||||||
import type { GpuInfo, MonitoringSample } from '../../types';
|
Cpu,
|
||||||
import { fmt } from '../../utils/units';
|
Wifi,
|
||||||
import { useCurveStore } from '../../store/curveStore';
|
WifiOff,
|
||||||
import { useState, useRef, useEffect } from 'react';
|
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 {
|
interface Props {
|
||||||
gpuInfo: GpuInfo | null;
|
gpuInfo: GpuInfo | null;
|
||||||
wsStatus: 'connecting' | 'connected' | 'disconnected';
|
wsStatus: "connecting" | "connected" | "disconnected";
|
||||||
monitor: MonitoringSample | null;
|
monitor: MonitoringSample | null;
|
||||||
|
user?: string | null;
|
||||||
|
onLogout?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusIcon = {
|
const statusIcon = {
|
||||||
@@ -17,19 +28,29 @@ const statusIcon = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusText = {
|
const statusText = {
|
||||||
connected: 'Connected',
|
connected: "Connected",
|
||||||
connecting: 'Connecting…',
|
connecting: "Connecting…",
|
||||||
disconnected: 'Disconnected',
|
disconnected: "Disconnected",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) {
|
export function StatusBar({
|
||||||
const { availableGpus, selectedGpuIndex, setSelectedGpuIndex } = useCurveStore();
|
gpuInfo,
|
||||||
|
wsStatus,
|
||||||
|
monitor,
|
||||||
|
user,
|
||||||
|
onLogout,
|
||||||
|
}: Props) {
|
||||||
|
const { availableGpus, selectedGpuIndex, setSelectedGpuIndex } =
|
||||||
|
useCurveStore();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: MouseEvent) {
|
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);
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,11 +75,17 @@ export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) {
|
|||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="flex items-center gap-1.5 px-2 py-1 -ml-2 rounded hover:bg-zinc-800 transition-colors group"
|
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">
|
<span className="font-medium text-zinc-200 truncate group-hover:text-white transition-colors">
|
||||||
{gpuInfo?.name ?? 'No Device'}
|
{gpuInfo?.name ?? "No Device"}
|
||||||
</span>
|
</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>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
@@ -71,12 +98,23 @@ export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) {
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full text-left px-3 py-2 text-sm flex items-center gap-2 hover:bg-zinc-700 transition-colors ${
|
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="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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -84,9 +122,12 @@ export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) {
|
|||||||
</div>
|
</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">
|
<span className="font-medium text-zinc-200 truncate">
|
||||||
{gpuInfo?.name ?? 'No Device'}
|
{gpuInfo?.name ?? "No Device"}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -101,28 +142,75 @@ export function StatusBar({ gpuInfo, wsStatus, monitor }: Props) {
|
|||||||
|
|
||||||
{/* Centre: live stats */}
|
{/* Centre: live stats */}
|
||||||
<div className="flex-1 flex items-center justify-center gap-5 min-w-0 hidden lg:flex">
|
<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
|
||||||
<StatPill label="Power" value={fmt.watts(monitor?.power_w)} color="text-pink-400" />
|
label="Temp"
|
||||||
<StatPill label="Fan" value={fmt.pct(monitor?.fan_pct)} color="text-blue-400" />
|
value={fmt.celsius(monitor?.temp_c)}
|
||||||
<StatPill label="GPU Util" value={fmt.pct(monitor?.gpu_util_pct)} color="text-yellow-400" />
|
color="text-orange-400"
|
||||||
<StatPill label="Mem Util" value={fmt.pct(monitor?.mem_util_pct)} color="text-sky-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>
|
</div>
|
||||||
|
|
||||||
{/* Right: connection status */}
|
{/* Right: user + connection status */}
|
||||||
<div className="flex items-center gap-1.5 text-zinc-400 shrink-0">
|
<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]}
|
{statusIcon[wsStatus]}
|
||||||
<span className="hidden sm:inline">{statusText[wsStatus]}</span>
|
<span className="hidden sm:inline">{statusText[wsStatus]}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatPill({ label, value, color }: { label: string; value: string; color: string }) {
|
function StatPill({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
color: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className="text-zinc-500 text-xs">{label}</span>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+289
@@ -0,0 +1,289 @@
|
|||||||
|
"""Authentication: bcrypt user store, 24-hour sessions, dual-mode gating.
|
||||||
|
|
||||||
|
Dual mode
|
||||||
|
---------
|
||||||
|
- If the user store file exists and contains at least one user, the API
|
||||||
|
requires authentication for all ``/api/*`` and ``/ws/*`` endpoints
|
||||||
|
(except the public auth endpoints and ``/api/ping``).
|
||||||
|
- If the user store is absent or empty, the server runs with no
|
||||||
|
authentication at all — exactly like before.
|
||||||
|
|
||||||
|
Passwords are hashed with bcrypt (``$2b$``). Plaintext passwords are never
|
||||||
|
stored. Sessions are in-memory random 256-bit tokens valid for 24 hours;
|
||||||
|
they are presented either as an HttpOnly cookie (browser) or as an
|
||||||
|
``Authorization: Bearer <token>`` header (CLI / scripts).
|
||||||
|
|
||||||
|
A simple per-IP lockout slows down brute-force attempts against login.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SESSION_TTL_S = 24 * 60 * 60 # sessions live for 24 hours
|
||||||
|
COOKIE_NAME = "nvcurve_session"
|
||||||
|
BCRYPT_ROUNDS = 12
|
||||||
|
MAX_PASSWORD_BYTES = 72 # bcrypt only uses the first 72 bytes
|
||||||
|
|
||||||
|
# Brute-force lockout: MAX_FAILURES within WINDOW_S → locked for LOCKOUT_S.
|
||||||
|
_MAX_FAILURES = 10
|
||||||
|
_WINDOW_S = 300
|
||||||
|
_LOCKOUT_S = 900
|
||||||
|
|
||||||
|
# ── User store ────────────────────────────────────────────────────────────────
|
||||||
|
# File format (JSON): {"users": {"alice": "$2b$12$...", "bob": "$2b$12$..."}}
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_username(name: str) -> bool:
|
||||||
|
if not name or len(name) > 32:
|
||||||
|
return False
|
||||||
|
return all(c.isalnum() or c in "_-." for c in name)
|
||||||
|
|
||||||
|
|
||||||
|
def load_users(path: str) -> dict[str, str]:
|
||||||
|
"""Load the user store. Returns {} if the file is absent or unreadable."""
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
users = data.get("users", {})
|
||||||
|
if not isinstance(users, dict):
|
||||||
|
return {}
|
||||||
|
return {k: v for k, v in users.items() if isinstance(k, str) and isinstance(v, str)}
|
||||||
|
|
||||||
|
|
||||||
|
def save_users(path: str, users: dict[str, str]) -> None:
|
||||||
|
"""Write the user store with restrictive permissions (0600, root-owned)."""
|
||||||
|
directory = os.path.dirname(path)
|
||||||
|
if directory:
|
||||||
|
try:
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"cannot create user store directory {directory!r}: {exc}"
|
||||||
|
) from exc
|
||||||
|
try:
|
||||||
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
except OSError as exc:
|
||||||
|
raise ValueError(f"cannot write user store {path!r}: {exc}") from exc
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
json.dump({"users": users}, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def auth_enabled(path: str) -> bool:
|
||||||
|
"""True if at least one user is configured (→ authentication required)."""
|
||||||
|
return bool(load_users(path))
|
||||||
|
|
||||||
|
|
||||||
|
def list_users(path: str) -> list[str]:
|
||||||
|
return sorted(load_users(path))
|
||||||
|
|
||||||
|
|
||||||
|
def user_store_state(path: str) -> str:
|
||||||
|
"""Report the user store state without requiring read access.
|
||||||
|
|
||||||
|
Returns one of:
|
||||||
|
- "absent": no file (auth disabled)
|
||||||
|
- "empty": file exists but has no users (auth disabled)
|
||||||
|
- "unreadable": file exists but can't be read (e.g. non-root on a 0600
|
||||||
|
root-owned store) — auth is almost certainly enabled
|
||||||
|
- "enabled": file readable and has at least one user (auth enabled)
|
||||||
|
"""
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return "absent"
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return "unreadable"
|
||||||
|
users = data.get("users", {}) if isinstance(data, dict) else {}
|
||||||
|
if not isinstance(users, dict) or not users:
|
||||||
|
return "empty"
|
||||||
|
return "enabled"
|
||||||
|
|
||||||
|
|
||||||
|
def add_user(path: str, username: str, password: str) -> None:
|
||||||
|
"""Create a new user with a bcrypt-hashed password."""
|
||||||
|
if not _valid_username(username):
|
||||||
|
raise ValueError(
|
||||||
|
f"invalid username {username!r} — use 1–32 chars: letters, digits, '_', '-', '.'"
|
||||||
|
)
|
||||||
|
if not password:
|
||||||
|
raise ValueError("password must not be empty")
|
||||||
|
if len(password.encode("utf-8")) > MAX_PASSWORD_BYTES:
|
||||||
|
raise ValueError(f"password too long (max {MAX_PASSWORD_BYTES} bytes)")
|
||||||
|
users = load_users(path)
|
||||||
|
if username in users:
|
||||||
|
raise ValueError(f"user {username!r} already exists")
|
||||||
|
users[username] = hash_password(password)
|
||||||
|
save_users(path, users)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_user(path: str, username: str) -> bool:
|
||||||
|
"""Remove a user. Returns False if the user did not exist."""
|
||||||
|
users = load_users(path)
|
||||||
|
if username not in users:
|
||||||
|
return False
|
||||||
|
del users[username]
|
||||||
|
save_users(path, users)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def set_password(path: str, username: str, password: str) -> None:
|
||||||
|
"""Replace the password of an existing user."""
|
||||||
|
if not password:
|
||||||
|
raise ValueError("password must not be empty")
|
||||||
|
if len(password.encode("utf-8")) > MAX_PASSWORD_BYTES:
|
||||||
|
raise ValueError(f"password too long (max {MAX_PASSWORD_BYTES} bytes)")
|
||||||
|
users = load_users(path)
|
||||||
|
if username not in users:
|
||||||
|
raise ValueError(f"user {username!r} does not exist")
|
||||||
|
users[username] = hash_password(password)
|
||||||
|
save_users(path, users)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Password hashing ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _pw_bytes(plain: str) -> bytes:
|
||||||
|
"""Encode a plaintext password, truncating to bcrypt's 72-byte limit.
|
||||||
|
|
||||||
|
bcrypt only uses the first 72 bytes and raises ValueError for longer
|
||||||
|
input; truncating here keeps the check total (no 500 on an over-long
|
||||||
|
login password) and matches what bcrypt does internally.
|
||||||
|
"""
|
||||||
|
return plain.encode("utf-8")[:MAX_PASSWORD_BYTES]
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(plain: str) -> str:
|
||||||
|
"""Hash a plaintext password with bcrypt ($2b$)."""
|
||||||
|
return bcrypt.hashpw(_pw_bytes(plain), bcrypt.gensalt(rounds=BCRYPT_ROUNDS)).decode(
|
||||||
|
"ascii"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
"""Constant-time check of a plaintext password against a bcrypt hash."""
|
||||||
|
try:
|
||||||
|
return bcrypt.checkpw(_pw_bytes(plain), hashed.encode("ascii"))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
_dummy_hash: bytes | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def check_credentials(users: dict[str, str], username: str, password: str) -> bool:
|
||||||
|
"""Verify username+password. Unknown users burn the same bcrypt time as
|
||||||
|
known ones so response timing does not reveal which usernames exist."""
|
||||||
|
global _dummy_hash
|
||||||
|
hashed = users.get(username)
|
||||||
|
if hashed is None:
|
||||||
|
if _dummy_hash is None:
|
||||||
|
_dummy_hash = bcrypt.hashpw(
|
||||||
|
b"nvcurve-timing-equalizer", bcrypt.gensalt(rounds=BCRYPT_ROUNDS)
|
||||||
|
)
|
||||||
|
bcrypt.checkpw(_pw_bytes(password), _dummy_hash)
|
||||||
|
return False
|
||||||
|
return verify_password(password, hashed)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sessions (in-memory, 24 h) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_sessions: dict[str, dict[str, Any]] = {} # token -> {"username", "expires_at"}
|
||||||
|
|
||||||
|
|
||||||
|
def create_session(username: str) -> tuple[str, float]:
|
||||||
|
"""Create a session. Returns (token, expires_at_unix)."""
|
||||||
|
_purge_expired()
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
expires_at = time.time() + SESSION_TTL_S
|
||||||
|
_sessions[token] = {"username": username, "expires_at": expires_at}
|
||||||
|
return token, expires_at
|
||||||
|
|
||||||
|
|
||||||
|
def get_session(token: str | None) -> str | None:
|
||||||
|
"""Return the username for a valid, unexpired token — else None."""
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
sess = _sessions.get(token)
|
||||||
|
if sess is None:
|
||||||
|
return None
|
||||||
|
if time.time() > sess["expires_at"]:
|
||||||
|
del _sessions[token]
|
||||||
|
return None
|
||||||
|
return sess["username"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_expires_at(token: str | None) -> float | None:
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
sess = _sessions.get(token)
|
||||||
|
if sess is None:
|
||||||
|
return None
|
||||||
|
return sess["expires_at"]
|
||||||
|
|
||||||
|
|
||||||
|
def destroy_session(token: str | None) -> None:
|
||||||
|
if token:
|
||||||
|
_sessions.pop(token, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_expired() -> None:
|
||||||
|
now = time.time()
|
||||||
|
for t in [t for t, s in _sessions.items() if now > s["expires_at"]]:
|
||||||
|
del _sessions[t]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Brute-force lockout (per client IP) ───────────────────────────────────────
|
||||||
|
|
||||||
|
_failures: dict[str, list[float]] = {} # ip -> recent failure timestamps
|
||||||
|
_lockouts: dict[str, float] = {} # ip -> unix time when lockout lifts
|
||||||
|
|
||||||
|
|
||||||
|
def is_locked_out(ip: str) -> bool:
|
||||||
|
until = _lockouts.get(ip, 0.0)
|
||||||
|
if until > time.time():
|
||||||
|
return True
|
||||||
|
if until:
|
||||||
|
del _lockouts[ip]
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def record_failure(ip: str) -> None:
|
||||||
|
now = time.time()
|
||||||
|
recent = [t for t in _failures.get(ip, []) if now - t < _WINDOW_S]
|
||||||
|
recent.append(now)
|
||||||
|
_failures[ip] = recent
|
||||||
|
if len(recent) >= _MAX_FAILURES:
|
||||||
|
_lockouts[ip] = now + _LOCKOUT_S
|
||||||
|
del _failures[ip]
|
||||||
|
|
||||||
|
|
||||||
|
def clear_failures(ip: str) -> None:
|
||||||
|
_failures.pop(ip, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Token extraction ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def extract_token(request: Any) -> str | None:
|
||||||
|
"""Pull the session token from an Authorization header or the cookie.
|
||||||
|
|
||||||
|
Works with both starlette Request and WebSocket objects (both expose
|
||||||
|
``.headers`` / ``.cookies``).
|
||||||
|
"""
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
if auth_header.lower().startswith("bearer "):
|
||||||
|
token = auth_header[7:].strip()
|
||||||
|
if token:
|
||||||
|
return token
|
||||||
|
return request.cookies.get(COOKIE_NAME)
|
||||||
+575
-192
File diff suppressed because it is too large.
Load diff
+43
-10
@@ -1,8 +1,9 @@
|
|||||||
"""HTTP client for communicating with a running nvcurve server."""
|
"""HTTP client for communicating with a running nvcurve server."""
|
||||||
|
|
||||||
import httpx
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
DEFAULT_BASE = "http://127.0.0.1:8042"
|
DEFAULT_BASE = "http://127.0.0.1:8042"
|
||||||
_TIMEOUT = 5.0
|
_TIMEOUT = 5.0
|
||||||
|
|
||||||
@@ -19,14 +20,20 @@ class ApiError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class NvCurveClient:
|
class NvCurveClient:
|
||||||
def __init__(self, base: str = DEFAULT_BASE, gpu_index: int = 0):
|
def __init__(
|
||||||
|
self, base: str = DEFAULT_BASE, gpu_index: int = 0, token: str | None = None
|
||||||
|
):
|
||||||
self._base = base.rstrip("/")
|
self._base = base.rstrip("/")
|
||||||
self.gpu_index = gpu_index
|
self.gpu_index = gpu_index
|
||||||
|
self.token = token
|
||||||
|
|
||||||
def _url(self, path: str) -> str:
|
def _url(self, path: str) -> str:
|
||||||
sep = "&" if "?" in path else "?"
|
sep = "&" if "?" in path else "?"
|
||||||
return f"{self._base}{path}{sep}gpu_index={self.gpu_index}"
|
return f"{self._base}{path}{sep}gpu_index={self.gpu_index}"
|
||||||
|
|
||||||
|
def _headers(self) -> dict:
|
||||||
|
return {"Authorization": f"Bearer {self.token}"} if self.token else {}
|
||||||
|
|
||||||
def _raise(self, r: httpx.Response) -> None:
|
def _raise(self, r: httpx.Response) -> None:
|
||||||
if r.is_error:
|
if r.is_error:
|
||||||
try:
|
try:
|
||||||
@@ -37,35 +44,58 @@ class NvCurveClient:
|
|||||||
|
|
||||||
def _get(self, path: str) -> Any:
|
def _get(self, path: str) -> Any:
|
||||||
try:
|
try:
|
||||||
r = httpx.get(self._url(path), timeout=_TIMEOUT)
|
r = httpx.get(self._url(path), headers=self._headers(), timeout=_TIMEOUT)
|
||||||
except httpx.ConnectError:
|
except httpx.ConnectError:
|
||||||
raise ServerNotRunning()
|
raise ServerNotRunning() from None
|
||||||
self._raise(r)
|
self._raise(r)
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
def _post(self, path: str, body: Any = None) -> Any:
|
def _post(self, path: str, body: Any = None) -> Any:
|
||||||
try:
|
try:
|
||||||
r = httpx.post(self._url(path), json=body, timeout=_TIMEOUT)
|
r = httpx.post(
|
||||||
|
self._url(path), json=body, headers=self._headers(), timeout=_TIMEOUT
|
||||||
|
)
|
||||||
except httpx.ConnectError:
|
except httpx.ConnectError:
|
||||||
raise ServerNotRunning()
|
raise ServerNotRunning() from None
|
||||||
self._raise(r)
|
self._raise(r)
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
def _delete(self, path: str) -> Any:
|
def _delete(self, path: str) -> Any:
|
||||||
try:
|
try:
|
||||||
r = httpx.delete(self._url(path), timeout=_TIMEOUT)
|
r = httpx.delete(self._url(path), headers=self._headers(), timeout=_TIMEOUT)
|
||||||
except httpx.ConnectError:
|
except httpx.ConnectError:
|
||||||
raise ServerNotRunning()
|
raise ServerNotRunning() from None
|
||||||
self._raise(r)
|
self._raise(r)
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
def ping(self) -> bool:
|
def ping(self) -> bool:
|
||||||
try:
|
try:
|
||||||
httpx.get(self._url("/api/gpu"), timeout=1.0)
|
httpx.get(self._base + "/api/ping", timeout=1.0)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# ── Auth ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def auth_status(self) -> dict:
|
||||||
|
"""Returns {auth_required, authenticated, username, expires_at}."""
|
||||||
|
return self._get("/api/auth/status")
|
||||||
|
|
||||||
|
def login(self, username: str, password: str) -> dict:
|
||||||
|
"""Authenticate and store the session token for subsequent calls."""
|
||||||
|
try:
|
||||||
|
r = httpx.post(
|
||||||
|
f"{self._base}/api/auth/login",
|
||||||
|
json={"username": username, "password": password},
|
||||||
|
timeout=_TIMEOUT,
|
||||||
|
)
|
||||||
|
except httpx.ConnectError:
|
||||||
|
raise ServerNotRunning() from None
|
||||||
|
self._raise(r)
|
||||||
|
data = r.json()
|
||||||
|
self.token = data.get("token")
|
||||||
|
return data
|
||||||
|
|
||||||
def gpus(self) -> list:
|
def gpus(self) -> list:
|
||||||
return self._get("/api/gpus")
|
return self._get("/api/gpus")
|
||||||
|
|
||||||
@@ -140,7 +170,10 @@ class NvCurveClient:
|
|||||||
return self._get("/api/config")
|
return self._get("/api/config")
|
||||||
|
|
||||||
def config_update(self, auto_load_profile: str | None, gpu_index: int = 0) -> dict:
|
def config_update(self, auto_load_profile: str | None, gpu_index: int = 0) -> dict:
|
||||||
return self._post("/api/config", {"auto_load_profile": auto_load_profile, "gpu_index": gpu_index})
|
return self._post(
|
||||||
|
"/api/config",
|
||||||
|
{"auto_load_profile": auto_load_profile, "gpu_index": gpu_index},
|
||||||
|
)
|
||||||
|
|
||||||
# ── Server control ───────────────────────────────────────────────────────
|
# ── Server control ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -1,6 +1,5 @@
|
|||||||
"""User-configurable settings with sensible defaults."""
|
"""User-configurable settings with sensible defaults."""
|
||||||
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
@@ -21,6 +20,11 @@ class Config:
|
|||||||
snapshot_dir: str = "/var/cache/nvcurve/snapshots"
|
snapshot_dir: str = "/var/cache/nvcurve/snapshots"
|
||||||
profile_dir: str = "/etc/nvcurve/profiles"
|
profile_dir: str = "/etc/nvcurve/profiles"
|
||||||
|
|
||||||
|
# Authentication: path to the bcrypt user store. If the file exists and
|
||||||
|
# contains at least one user, the API requires login (dual mode). If it is
|
||||||
|
# absent or empty, the server runs with no authentication.
|
||||||
|
users_file: str = "/etc/nvcurve/users.json"
|
||||||
|
|
||||||
# Per-GPU default profiles: applied automatically on server startup.
|
# Per-GPU default profiles: applied automatically on server startup.
|
||||||
# Key = stable GPU identifier (UUID string, "pci:{bus_id}", or "idx:{n}" fallback).
|
# Key = stable GPU identifier (UUID string, "pci:{bus_id}", or "idx:{n}" fallback).
|
||||||
# Value = profile name (str).
|
# Value = profile name (str).
|
||||||
|
|||||||
+56
-17
@@ -15,6 +15,7 @@ Requires root.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -22,6 +23,8 @@ import signal
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
|
||||||
log = logging.getLogger("nvcurve.daemon")
|
log = logging.getLogger("nvcurve.daemon")
|
||||||
|
|
||||||
SOCKET_PATH = "/run/nvcurve-daemon.sock"
|
SOCKET_PATH = "/run/nvcurve-daemon.sock"
|
||||||
@@ -29,20 +32,36 @@ _PERSISTENT_CONFIG_FILE = "/etc/nvcurve/config.json"
|
|||||||
|
|
||||||
# Global server subprocess — only touched from the asyncio event loop.
|
# Global server subprocess — only touched from the asyncio event loop.
|
||||||
_server_proc: subprocess.Popen | None = None
|
_server_proc: subprocess.Popen | None = None
|
||||||
_cfg = None # Config instance, set in run()
|
_cfg: Config | None = None # Config instance, set in run()
|
||||||
|
|
||||||
|
|
||||||
# ── Socket command handlers ────────────────────────────────────────────────────
|
# ── Socket command handlers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def _handle_serve_start(host: str, port: int) -> dict:
|
async def _handle_serve_start(host: str, port: int) -> dict:
|
||||||
global _server_proc
|
global _server_proc
|
||||||
if _server_proc is not None and _server_proc.poll() is None:
|
if _server_proc is not None and _server_proc.poll() is None:
|
||||||
return {"ok": False, "error": "web server already running", "pid": _server_proc.pid}
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "web server already running",
|
||||||
|
"pid": _server_proc.pid,
|
||||||
|
}
|
||||||
|
|
||||||
cmd = [sys.executable, "-m", "nvcurve", "serve", "start",
|
cmd = [
|
||||||
"--host", host, "--port", str(port), "--direct"]
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"nvcurve",
|
||||||
|
"serve",
|
||||||
|
"start",
|
||||||
|
"--host",
|
||||||
|
host,
|
||||||
|
"--port",
|
||||||
|
str(port),
|
||||||
|
"--direct",
|
||||||
|
]
|
||||||
log_path = "/var/log/nvcurve-server.log"
|
log_path = "/var/log/nvcurve-server.log"
|
||||||
log.info("Starting web server on %s:%d (log: %s)", host, port, log_path)
|
log.info("Starting web server on %s:%d (log: %s)", host, port, log_path)
|
||||||
|
try:
|
||||||
with open(log_path, "a") as lf:
|
with open(log_path, "a") as lf:
|
||||||
_server_proc = subprocess.Popen(
|
_server_proc = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
@@ -50,6 +69,8 @@ async def _handle_serve_start(host: str, port: int) -> dict:
|
|||||||
stderr=lf,
|
stderr=lf,
|
||||||
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
|
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
|
||||||
)
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
return {"ok": False, "error": f"cannot open log file {log_path}: {exc}"}
|
||||||
log.info("Web server started (PID %d)", _server_proc.pid)
|
log.info("Web server started (PID %d)", _server_proc.pid)
|
||||||
return {"ok": True, "pid": _server_proc.pid}
|
return {"ok": True, "pid": _server_proc.pid}
|
||||||
|
|
||||||
@@ -62,9 +83,7 @@ async def _handle_serve_stop() -> dict:
|
|||||||
log.info("Stopping web server (PID %d)…", _server_proc.pid)
|
log.info("Stopping web server (PID %d)…", _server_proc.pid)
|
||||||
_server_proc.terminate()
|
_server_proc.terminate()
|
||||||
try:
|
try:
|
||||||
await asyncio.get_running_loop().run_in_executor(
|
await asyncio.get_running_loop().run_in_executor(None, _server_proc.wait, 5)
|
||||||
None, _server_proc.wait, 5
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
_server_proc.kill()
|
_server_proc.kill()
|
||||||
_server_proc = None
|
_server_proc = None
|
||||||
@@ -84,6 +103,8 @@ async def _dispatch(req: dict) -> dict:
|
|||||||
if cmd == "ping":
|
if cmd == "ping":
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
elif cmd == "serve_start":
|
elif cmd == "serve_start":
|
||||||
|
if _cfg is None:
|
||||||
|
return {"ok": False, "error": "config not initialized"}
|
||||||
host = req.get("host", _cfg.host)
|
host = req.get("host", _cfg.host)
|
||||||
port = req.get("port", _cfg.port)
|
port = req.get("port", _cfg.port)
|
||||||
return await _handle_serve_start(host, port)
|
return await _handle_serve_start(host, port)
|
||||||
@@ -110,20 +131,17 @@ async def _handle_client(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
resp = {"ok": False, "error": str(exc)}
|
resp = {"ok": False, "error": str(exc)}
|
||||||
finally:
|
finally:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
writer.write(json.dumps(resp).encode() + b"\n")
|
writer.write(json.dumps(resp).encode() + b"\n")
|
||||||
await writer.drain()
|
await writer.drain()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
writer.close()
|
writer.close()
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
await writer.wait_closed()
|
await writer.wait_closed()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# ── Entrypoint ─────────────────────────────────────────────────────────────────
|
# ── Entrypoint ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _load_persistent_config() -> dict:
|
def _load_persistent_config() -> dict:
|
||||||
try:
|
try:
|
||||||
with open(_PERSISTENT_CONFIG_FILE) as f:
|
with open(_PERSISTENT_CONFIG_FILE) as f:
|
||||||
@@ -147,10 +165,16 @@ def run() -> None:
|
|||||||
|
|
||||||
cfg_data = _load_persistent_config()
|
cfg_data = _load_persistent_config()
|
||||||
|
|
||||||
from .config import Config
|
|
||||||
_cfg = Config()
|
_cfg = Config()
|
||||||
for key in ("max_delta_khz", "auto_snapshot", "max_snapshots",
|
for key in (
|
||||||
"snapshot_dir", "profile_dir", "host", "port"):
|
"max_delta_khz",
|
||||||
|
"auto_snapshot",
|
||||||
|
"max_snapshots",
|
||||||
|
"snapshot_dir",
|
||||||
|
"profile_dir",
|
||||||
|
"host",
|
||||||
|
"port",
|
||||||
|
):
|
||||||
if key in cfg_data:
|
if key in cfg_data:
|
||||||
setattr(_cfg, key, cfg_data[key])
|
setattr(_cfg, key, cfg_data[key])
|
||||||
|
|
||||||
@@ -175,13 +199,25 @@ async def _serve_socket(auto_serve: bool = False) -> None:
|
|||||||
|
|
||||||
# Clean up stale socket from a previous (unclean) run.
|
# Clean up stale socket from a previous (unclean) run.
|
||||||
if os.path.exists(SOCKET_PATH):
|
if os.path.exists(SOCKET_PATH):
|
||||||
|
try:
|
||||||
os.unlink(SOCKET_PATH)
|
os.unlink(SOCKET_PATH)
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("Could not remove stale socket %s: %s", SOCKET_PATH, exc)
|
||||||
|
|
||||||
server = await asyncio.start_unix_server(_handle_client, path=SOCKET_PATH)
|
server = await asyncio.start_unix_server(_handle_client, path=SOCKET_PATH)
|
||||||
os.chmod(SOCKET_PATH, 0o666) # allow non-root CLI to connect
|
# The socket must be connectable by unprivileged users: the CLI runs as the
|
||||||
|
# regular user and talks to this root daemon over the socket. 0o666 is
|
||||||
|
# intentional (standard for /run daemon sockets).
|
||||||
|
# pi-lens-ignore: S103
|
||||||
|
os.chmod(
|
||||||
|
SOCKET_PATH, 0o666
|
||||||
|
) # nosemgrep: python.lang.security.audit.insecure-file-permissions.insecure-file-permissions
|
||||||
log.info("Daemon listening on %s", SOCKET_PATH)
|
log.info("Daemon listening on %s", SOCKET_PATH)
|
||||||
|
|
||||||
if auto_serve:
|
if auto_serve:
|
||||||
|
if _cfg is None:
|
||||||
|
log.warning("auto_serve requested but config not initialized")
|
||||||
|
else:
|
||||||
log.info("auto_serve enabled — starting web server on boot")
|
log.info("auto_serve enabled — starting web server on boot")
|
||||||
await _handle_serve_start(_cfg.host, _cfg.port)
|
await _handle_serve_start(_cfg.host, _cfg.port)
|
||||||
|
|
||||||
@@ -206,4 +242,7 @@ async def _serve_socket(auto_serve: bool = False) -> None:
|
|||||||
_server_proc.kill()
|
_server_proc.kill()
|
||||||
|
|
||||||
if os.path.exists(SOCKET_PATH):
|
if os.path.exists(SOCKET_PATH):
|
||||||
|
try:
|
||||||
os.unlink(SOCKET_PATH)
|
os.unlink(SOCKET_PATH)
|
||||||
|
except OSError as exc:
|
||||||
|
log.warning("Could not remove socket %s on shutdown: %s", SOCKET_PATH, exc)
|
||||||
+384
-94
@@ -7,17 +7,36 @@ Requires root (NvAPI needs it).
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from . import auth
|
||||||
from .config import Config, default_config
|
from .config import Config, default_config
|
||||||
from .hal.gpu import get_gpu, discover_gpus
|
from .hal.fans import (
|
||||||
|
get_fan_info,
|
||||||
|
get_temp,
|
||||||
|
interpolate_fan_speed,
|
||||||
|
reset_fan,
|
||||||
|
set_fan_speed,
|
||||||
|
validate_curve,
|
||||||
|
)
|
||||||
|
from .hal.gpu import discover_gpus, get_gpu
|
||||||
|
from .hal.limits import (
|
||||||
|
get_clock_offsets,
|
||||||
|
get_mem_offset_range,
|
||||||
|
get_power_limit,
|
||||||
|
set_clock_offsets,
|
||||||
|
set_power_limit,
|
||||||
|
)
|
||||||
from .hal.monitoring import (
|
from .hal.monitoring import (
|
||||||
get_driver_version,
|
get_driver_version,
|
||||||
get_vram_total,
|
get_vram_total,
|
||||||
@@ -28,41 +47,29 @@ from .hal.monitoring import (
|
|||||||
from .hal.ranges import get_clock_ranges
|
from .hal.ranges import get_clock_ranges
|
||||||
from .hal.snapshot import (
|
from .hal.snapshot import (
|
||||||
list_snapshots,
|
list_snapshots,
|
||||||
|
)
|
||||||
|
from .hal.snapshot import (
|
||||||
restore as snapshot_restore,
|
restore as snapshot_restore,
|
||||||
|
)
|
||||||
|
from .hal.snapshot import (
|
||||||
save as snapshot_save,
|
save as snapshot_save,
|
||||||
)
|
)
|
||||||
from .hal.limits import (
|
|
||||||
get_power_limit,
|
|
||||||
set_power_limit,
|
|
||||||
get_clock_offsets,
|
|
||||||
set_clock_offsets,
|
|
||||||
get_mem_offset_range,
|
|
||||||
)
|
|
||||||
from .hal.fans import (
|
|
||||||
get_fan_info,
|
|
||||||
set_fan_speed,
|
|
||||||
reset_fan,
|
|
||||||
get_temp,
|
|
||||||
interpolate_fan_speed,
|
|
||||||
validate_curve,
|
|
||||||
)
|
|
||||||
from .profiles.native import (
|
|
||||||
ProfileData,
|
|
||||||
save_profile,
|
|
||||||
load_profile,
|
|
||||||
list_profiles,
|
|
||||||
delete_profile,
|
|
||||||
rename_profile,
|
|
||||||
)
|
|
||||||
from .hal.vfcurve import (
|
from .hal.vfcurve import (
|
||||||
read_clock_offsets,
|
read_clock_offsets,
|
||||||
read_curve,
|
read_curve,
|
||||||
read_vfp_curve,
|
|
||||||
reset_offsets,
|
reset_offsets,
|
||||||
write_global_offset,
|
write_global_offset,
|
||||||
write_offsets,
|
write_offsets,
|
||||||
)
|
)
|
||||||
from .safety import validate_write, check_negative_freq_warnings
|
from .profiles.native import (
|
||||||
|
ProfileData,
|
||||||
|
delete_profile,
|
||||||
|
list_profiles,
|
||||||
|
load_profile,
|
||||||
|
rename_profile,
|
||||||
|
save_profile,
|
||||||
|
)
|
||||||
|
from .safety import check_negative_freq_warnings, validate_write
|
||||||
|
|
||||||
log = logging.getLogger("nvcurve.server")
|
log = logging.getLogger("nvcurve.server")
|
||||||
|
|
||||||
@@ -71,6 +78,7 @@ def _open_browser_as_user(url: str) -> None:
|
|||||||
"""Open URL as the original (non-root) user when running under sudo."""
|
"""Open URL as the original (non-root) user when running under sudo."""
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
sudo_user = os.environ.get("SUDO_USER")
|
sudo_user = os.environ.get("SUDO_USER")
|
||||||
if sudo_user and os.geteuid() == 0:
|
if sudo_user and os.geteuid() == 0:
|
||||||
try:
|
try:
|
||||||
@@ -83,8 +91,10 @@ def _open_browser_as_user(url: str) -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
import webbrowser
|
import webbrowser
|
||||||
|
|
||||||
webbrowser.open(url)
|
webbrowser.open(url)
|
||||||
|
|
||||||
|
|
||||||
# ── Shared app state ──────────────────────────────────────────────────────────
|
# ── Shared app state ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_state: dict[str, Any] = {
|
_state: dict[str, Any] = {
|
||||||
@@ -92,15 +102,18 @@ _state: dict[str, Any] = {
|
|||||||
"config": default_config,
|
"config": default_config,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_gpu_state(gpu_index: int) -> dict:
|
def _get_gpu_state(gpu_index: int) -> dict:
|
||||||
if gpu_index not in _state["gpus"]:
|
if gpu_index not in _state["gpus"]:
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
raise HTTPException(status_code=404, detail=f"GPU {gpu_index} not found")
|
raise HTTPException(status_code=404, detail=f"GPU {gpu_index} not found")
|
||||||
return _state["gpus"][gpu_index]
|
return _state["gpus"][gpu_index]
|
||||||
|
|
||||||
|
|
||||||
# ── Serialization helpers ─────────────────────────────────────────────────────
|
# ── Serialization helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _vfpoint_dict(p) -> dict:
|
def _vfpoint_dict(p) -> dict:
|
||||||
return {
|
return {
|
||||||
"index": p.index,
|
"index": p.index,
|
||||||
@@ -138,8 +151,12 @@ def _sample_dict(s) -> dict:
|
|||||||
"pstate_label": f"P{s.pstate}" if s.pstate is not None else None,
|
"pstate_label": f"P{s.pstate}" if s.pstate is not None else None,
|
||||||
"mem_used_bytes": s.mem_used_bytes,
|
"mem_used_bytes": s.mem_used_bytes,
|
||||||
"mem_total_bytes": s.mem_total_bytes,
|
"mem_total_bytes": s.mem_total_bytes,
|
||||||
"mem_used_mib": round(s.mem_used_bytes / (1024 ** 2), 1) if s.mem_used_bytes is not None else None,
|
"mem_used_mib": round(s.mem_used_bytes / (1024**2), 1)
|
||||||
"mem_total_mib": round(s.mem_total_bytes / (1024 ** 2), 1) if s.mem_total_bytes is not None else None,
|
if s.mem_used_bytes is not None
|
||||||
|
else None,
|
||||||
|
"mem_total_mib": round(s.mem_total_bytes / (1024**2), 1)
|
||||||
|
if s.mem_total_bytes is not None
|
||||||
|
else None,
|
||||||
"gpu_util_pct": s.gpu_util_pct,
|
"gpu_util_pct": s.gpu_util_pct,
|
||||||
"mem_util_pct": s.mem_util_pct,
|
"mem_util_pct": s.mem_util_pct,
|
||||||
}
|
}
|
||||||
@@ -147,6 +164,7 @@ def _sample_dict(s) -> dict:
|
|||||||
|
|
||||||
# ── WebSocket broadcast ───────────────────────────────────────────────────────
|
# ── WebSocket broadcast ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def _broadcast(clients: set, payload: dict) -> None:
|
async def _broadcast(clients: set, payload: dict) -> None:
|
||||||
"""Send JSON payload to all connected WebSocket clients, evict dead ones."""
|
"""Send JSON payload to all connected WebSocket clients, evict dead ones."""
|
||||||
dead = set()
|
dead = set()
|
||||||
@@ -160,6 +178,7 @@ async def _broadcast(clients: set, payload: dict) -> None:
|
|||||||
|
|
||||||
# ── Background monitoring poller ─────────────────────────────────────────────
|
# ── Background monitoring poller ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def _monitor_poller(gpu_index: int) -> None:
|
async def _monitor_poller(gpu_index: int) -> None:
|
||||||
"""Continuously poll GPU state and push to connected monitor WebSocket clients."""
|
"""Continuously poll GPU state and push to connected monitor WebSocket clients."""
|
||||||
cfg: Config = _state["config"]
|
cfg: Config = _state["config"]
|
||||||
@@ -198,6 +217,7 @@ async def _fan_poller(gpu_index: int) -> None:
|
|||||||
|
|
||||||
# ── Lifespan ──────────────────────────────────────────────────────────────────
|
# ── Lifespan ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
@@ -260,16 +280,28 @@ async def lifespan(app: FastAPI):
|
|||||||
if gpu_idx is None:
|
if gpu_idx is None:
|
||||||
log.warning("Auto-load: no GPU found with key %r — skipping", gpu_key)
|
log.warning("Auto-load: no GPU found with key %r — skipping", gpu_key)
|
||||||
continue
|
continue
|
||||||
log.info("Auto-loading profile %r on GPU %d (%s) [compat path]",
|
log.info(
|
||||||
profile_name, gpu_idx, gpu_key)
|
"Auto-loading profile %r on GPU %d (%s) [compat path]",
|
||||||
|
profile_name,
|
||||||
|
gpu_idx,
|
||||||
|
gpu_key,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
await _auto_apply_profile_with_retry(profile_name, gpu_idx)
|
await _auto_apply_profile_with_retry(profile_name, gpu_idx)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
log.warning("Auto-load profile %r not found in %s — skipping GPU %d",
|
log.warning(
|
||||||
profile_name, cfg.profile_dir, gpu_idx)
|
"Auto-load profile %r not found in %s — skipping GPU %d",
|
||||||
|
profile_name,
|
||||||
|
cfg.profile_dir,
|
||||||
|
gpu_idx,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.warning("Auto-load profile %r failed on GPU %d: %s — skipping",
|
log.warning(
|
||||||
profile_name, gpu_idx, exc)
|
"Auto-load profile %r failed on GPU %d: %s — skipping",
|
||||||
|
profile_name,
|
||||||
|
gpu_idx,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
# ──────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
yield # server is running
|
yield # server is running
|
||||||
@@ -294,10 +326,15 @@ async def lifespan(app: FastAPI):
|
|||||||
g_state["fan_curve"] = None
|
g_state["fan_curve"] = None
|
||||||
try:
|
try:
|
||||||
await loop.run_in_executor(None, reset_fan, gpu_index)
|
await loop.run_in_executor(None, reset_fan, gpu_index)
|
||||||
log.info("GPU %d: restored automatic fan control on shutdown", gpu_index)
|
log.info(
|
||||||
|
"GPU %d: restored automatic fan control on shutdown", gpu_index
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.warning("GPU %d: failed to restore automatic fan control on shutdown: %s",
|
log.warning(
|
||||||
gpu_index, exc)
|
"GPU %d: failed to restore automatic fan control on shutdown: %s",
|
||||||
|
gpu_index,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
await loop.run_in_executor(None, shutdown_nvml)
|
await loop.run_in_executor(None, shutdown_nvml)
|
||||||
|
|
||||||
@@ -314,8 +351,53 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Authentication middleware (dual mode) ─────────────────────────────────────
|
||||||
|
# When the user store contains at least one user, every /api/* endpoint requires
|
||||||
|
# a valid session (cookie or Bearer token). With no users configured, the API is
|
||||||
|
# open — exactly like before. Static files (the SPA, including the login page)
|
||||||
|
# and the public auth/ping endpoints are always reachable.
|
||||||
|
|
||||||
|
PUBLIC_API_PATHS = {
|
||||||
|
"/api/ping",
|
||||||
|
"/api/auth/login",
|
||||||
|
"/api/auth/status",
|
||||||
|
"/api/auth/logout",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AuthMiddleware:
|
||||||
|
"""Require a valid session for /api/* when authentication is enabled."""
|
||||||
|
|
||||||
|
def __init__(self, app):
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope["type"] == "http":
|
||||||
|
path = scope["path"]
|
||||||
|
if (
|
||||||
|
path.startswith("/api/")
|
||||||
|
and path not in PUBLIC_API_PATHS
|
||||||
|
and scope.get("method") != "OPTIONS"
|
||||||
|
):
|
||||||
|
cfg: Config = _state["config"]
|
||||||
|
if auth.auth_enabled(cfg.users_file):
|
||||||
|
token = auth.extract_token(Request(scope))
|
||||||
|
if auth.get_session(token) is None:
|
||||||
|
response = JSONResponse(
|
||||||
|
{"detail": "Authentication required"},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
await response(scope, receive, send)
|
||||||
|
return
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
|
app.add_middleware(AuthMiddleware)
|
||||||
|
|
||||||
|
|
||||||
# ── Request models ────────────────────────────────────────────────────────────
|
# ── Request models ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class WriteRequest(BaseModel):
|
class WriteRequest(BaseModel):
|
||||||
deltas: dict[int, int] # {point_index: delta_kHz}
|
deltas: dict[int, int] # {point_index: delta_kHz}
|
||||||
max_delta_khz: int | None = None # per-request safety limit override
|
max_delta_khz: int | None = None # per-request safety limit override
|
||||||
@@ -365,13 +447,105 @@ class FanSpeedRequest(BaseModel):
|
|||||||
fan_pct: int
|
fan_pct: int
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
# ── Helper: run blocking HAL call in thread pool ──────────────────────────────
|
# ── Helper: run blocking HAL call in thread pool ──────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def _run(fn, *args):
|
async def _run(fn, *args):
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
return await loop.run_in_executor(None, fn, *args)
|
return await loop.run_in_executor(None, fn, *args)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auth endpoints ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/ping")
|
||||||
|
async def api_ping():
|
||||||
|
"""Public liveness probe (no auth). Used by the CLI to detect a running server."""
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/auth/status")
|
||||||
|
async def api_auth_status(request: Request):
|
||||||
|
"""Report whether auth is required and whether this request is authenticated."""
|
||||||
|
cfg: Config = _state["config"]
|
||||||
|
required = auth.auth_enabled(cfg.users_file)
|
||||||
|
token = auth.extract_token(request)
|
||||||
|
username = auth.get_session(token) if required else None
|
||||||
|
expires_at = auth.get_session_expires_at(token) if username else None
|
||||||
|
return {
|
||||||
|
"auth_required": required,
|
||||||
|
"authenticated": username is not None,
|
||||||
|
"username": username,
|
||||||
|
"expires_at": expires_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/login")
|
||||||
|
async def api_auth_login(req: LoginRequest, request: Request):
|
||||||
|
"""Authenticate with username+password. Sets a 24-hour session cookie.
|
||||||
|
|
||||||
|
The plaintext password is checked against the stored bcrypt hash and never
|
||||||
|
persisted. On success a random session token is returned (for Bearer use)
|
||||||
|
and set as an HttpOnly cookie (for browser use).
|
||||||
|
"""
|
||||||
|
cfg: Config = _state["config"]
|
||||||
|
users = auth.load_users(cfg.users_file)
|
||||||
|
if not users:
|
||||||
|
raise HTTPException(status_code=404, detail="Authentication is not enabled")
|
||||||
|
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
if auth.is_locked_out(client_ip):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429, detail="Too many failed attempts. Try again later."
|
||||||
|
)
|
||||||
|
|
||||||
|
if auth.check_credentials(users, req.username, req.password):
|
||||||
|
auth.clear_failures(client_ip)
|
||||||
|
token, expires_at = auth.create_session(req.username)
|
||||||
|
response = JSONResponse(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"username": req.username,
|
||||||
|
"expires_at": expires_at,
|
||||||
|
"token": token,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response.set_cookie(
|
||||||
|
auth.COOKIE_NAME,
|
||||||
|
token,
|
||||||
|
max_age=auth.SESSION_TTL_S,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
auth.record_failure(client_ip)
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/logout")
|
||||||
|
async def api_auth_logout(request: Request):
|
||||||
|
"""End the current session (idempotent)."""
|
||||||
|
token = auth.extract_token(request)
|
||||||
|
auth.destroy_session(token)
|
||||||
|
response = JSONResponse({"ok": True})
|
||||||
|
response.delete_cookie(auth.COOKIE_NAME, path="/")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/auth/users")
|
||||||
|
async def api_auth_users():
|
||||||
|
"""List configured usernames (requires auth)."""
|
||||||
|
cfg: Config = _state["config"]
|
||||||
|
return {"users": auth.list_users(cfg.users_file)}
|
||||||
|
|
||||||
|
|
||||||
def _require_gpu(gpu_index: int = 0):
|
def _require_gpu(gpu_index: int = 0):
|
||||||
g_state = _get_gpu_state(gpu_index)
|
g_state = _get_gpu_state(gpu_index)
|
||||||
gpu = g_state["gpu"]
|
gpu = g_state["gpu"]
|
||||||
@@ -382,10 +556,12 @@ def _require_gpu(gpu_index: int = 0):
|
|||||||
|
|
||||||
# ── REST endpoints ────────────────────────────────────────────────────────────
|
# ── REST endpoints ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/gpus")
|
@app.get("/api/gpus")
|
||||||
async def api_gpus():
|
async def api_gpus():
|
||||||
"""List all discovered GPUs."""
|
"""List all discovered GPUs."""
|
||||||
from .hal.gpu import discover_gpus
|
from .hal.gpu import discover_gpus
|
||||||
|
|
||||||
gpu_infos = await _run(discover_gpus)
|
gpu_infos = await _run(discover_gpus)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -397,6 +573,7 @@ async def api_gpus():
|
|||||||
for info in gpu_infos
|
for info in gpu_infos
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/gpu")
|
@app.get("/api/gpu")
|
||||||
async def api_gpu(gpu_index: int = 0):
|
async def api_gpu(gpu_index: int = 0):
|
||||||
"""GPU info: name, driver version, VRAM."""
|
"""GPU info: name, driver version, VRAM."""
|
||||||
@@ -408,7 +585,7 @@ async def api_gpu(gpu_index: int = 0):
|
|||||||
"index": gpu_index,
|
"index": gpu_index,
|
||||||
"driver_version": driver,
|
"driver_version": driver,
|
||||||
"vram_bytes": vram,
|
"vram_bytes": vram,
|
||||||
"vram_gib": round(vram / (1024 ** 3), 2) if vram else None,
|
"vram_gib": round(vram / (1024**3), 2) if vram else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -433,7 +610,9 @@ async def api_curve_point(point: int, gpu_index: int = 0):
|
|||||||
if state is None:
|
if state is None:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to read curve: {err}")
|
raise HTTPException(status_code=500, detail=f"Failed to read curve: {err}")
|
||||||
if point < 0 or point >= len(state.points):
|
if point < 0 or point >= len(state.points):
|
||||||
raise HTTPException(status_code=400, detail=f"Point index must be 0–{len(state.points)-1}")
|
raise HTTPException(
|
||||||
|
status_code=400, detail=f"Point index must be 0–{len(state.points) - 1}"
|
||||||
|
)
|
||||||
return _vfpoint_dict(state.points[point])
|
return _vfpoint_dict(state.points[point])
|
||||||
|
|
||||||
|
|
||||||
@@ -451,6 +630,7 @@ async def api_ranges(gpu_index: int = 0):
|
|||||||
async def api_voltage(gpu_index: int = 0):
|
async def api_voltage(gpu_index: int = 0):
|
||||||
"""Current GPU core voltage."""
|
"""Current GPU core voltage."""
|
||||||
from .hal.monitoring import read_voltage
|
from .hal.monitoring import read_voltage
|
||||||
|
|
||||||
gpu, g_state = _require_gpu(gpu_index)
|
gpu, g_state = _require_gpu(gpu_index)
|
||||||
voltage_uv, err = await _run(read_voltage, gpu)
|
voltage_uv, err = await _run(read_voltage, gpu)
|
||||||
if voltage_uv is None:
|
if voltage_uv is None:
|
||||||
@@ -492,6 +672,7 @@ def _persist_config_field(key: str, value) -> None:
|
|||||||
"""
|
"""
|
||||||
import json as _json
|
import json as _json
|
||||||
import os as _os
|
import os as _os
|
||||||
|
|
||||||
config_path = "/etc/nvcurve/config.json"
|
config_path = "/etc/nvcurve/config.json"
|
||||||
if not _os.path.exists(config_path):
|
if not _os.path.exists(config_path):
|
||||||
return
|
return
|
||||||
@@ -589,6 +770,7 @@ async def _auto_apply_profile_with_retry(
|
|||||||
Logs a warning and gives up after max_retries failed attempts.
|
Logs a warning and gives up after max_retries failed attempts.
|
||||||
"""
|
"""
|
||||||
import os as _os
|
import os as _os
|
||||||
|
|
||||||
cfg: Config = _state["config"]
|
cfg: Config = _state["config"]
|
||||||
g_state = _get_gpu_state(gpu_index)
|
g_state = _get_gpu_state(gpu_index)
|
||||||
gpu = g_state["gpu"]
|
gpu = g_state["gpu"]
|
||||||
@@ -598,43 +780,69 @@ async def _auto_apply_profile_with_retry(
|
|||||||
profile = await _run(load_profile, filepath) # raises FileNotFoundError if missing
|
profile = await _run(load_profile, filepath) # raises FileNotFoundError if missing
|
||||||
|
|
||||||
expected: dict[int, int] = (
|
expected: dict[int, int] = (
|
||||||
{int(k): v for k, v in profile.curve_deltas.items()} if profile.curve_deltas else {}
|
{int(k): v for k, v in profile.curve_deltas.items()}
|
||||||
|
if profile.curve_deltas
|
||||||
|
else {}
|
||||||
)
|
)
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
errs = await _apply_profile(name, gpu_index)
|
errs = await _apply_profile(name, gpu_index)
|
||||||
|
|
||||||
if errs:
|
if errs:
|
||||||
log.warning("Auto-load attempt %d/%d had errors: %s",
|
log.warning(
|
||||||
attempt + 1, max_retries, "; ".join(errs))
|
"Auto-load attempt %d/%d had errors: %s",
|
||||||
|
attempt + 1,
|
||||||
|
max_retries,
|
||||||
|
"; ".join(errs),
|
||||||
|
)
|
||||||
elif expected:
|
elif expected:
|
||||||
offsets, err = await _run(read_clock_offsets, gpu)
|
offsets, err = await _run(read_clock_offsets, gpu)
|
||||||
if offsets is None:
|
if offsets is None:
|
||||||
log.warning("Auto-load attempt %d/%d: read-back failed: %s",
|
log.warning(
|
||||||
attempt + 1, max_retries, err)
|
"Auto-load attempt %d/%d: read-back failed: %s",
|
||||||
|
attempt + 1,
|
||||||
|
max_retries,
|
||||||
|
err,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
mismatches = [
|
mismatches = [
|
||||||
f"pt{idx}: expected {val/1000:+.0f}MHz got {offsets[idx]/1000:+.0f}MHz"
|
f"pt{idx}: expected {val / 1000:+.0f}MHz got {offsets[idx] / 1000:+.0f}MHz"
|
||||||
for idx, val in expected.items()
|
for idx, val in expected.items()
|
||||||
if idx < len(offsets) and offsets[idx] != val
|
if idx < len(offsets) and offsets[idx] != val
|
||||||
]
|
]
|
||||||
if not mismatches:
|
if not mismatches:
|
||||||
log.info("Auto-load profile %r verified on GPU %d (attempt %d/%d)",
|
log.info(
|
||||||
name, gpu_index, attempt + 1, max_retries)
|
"Auto-load profile %r verified on GPU %d (attempt %d/%d)",
|
||||||
|
name,
|
||||||
|
gpu_index,
|
||||||
|
attempt + 1,
|
||||||
|
max_retries,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
log.warning("Auto-load attempt %d/%d: read-back mismatch — %s",
|
log.warning(
|
||||||
attempt + 1, max_retries, "; ".join(mismatches))
|
"Auto-load attempt %d/%d: read-back mismatch — %s",
|
||||||
|
attempt + 1,
|
||||||
|
max_retries,
|
||||||
|
"; ".join(mismatches),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
log.info("Auto-load profile %r applied on GPU %d (attempt %d/%d)",
|
log.info(
|
||||||
name, gpu_index, attempt + 1, max_retries)
|
"Auto-load profile %r applied on GPU %d (attempt %d/%d)",
|
||||||
|
name,
|
||||||
|
gpu_index,
|
||||||
|
attempt + 1,
|
||||||
|
max_retries,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
delay = 2 ** attempt # 1 s, 2 s, 4 s
|
delay = 2**attempt # 1 s, 2 s, 4 s
|
||||||
log.info("Retrying auto-load in %ds…", delay)
|
log.info("Retrying auto-load in %ds…", delay)
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
log.warning("Auto-load profile %r failed after %d attempts — giving up", name, max_retries)
|
log.warning(
|
||||||
|
"Auto-load profile %r failed after %d attempts — giving up", name, max_retries
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _apply_profile(name: str, gpu_index: int = 0) -> list[str]:
|
async def _apply_profile(name: str, gpu_index: int = 0) -> list[str]:
|
||||||
@@ -645,6 +853,7 @@ async def _apply_profile(name: str, gpu_index: int = 0) -> list[str]:
|
|||||||
Sets g_state["active_profile"] on full success.
|
Sets g_state["active_profile"] on full success.
|
||||||
"""
|
"""
|
||||||
import os as _os
|
import os as _os
|
||||||
|
|
||||||
g_state = _get_gpu_state(gpu_index)
|
g_state = _get_gpu_state(gpu_index)
|
||||||
gpu = g_state["gpu"]
|
gpu = g_state["gpu"]
|
||||||
cfg: Config = _state["config"]
|
cfg: Config = _state["config"]
|
||||||
@@ -677,7 +886,13 @@ async def _apply_profile(name: str, gpu_index: int = 0) -> list[str]:
|
|||||||
errs.append("Curve: " + "; ".join(errors))
|
errs.append("Curve: " + "; ".join(errors))
|
||||||
else:
|
else:
|
||||||
if cfg.auto_snapshot:
|
if cfg.auto_snapshot:
|
||||||
await _run(snapshot_save, gpu, g_state["gpu_name"], cfg.snapshot_dir, cfg.max_snapshots)
|
await _run(
|
||||||
|
snapshot_save,
|
||||||
|
gpu,
|
||||||
|
g_state["gpu_name"],
|
||||||
|
cfg.snapshot_dir,
|
||||||
|
cfg.max_snapshots,
|
||||||
|
)
|
||||||
ret, desc = await _run(write_offsets, gpu, deltas)
|
ret, desc = await _run(write_offsets, gpu, deltas)
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
errs.append(f"Curve write failed ({ret}): {desc}")
|
errs.append(f"Curve write failed ({ret}): {desc}")
|
||||||
@@ -748,7 +963,9 @@ async def api_profile_delete(name: str):
|
|||||||
g_state["active_profile"] = None
|
g_state["active_profile"] = None
|
||||||
changed = any(v == name for v in cfg.auto_load_profiles.values())
|
changed = any(v == name for v in cfg.auto_load_profiles.values())
|
||||||
if changed:
|
if changed:
|
||||||
cfg.auto_load_profiles = {k: v for k, v in cfg.auto_load_profiles.items() if v != name}
|
cfg.auto_load_profiles = {
|
||||||
|
k: v for k, v in cfg.auto_load_profiles.items() if v != name
|
||||||
|
}
|
||||||
_persist_auto_load_profiles(cfg.auto_load_profiles)
|
_persist_auto_load_profiles(cfg.auto_load_profiles)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@@ -905,6 +1122,7 @@ async def api_limits_reset(gpu_index: int = 0):
|
|||||||
|
|
||||||
# ── Fan endpoints ──────────────────────────────────────────────────────────────
|
# ── Fan endpoints ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/fans")
|
@app.get("/api/fans")
|
||||||
async def api_fans(gpu_index: int = 0):
|
async def api_fans(gpu_index: int = 0):
|
||||||
"""Current fan state: fan %, curve, and whether curve control is active."""
|
"""Current fan state: fan %, curve, and whether curve control is active."""
|
||||||
@@ -935,12 +1153,18 @@ async def api_fans_update(req: FanCurveRequest, gpu_index: int = 0):
|
|||||||
# set to an inappropriate speed.
|
# set to an inappropriate speed.
|
||||||
if curve_data:
|
if curve_data:
|
||||||
sample = await _run(poll, g_state["gpu"], gpu_index)
|
sample = await _run(poll, g_state["gpu"], gpu_index)
|
||||||
test_temp = sample.temp_c if sample and sample.temp_c is not None else curve_data[0]["temp_c"]
|
test_temp = (
|
||||||
|
sample.temp_c
|
||||||
|
if sample and sample.temp_c is not None
|
||||||
|
else curve_data[0]["temp_c"]
|
||||||
|
)
|
||||||
target = interpolate_fan_speed(curve_data, test_temp)
|
target = interpolate_fan_speed(curve_data, test_temp)
|
||||||
if target is not None:
|
if target is not None:
|
||||||
fan_ok, fan_msg = await _run(set_fan_speed, gpu_index, target)
|
fan_ok, fan_msg = await _run(set_fan_speed, gpu_index, target)
|
||||||
if not fan_ok:
|
if not fan_ok:
|
||||||
raise HTTPException(status_code=500, detail=f"Fan control not available: {fan_msg}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Fan control not available: {fan_msg}"
|
||||||
|
)
|
||||||
|
|
||||||
# Stop existing poller if running
|
# Stop existing poller if running
|
||||||
if g_state.get("fan_poller_task"):
|
if g_state.get("fan_poller_task"):
|
||||||
@@ -994,6 +1218,7 @@ async def api_fans_speed(req: FanSpeedRequest, gpu_index: int = 0):
|
|||||||
|
|
||||||
# ── Write endpoints ────────────────────────────────────────────────────────────
|
# ── Write endpoints ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def _reconcile_check(gpu_index: int) -> dict | None:
|
async def _reconcile_check(gpu_index: int) -> dict | None:
|
||||||
"""Re-read current offsets and return a warning dict if they differ from our last known state.
|
"""Re-read current offsets and return a warning dict if they differ from our last known state.
|
||||||
|
|
||||||
@@ -1035,7 +1260,9 @@ async def api_curve_write(req: WriteRequest, gpu_index: int = 0):
|
|||||||
|
|
||||||
vfp_state, _ = await _run(read_curve, gpu, g_state["gpu_name"])
|
vfp_state, _ = await _run(read_curve, gpu, g_state["gpu_name"])
|
||||||
|
|
||||||
effective_limit = req.max_delta_khz if req.max_delta_khz is not None else cfg.max_delta_khz
|
effective_limit = (
|
||||||
|
req.max_delta_khz if req.max_delta_khz is not None else cfg.max_delta_khz
|
||||||
|
)
|
||||||
errors = validate_write(req.deltas, effective_limit)
|
errors = validate_write(req.deltas, effective_limit)
|
||||||
if errors:
|
if errors:
|
||||||
raise HTTPException(status_code=400, detail={"errors": errors})
|
raise HTTPException(status_code=400, detail={"errors": errors})
|
||||||
@@ -1052,7 +1279,13 @@ async def api_curve_write(req: WriteRequest, gpu_index: int = 0):
|
|||||||
warning = await _reconcile_check(gpu_index)
|
warning = await _reconcile_check(gpu_index)
|
||||||
|
|
||||||
if cfg.auto_snapshot:
|
if cfg.auto_snapshot:
|
||||||
await _run(snapshot_save, gpu, g_state["gpu_name"], cfg.snapshot_dir, cfg.max_snapshots)
|
await _run(
|
||||||
|
snapshot_save,
|
||||||
|
gpu,
|
||||||
|
g_state["gpu_name"],
|
||||||
|
cfg.snapshot_dir,
|
||||||
|
cfg.max_snapshots,
|
||||||
|
)
|
||||||
|
|
||||||
ret, desc = await _run(write_offsets, gpu, req.deltas)
|
ret, desc = await _run(write_offsets, gpu, req.deltas)
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
@@ -1081,7 +1314,9 @@ async def api_curve_write_global(req: GlobalOffsetRequest, gpu_index: int = 0):
|
|||||||
raise HTTPException(status_code=500, detail="Failed to read curve")
|
raise HTTPException(status_code=500, detail="Failed to read curve")
|
||||||
|
|
||||||
all_deltas = {p.index: req.delta_khz for p in vfp_state.points if p.domain == "gpu"}
|
all_deltas = {p.index: req.delta_khz for p in vfp_state.points if p.domain == "gpu"}
|
||||||
effective_limit = req.max_delta_khz if req.max_delta_khz is not None else cfg.max_delta_khz
|
effective_limit = (
|
||||||
|
req.max_delta_khz if req.max_delta_khz is not None else cfg.max_delta_khz
|
||||||
|
)
|
||||||
errors = validate_write(all_deltas, effective_limit)
|
errors = validate_write(all_deltas, effective_limit)
|
||||||
if errors:
|
if errors:
|
||||||
raise HTTPException(status_code=400, detail={"errors": errors})
|
raise HTTPException(status_code=400, detail={"errors": errors})
|
||||||
@@ -1097,7 +1332,13 @@ async def api_curve_write_global(req: GlobalOffsetRequest, gpu_index: int = 0):
|
|||||||
warning = await _reconcile_check(gpu_index)
|
warning = await _reconcile_check(gpu_index)
|
||||||
|
|
||||||
if cfg.auto_snapshot:
|
if cfg.auto_snapshot:
|
||||||
await _run(snapshot_save, gpu, g_state["gpu_name"], cfg.snapshot_dir, cfg.max_snapshots)
|
await _run(
|
||||||
|
snapshot_save,
|
||||||
|
gpu,
|
||||||
|
g_state["gpu_name"],
|
||||||
|
cfg.snapshot_dir,
|
||||||
|
cfg.max_snapshots,
|
||||||
|
)
|
||||||
|
|
||||||
ret, desc = await _run(write_global_offset, gpu, req.delta_khz)
|
ret, desc = await _run(write_global_offset, gpu, req.delta_khz)
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
@@ -1124,7 +1365,13 @@ async def api_curve_reset(gpu_index: int = 0):
|
|||||||
warning = await _reconcile_check(gpu_index)
|
warning = await _reconcile_check(gpu_index)
|
||||||
|
|
||||||
if cfg.auto_snapshot:
|
if cfg.auto_snapshot:
|
||||||
await _run(snapshot_save, gpu, g_state["gpu_name"], cfg.snapshot_dir, cfg.max_snapshots)
|
await _run(
|
||||||
|
snapshot_save,
|
||||||
|
gpu,
|
||||||
|
g_state["gpu_name"],
|
||||||
|
cfg.snapshot_dir,
|
||||||
|
cfg.max_snapshots,
|
||||||
|
)
|
||||||
|
|
||||||
ret, desc = await _run(reset_offsets, gpu)
|
ret, desc = await _run(reset_offsets, gpu)
|
||||||
if ret != 0:
|
if ret != 0:
|
||||||
@@ -1151,10 +1398,14 @@ async def api_curve_verify(req: VerifyRequest, gpu_index: int = 0):
|
|||||||
|
|
||||||
before_offsets, err = await _run(read_clock_offsets, gpu)
|
before_offsets, err = await _run(read_clock_offsets, gpu)
|
||||||
if before_offsets is None:
|
if before_offsets is None:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to read current state: {err}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to read current state: {err}"
|
||||||
|
)
|
||||||
|
|
||||||
# Always snapshot before verify — it's a testing operation
|
# Always snapshot before verify — it's a testing operation
|
||||||
await _run(snapshot_save, gpu, g_state["gpu_name"], cfg.snapshot_dir, cfg.max_snapshots)
|
await _run(
|
||||||
|
snapshot_save, gpu, g_state["gpu_name"], cfg.snapshot_dir, cfg.max_snapshots
|
||||||
|
)
|
||||||
|
|
||||||
async with g_state["write_lock"]:
|
async with g_state["write_lock"]:
|
||||||
ret, desc = await _run(write_offsets, gpu, req.deltas)
|
ret, desc = await _run(write_offsets, gpu, req.deltas)
|
||||||
@@ -1165,7 +1416,9 @@ async def api_curve_verify(req: VerifyRequest, gpu_index: int = 0):
|
|||||||
|
|
||||||
after_offsets, err = await _run(read_clock_offsets, gpu)
|
after_offsets, err = await _run(read_clock_offsets, gpu)
|
||||||
if after_offsets is None:
|
if after_offsets is None:
|
||||||
raise HTTPException(status_code=500, detail=f"Verification read failed: {err}")
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Verification read failed: {err}"
|
||||||
|
)
|
||||||
|
|
||||||
g_state["active_profile"] = None
|
g_state["active_profile"] = None
|
||||||
await _update_offsets_and_broadcast(gpu_index)
|
await _update_offsets_and_broadcast(gpu_index)
|
||||||
@@ -1177,12 +1430,14 @@ async def api_curve_verify(req: VerifyRequest, gpu_index: int = 0):
|
|||||||
match = actual == expected
|
match = actual == expected
|
||||||
if not match:
|
if not match:
|
||||||
all_matched = False
|
all_matched = False
|
||||||
points_result.append({
|
points_result.append(
|
||||||
|
{
|
||||||
"point": point,
|
"point": point,
|
||||||
"expected_khz": expected,
|
"expected_khz": expected,
|
||||||
"actual_khz": actual,
|
"actual_khz": actual,
|
||||||
"match": match,
|
"match": match,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
collateral = [
|
collateral = [
|
||||||
{"point": i, "before_khz": before_offsets[i], "after_khz": after_offsets[i]}
|
{"point": i, "before_khz": before_offsets[i], "after_khz": after_offsets[i]}
|
||||||
@@ -1206,6 +1461,7 @@ async def api_shutdown():
|
|||||||
"""Gracefully shut down the server process."""
|
"""Gracefully shut down the server process."""
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
loop.call_later(0.1, lambda: os.kill(os.getpid(), signal.SIGTERM))
|
loop.call_later(0.1, lambda: os.kill(os.getpid(), signal.SIGTERM))
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
@@ -1216,7 +1472,9 @@ async def api_snapshot_save(gpu_index: int = 0):
|
|||||||
"""Save a ClockBoostTable snapshot."""
|
"""Save a ClockBoostTable snapshot."""
|
||||||
gpu, g_state = _require_gpu(gpu_index)
|
gpu, g_state = _require_gpu(gpu_index)
|
||||||
cfg: Config = _state["config"]
|
cfg: Config = _state["config"]
|
||||||
path = await _run(snapshot_save, gpu, g_state["gpu_name"], cfg.snapshot_dir, cfg.max_snapshots)
|
path = await _run(
|
||||||
|
snapshot_save, gpu, g_state["gpu_name"], cfg.snapshot_dir, cfg.max_snapshots
|
||||||
|
)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise HTTPException(status_code=500, detail="Failed to save snapshot")
|
raise HTTPException(status_code=500, detail="Failed to save snapshot")
|
||||||
return {"ok": True, "filepath": path}
|
return {"ok": True, "filepath": path}
|
||||||
@@ -1241,9 +1499,27 @@ async def api_snapshot_restore(req: SnapshotRestoreRequest, gpu_index: int = 0):
|
|||||||
|
|
||||||
# ── WebSocket endpoints ───────────────────────────────────────────────────────
|
# ── WebSocket endpoints ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _ws_authenticated(ws: WebSocket) -> bool:
|
||||||
|
"""True if the WebSocket connection is allowed (auth disabled or valid session).
|
||||||
|
|
||||||
|
The session token is read from the Authorization header or the cookie
|
||||||
|
(browsers send the cookie automatically on the WS handshake). The token is
|
||||||
|
deliberately NOT accepted via a query string, since uvicorn's access log
|
||||||
|
records the full path including the query string.
|
||||||
|
"""
|
||||||
|
cfg: Config = _state["config"]
|
||||||
|
if not auth.auth_enabled(cfg.users_file):
|
||||||
|
return True
|
||||||
|
return auth.get_session(auth.extract_token(ws)) is not None
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws/monitor")
|
@app.websocket("/ws/monitor")
|
||||||
async def ws_monitor(ws: WebSocket):
|
async def ws_monitor(ws: WebSocket):
|
||||||
"""Stream MonitoringSample at poll_interval_s. Clients receive JSON objects."""
|
"""Stream MonitoringSample at poll_interval_s. Clients receive JSON objects."""
|
||||||
|
if not _ws_authenticated(ws):
|
||||||
|
await ws.close(code=1008)
|
||||||
|
return
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
try:
|
try:
|
||||||
data = await ws.receive_json()
|
data = await ws.receive_json()
|
||||||
@@ -1274,7 +1550,7 @@ async def ws_monitor(ws: WebSocket):
|
|||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
log.debug("monitor ws client error", exc_info=True)
|
||||||
finally:
|
finally:
|
||||||
g_state["monitor_clients"].discard(ws)
|
g_state["monitor_clients"].discard(ws)
|
||||||
|
|
||||||
@@ -1282,6 +1558,9 @@ async def ws_monitor(ws: WebSocket):
|
|||||||
@app.websocket("/ws/curve")
|
@app.websocket("/ws/curve")
|
||||||
async def ws_curve(ws: WebSocket):
|
async def ws_curve(ws: WebSocket):
|
||||||
"""Push CurveState whenever the curve changes (after writes)."""
|
"""Push CurveState whenever the curve changes (after writes)."""
|
||||||
|
if not _ws_authenticated(ws):
|
||||||
|
await ws.close(code=1008)
|
||||||
|
return
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
try:
|
try:
|
||||||
data = await ws.receive_json()
|
data = await ws.receive_json()
|
||||||
@@ -1313,47 +1592,54 @@ async def ws_curve(ws: WebSocket):
|
|||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
log.debug("curve ws client error", exc_info=True)
|
||||||
finally:
|
finally:
|
||||||
g_state["curve_clients"].discard(ws)
|
g_state["curve_clients"].discard(ws)
|
||||||
|
|
||||||
|
|
||||||
# ── Frontend SPA ──────────────────────────────────────────────────────────────
|
# ── Frontend SPA ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import os
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# When set, suppresses the auto-open browser behaviour so the dev can open
|
# When set, suppresses the auto-open browser behaviour so the dev can open
|
||||||
# the Vite dev server (pnpm dev) manually instead.
|
# the Vite dev server (pnpm dev) manually instead.
|
||||||
_DEV_PORT = os.environ.get("NVCURVE_DEV_PORT")
|
_DEV_PORT = os.environ.get("NVCURVE_DEV_PORT")
|
||||||
|
|
||||||
# Robust asset resolution using importlib.resources
|
|
||||||
try:
|
def _resolve_dist_dir() -> str:
|
||||||
|
"""Resolve the frontend dist directory to a string path.
|
||||||
|
|
||||||
|
Prefers the packaged location (importlib.resources), then falls back to
|
||||||
|
the project-root layout used during local development.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
from importlib.resources import files as _resource_files
|
from importlib.resources import files as _resource_files
|
||||||
# In a packaged installation, frontend/dist is inside the package
|
|
||||||
_dist_dir = _resource_files("nvcurve") / "frontend" / "dist"
|
|
||||||
|
|
||||||
# Fallback for local development where frontend/dist might be at project root
|
candidate = _resource_files("nvcurve") / "frontend" / "dist"
|
||||||
if not _dist_dir.is_dir():
|
if candidate.is_dir():
|
||||||
_here = Path(__file__).parent
|
return str(candidate)
|
||||||
_dist_dir = _here.parent / "frontend" / "dist"
|
except (ImportError, TypeError):
|
||||||
except (ImportError, TypeError):
|
pass
|
||||||
# Legacy fallback for older Python or environments without importlib.resources.files
|
here = Path(__file__).parent
|
||||||
_here = Path(__file__).parent
|
for base in (here, here.parent):
|
||||||
_dist_dir = _here / "frontend" / "dist"
|
dist = base / "frontend" / "dist"
|
||||||
if not _dist_dir.is_dir():
|
if dist.is_dir():
|
||||||
_dist_dir = _here.parent / "frontend" / "dist"
|
return str(dist)
|
||||||
|
return str(here / "frontend" / "dist")
|
||||||
|
|
||||||
_dist_dir = str(_dist_dir)
|
|
||||||
|
_dist_dir = _resolve_dist_dir()
|
||||||
|
|
||||||
if os.path.isdir(os.path.join(_dist_dir, "assets")):
|
if os.path.isdir(os.path.join(_dist_dir, "assets")):
|
||||||
app.mount("/assets", StaticFiles(directory=os.path.join(_dist_dir, "assets")), name="assets")
|
app.mount(
|
||||||
|
"/assets",
|
||||||
|
StaticFiles(directory=os.path.join(_dist_dir, "assets")),
|
||||||
|
name="assets",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/{catchall:path}")
|
@app.get("/{catchall:path}")
|
||||||
async def serve_spa(catchall: str):
|
async def serve_spa(catchall: str):
|
||||||
if catchall.startswith("api/") or catchall.startswith("ws/"):
|
if catchall.startswith(("api/", "ws/")):
|
||||||
raise HTTPException(status_code=404, detail="Not Found")
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
|
|
||||||
if not os.path.isdir(_dist_dir):
|
if not os.path.isdir(_dist_dir):
|
||||||
@@ -1372,6 +1658,7 @@ async def serve_spa(catchall: str):
|
|||||||
|
|
||||||
# ── Factory for configured app ────────────────────────────────────────────────
|
# ── Factory for configured app ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def create_app(config: Config = default_config) -> FastAPI:
|
def create_app(config: Config = default_config) -> FastAPI:
|
||||||
"""Create a server app with a custom config (e.g. different gpu_index)."""
|
"""Create a server app with a custom config (e.g. different gpu_index)."""
|
||||||
_state["config"] = config
|
_state["config"] = config
|
||||||
@@ -1388,6 +1675,7 @@ def run(
|
|||||||
"""Start the uvicorn server. Blocking."""
|
"""Start the uvicorn server. Blocking."""
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
_state["config"] = config
|
_state["config"] = config
|
||||||
@@ -1404,7 +1692,9 @@ def run(
|
|||||||
s.bind((host, port))
|
s.bind((host, port))
|
||||||
except OSError:
|
except OSError:
|
||||||
print(f"Error: port {port} is already in use.")
|
print(f"Error: port {port} is already in use.")
|
||||||
print(f"Use --port N to specify a different port, or free port {port} first.")
|
print(
|
||||||
|
f"Use --port N to specify a different port, or free port {port} first."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
url = f"http://{host}:{port}"
|
url = f"http://{host}:{port}"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ dependencies = [
|
|||||||
"nvidia-ml-py>=12.0",
|
"nvidia-ml-py>=12.0",
|
||||||
"pydantic>=2.0",
|
"pydantic>=2.0",
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
|
"bcrypt>=4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"venvPath": ".",
|
||||||
|
"venv": ".venv"
|
||||||
|
}
|
||||||
@@ -33,6 +33,72 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bcrypt"
|
||||||
|
version = "5.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.2.25"
|
version = "2026.2.25"
|
||||||
@@ -159,6 +225,7 @@ name = "nvcurve"
|
|||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "bcrypt" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "nvidia-ml-py" },
|
{ name = "nvidia-ml-py" },
|
||||||
@@ -168,6 +235,7 @@ dependencies = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "bcrypt", specifier = ">=4.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.115" },
|
{ name = "fastapi", specifier = ">=0.115" },
|
||||||
{ name = "httpx", specifier = ">=0.27" },
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
{ name = "nvidia-ml-py", specifier = ">=12.0" },
|
{ name = "nvidia-ml-py", specifier = ">=12.0" },
|
||||||
|
|||||||
Reference in new issue
Block a user