docs: add API reference and enrich OpenAPI metadata
- docs/API.md: full REST + WebSocket reference with auth usage (cookie/Bearer login flow, curl examples), conventions, endpoint tables, and WS protocol (WS routes are not in the OpenAPI schema) - server.py: app-level description, openapi_tags grouping, and tags/responses metadata on all REST routes documenting the error codes each route raises; hide the SPA catch-all from the schema - README: link the API reference in the docs index
This commit is contained in:
1 parent
db0e676501
commit
b75b9d43e9
3 files changed
+473
-38
No files matched your search
@@ -205,6 +205,7 @@ The GPU key can be a UUID, `pci:XXXX`, or `idx:N` fallback. Find your GPU key wi
|
|||||||
- **[Overview](docs/Overview.md)** — What it does, capabilities, architecture
|
- **[Overview](docs/Overview.md)** — What it does, capabilities, architecture
|
||||||
- **[Installation](docs/Installation.md)** — Prerequisites, source build, troubleshooting
|
- **[Installation](docs/Installation.md)** — Prerequisites, source build, troubleshooting
|
||||||
- **[Usage Guide](docs/Usage-Guide.md)** — Web UI, CLI reference, systemd service
|
- **[Usage Guide](docs/Usage-Guide.md)** — Web UI, CLI reference, systemd service
|
||||||
|
- **[API Reference](docs/API.md)** — REST + WebSocket API, authentication, endpoint summary (interactive docs at `/docs`)
|
||||||
- **[Tips and Tricks](docs/Tips-and-Tricks.md)** — Workflows, curve flattening, safety
|
- **[Tips and Tricks](docs/Tips-and-Tricks.md)** — Workflows, curve flattening, safety
|
||||||
- **[WireView Pro II Setup](docs/WireView.md)** — One-time host setup (udev rule + port access) for the 12VHPWR connector monitor
|
- **[WireView Pro II Setup](docs/WireView.md)** — One-time host setup (udev rule + port access) for the 12VHPWR connector monitor
|
||||||
|
|
||||||
|
|||||||
+243
@@ -0,0 +1,243 @@
|
|||||||
|
# NVCurve API Reference
|
||||||
|
|
||||||
|
The web server (`nvcurve serve`) exposes a **REST + WebSocket API** on
|
||||||
|
`http://127.0.0.1:8042` by default. The same server also serves the web UI
|
||||||
|
SPA, so the API and UI share one port.
|
||||||
|
|
||||||
|
**Interactive docs** (served by the running server, no login required):
|
||||||
|
|
||||||
|
| URL | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `/docs` | Swagger UI (interactive, per-endpoint request/response metadata) |
|
||||||
|
| `/redoc` | ReDoc (read-friendly reference) |
|
||||||
|
| `/openapi.json` | Machine-readable OpenAPI 3.1 schema |
|
||||||
|
|
||||||
|
> WebSocket endpoints are **not** part of the OpenAPI schema (OpenAPI has no
|
||||||
|
> WebSocket operation type) — they are documented in the
|
||||||
|
> [WebSockets](#websockets) section below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
The server has two modes, decided automatically:
|
||||||
|
|
||||||
|
- **No users configured** → the API is fully open (no auth).
|
||||||
|
- **One or more users configured** → every `/api/*` and `/ws/*` endpoint
|
||||||
|
requires a valid session, except the public endpoints listed below.
|
||||||
|
|
||||||
|
Sessions last **24 hours**. Passwords are stored as bcrypt hashes.
|
||||||
|
|
||||||
|
### Public endpoints (always reachable)
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/ping` | Liveness probe (CLI uses it to detect a running server) |
|
||||||
|
| `GET /api/auth/status` | Whether auth is required and whether *this* request is authenticated |
|
||||||
|
| `POST /api/auth/login` | Authenticate, obtain session |
|
||||||
|
| `POST /api/auth/logout` | End the current session |
|
||||||
|
|
||||||
|
### Obtaining a session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://127.0.0.1:8042/api/auth/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"username": "alice", "password": "secret"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"username": "alice",
|
||||||
|
"expires_at": 1750000000.0,
|
||||||
|
"token": "9f2c1a…"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
On success the server also sets an `HttpOnly` cookie `nvcurve_session`
|
||||||
|
(`Secure` when TLS is enabled). You can use either credential:
|
||||||
|
|
||||||
|
- **Cookie** — what browsers use automatically.
|
||||||
|
- **Bearer token** — what scripts/CLI use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=$(curl -s -X POST http://127.0.0.1:8042/api/auth/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"username": "alice", "password": "secret"}' | jq -r .token)
|
||||||
|
|
||||||
|
curl -s http://127.0.0.1:8042/api/curve -H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- For WebSockets, the token is read from the `Authorization` header or the
|
||||||
|
cookie on the handshake. It is **deliberately not** accepted via query
|
||||||
|
string (the access log records full paths).
|
||||||
|
- Failed logins are rate-limited per client IP; lockouts return `429`.
|
||||||
|
Behind a reverse proxy, set `trusted_proxies` in the config so the real
|
||||||
|
client IP is used.
|
||||||
|
- `GET /api/auth/status` reports your current state:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"auth_required": true, "authenticated": true, "username": "alice", "expires_at": 1750000000.0}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **`gpu_index`** — GPU-selecting routes take a 0-based `gpu_index` query
|
||||||
|
parameter (default `0`). Unknown indexes → `404`; an uninitialized GPU →
|
||||||
|
`503`.
|
||||||
|
- **Errors** — JSON `{"detail": "..."}`. Validation errors on write endpoints
|
||||||
|
return `400` with `detail.errors[]` listing the offending points.
|
||||||
|
- **Safety cap** — curve writes are capped by the server-side
|
||||||
|
`max_delta_khz` (default ±3000 MHz, set in `/etc/nvcurve/config.json`).
|
||||||
|
Clients cannot raise it per request.
|
||||||
|
- **Units** — curve deltas are in **kHz**; clock offsets and memory offsets
|
||||||
|
are in **MHz**; voltages in µV/mV; power in watts.
|
||||||
|
- **Side effects** — write endpoints (`/api/curve/*`, `/api/limits`,
|
||||||
|
`/api/profiles/*/apply`, `/api/snapshot/restore`) change hardware state.
|
||||||
|
With `auto_snapshot: true` (default) a ClockBoostTable snapshot is saved
|
||||||
|
before every curve write.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## REST endpoints
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
| Method & path | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/ping` | Liveness probe → `{"ok": true}` |
|
||||||
|
| `GET /api/auth/status` | Auth mode + current session state |
|
||||||
|
| `POST /api/auth/login` | Body `{"username", "password"}` → session token + cookie. Errors: `401` bad credentials, `404` auth disabled, `429` locked out |
|
||||||
|
| `POST /api/auth/logout` | End session (idempotent) |
|
||||||
|
| `GET /api/auth/users` | List configured usernames (requires session) |
|
||||||
|
|
||||||
|
### GPU
|
||||||
|
|
||||||
|
| Method & path | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/gpus` | List discovered GPUs → `[{index, name, uuid, pci_bus_id}]` |
|
||||||
|
| `GET /api/gpu?gpu_index=0` | Name, driver version, VRAM |
|
||||||
|
| `GET /api/dashboard?gpu_index=0` | Static dashboard info (VBIOS, CUDA cores, PCIe, BAR1, …). Live values come from the monitor WebSocket |
|
||||||
|
| `GET /api/monitor?gpu_index=0` | One-shot monitoring snapshot: voltage, clocks, temp, power, fans, p-state, VRAM, utilization, throttle reasons |
|
||||||
|
|
||||||
|
### Curve
|
||||||
|
|
||||||
|
| Method & path | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/curve?gpu_index=0` | Full curve: `{gpu_name, timestamp, points: [{index, freq_khz, volt_mv, delta_khz, effective_freq_khz, domain, …}]}` |
|
||||||
|
| `GET /api/curve/{point}?gpu_index=0` | Single V/F point detail (`400` if index out of range) |
|
||||||
|
| `GET /api/ranges?gpu_index=0` | Clock boost domain ranges (min/max offset per domain) |
|
||||||
|
| `GET /api/voltage?gpu_index=0` | Current core voltage → `{voltage_uv, voltage_mv}` |
|
||||||
|
| `POST /api/curve/write?gpu_index=0` | Body `{"deltas": {"<point>": <delta_khz>}}` — write per-point frequency offsets. Response includes `ok`, `return_code`, optional `warning` (external change detected) and `freq_warnings` (negative-frequency risk) |
|
||||||
|
| `POST /api/curve/write/global?gpu_index=0` | Body `{"delta_khz": N}` — uniform offset on all GPU-domain points |
|
||||||
|
| `POST /api/curve/reset?gpu_index=0` | Reset all frequency offsets to zero |
|
||||||
|
| `POST /api/curve/verify?gpu_index=0` | Body `{"deltas": {...}}` — write-verify-read cycle; returns per-point `match` results and `collateral_changes` on other points |
|
||||||
|
|
||||||
|
### Profiles
|
||||||
|
|
||||||
|
Profiles capture the current curve deltas, power limit, memory offset and
|
||||||
|
active fan curve as a named JSON file.
|
||||||
|
|
||||||
|
| Method & path | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/profiles?gpu_index=0` | `{profiles: [...], active, auto_load}` |
|
||||||
|
| `POST /api/profiles?gpu_index=0` | Body `{"name": "..."}` — save current GPU state as a profile |
|
||||||
|
| `POST /api/profiles/{name}/apply?gpu_index=0` | Apply a saved profile to hardware. `404` if missing; `500` with per-part errors if any part fails |
|
||||||
|
| `DELETE /api/profiles/{name}` | Delete a profile (also clears auto-load references) |
|
||||||
|
| `POST /api/profiles/{name}/rename` | Body `{"new_name": "..."}` |
|
||||||
|
|
||||||
|
### Config
|
||||||
|
|
||||||
|
| Method & path | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/config?gpu_index=0` | `{auto_load_profile}` for the GPU |
|
||||||
|
| `POST /api/config` | Body `{"auto_load_profile": "name" \| null, "gpu_index": 0}` — set/clear the profile auto-applied at server start. Persists to `/etc/nvcurve/config.json` if present |
|
||||||
|
|
||||||
|
### Limits
|
||||||
|
|
||||||
|
| Method & path | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/limits?gpu_index=0` | Power limit (current/default/min/max), `gpc_offset_mhz`, `mem_offset_mhz`, memory offset range |
|
||||||
|
| `POST /api/limits?gpu_index=0` | Body `{power_limit_w?, mem_offset_mhz?, power_cap_mode?}` — any subset. `power_cap_mode` is `"nvml"` (default) or `"ioctl"` (experimental RM power control; `409` if unsupported). Changing the memory offset may reset the curve table — the server re-applies the last known curve offsets |
|
||||||
|
| `POST /api/limits/reset?gpu_index=0` | Power limit → hardware default, memory offset → 0 |
|
||||||
|
|
||||||
|
### Fans
|
||||||
|
|
||||||
|
| Method & path | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/fans?gpu_index=0` | Per-fan state: `{fan_pct, fans: [{index, fan_pct}], num_fans, min_fan_pct, max_fan_pct, fan_mode: "auto" \| "curve", curve, curve_active, fan_targets}` |
|
||||||
|
| `POST /api/fans?gpu_index=0` | Body `{"curve": [{"temp_c": 60, "fan_pct": 40}, …], "fans": [0] \| null}` — set the fan curve and start the control poller. `fans: null` drives all fans. `400` for invalid curves (e.g. non-monotonic temps) |
|
||||||
|
| `POST /api/fans/reset?gpu_index=0` | Deactivate curve control, restore automatic fan mode |
|
||||||
|
| `POST /api/fans/speed?gpu_index=0` | Body `{"fan_pct": 50, "fan": 0 \| null}` — one-shot exact speed (bypasses curve) |
|
||||||
|
|
||||||
|
Active fan curves persist across server restarts.
|
||||||
|
|
||||||
|
### WireView Pro II
|
||||||
|
|
||||||
|
| Method & path | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/wireview` | `{available, connected, info, sample}` — `available` is true when a Thermal Grizzly WireView Pro II is connected; `sample` is the most recent reading (`null` until the first) |
|
||||||
|
|
||||||
|
### Snapshots
|
||||||
|
|
||||||
|
| Method & path | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/snapshots` | List saved ClockBoostTable snapshots → `[{filepath, timestamp, gpu, nonzero_offsets, size}]` |
|
||||||
|
| `POST /api/snapshot/save?gpu_index=0` | Save the current ClockBoostTable → `{ok, filepath}` |
|
||||||
|
| `POST /api/snapshot/restore?gpu_index=0` | Body `{"filepath": "…" \| null}` — restore a snapshot (most recent if omitted) |
|
||||||
|
|
||||||
|
### Server
|
||||||
|
|
||||||
|
| Method & path | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `POST /api/shutdown` | Gracefully stop the server process. `403` when `allow_api_shutdown: false` (recommended on shared systems — use systemd instead) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WebSockets
|
||||||
|
|
||||||
|
All three endpoints use the same handshake: connect, then send a subscribe
|
||||||
|
message. The server sends an initial state payload, then keeps streaming.
|
||||||
|
|
||||||
|
| Endpoint | Subscribe message | Stream |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/ws/monitor` | `{"action": "subscribe", "gpu_index": 0}` | Monitoring sample every `poll_interval_s` (default 1 s): voltage, clocks, temp, power, fans, p-state, VRAM, utilization, throttle reasons |
|
||||||
|
| `/ws/curve` | `{"action": "subscribe", "gpu_index": 0}` | Full curve state, pushed whenever the curve changes (after any write) |
|
||||||
|
| `/ws/wireview` | `{"action": "subscribe"}` | `{"type": "unavailable"}` or `{"type": "sample", "info": {...}, "sample": {...}}` per poll tick |
|
||||||
|
|
||||||
|
Authentication (when enabled): the session token is taken from the
|
||||||
|
`Authorization: Bearer <token>` header or the `nvcurve_session` cookie on the
|
||||||
|
handshake. Unauthenticated connections are closed with code `1008`.
|
||||||
|
|
||||||
|
Example (Python `websockets`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio, json, websockets
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
async with websockets.connect(
|
||||||
|
"ws://127.0.0.1:8042/ws/monitor",
|
||||||
|
extra_headers={"Authorization": f"Bearer {token}"},
|
||||||
|
) as ws:
|
||||||
|
await ws.send(json.dumps({"action": "subscribe", "gpu_index": 0}))
|
||||||
|
async for msg in ws:
|
||||||
|
sample = json.loads(msg)
|
||||||
|
print(sample["temp_c"], sample["clock_mhz"])
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
The `nvcurve` CLI talks to this same API (default base
|
||||||
|
`http://127.0.0.1:8042`, override with `--base-url`), so any scripted use
|
||||||
|
covered by the CLI works against the API as well. See
|
||||||
|
[Usage-Guide.md](Usage-Guide.md) for CLI details.
|
||||||
+229
-38
@@ -638,7 +638,88 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
# ── App ───────────────────────────────────────────────────────────────────────
|
# ── App ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app = FastAPI(title="nvcurve", version="0.5.0", lifespan=lifespan)
|
# OpenAPI tag groups, rendered as sections in /docs.
|
||||||
|
OPENAPI_TAGS = [
|
||||||
|
{
|
||||||
|
"name": "Auth",
|
||||||
|
"description": (
|
||||||
|
"Session management. ping, status, login and logout are public; "
|
||||||
|
"all other /api/* and /ws/* endpoints require a session once at "
|
||||||
|
"least one user is configured."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GPU",
|
||||||
|
"description": (
|
||||||
|
"GPU discovery, static info and live monitoring. Routes accept a "
|
||||||
|
"0-based gpu_index query parameter (default 0)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Curve",
|
||||||
|
"description": (
|
||||||
|
"V/F curve read and write (per-point deltas, global offset, reset, "
|
||||||
|
"write-verify). Writes are capped by the server-side safety limit "
|
||||||
|
"(max_delta_khz in /etc/nvcurve/config.json)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Profiles",
|
||||||
|
"description": (
|
||||||
|
"Named profiles: save, apply, rename, delete. A profile captures "
|
||||||
|
"curve deltas, limits and the active fan curve."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Config",
|
||||||
|
"description": "Mutable per-GPU server configuration (auto-load profile).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Limits",
|
||||||
|
"description": "Power limit and memory clock offset (read/set/reset).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fans",
|
||||||
|
"description": (
|
||||||
|
"Fan curve control: set a curve, one-shot speed, reset to automatic "
|
||||||
|
"fan mode."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "WireView",
|
||||||
|
"description": "Thermal Grizzly WireView Pro II sensor access.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Snapshots",
|
||||||
|
"description": "ClockBoostTable snapshots: list, save, restore.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Server",
|
||||||
|
"description": "Server lifecycle.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="nvcurve",
|
||||||
|
version="0.5.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
description=(
|
||||||
|
"REST + WebSocket API for the nvcurve GPU V/F curve editor.\n\n"
|
||||||
|
"Base URL defaults to http://127.0.0.1:8042 (see `nvcurve serve --help`).\n\n"
|
||||||
|
"**Authentication** — with no users configured the API is open. Once at "
|
||||||
|
"least one user exists, every /api/* and /ws/* endpoint requires a "
|
||||||
|
"session: either the `nvcurve_session` cookie set by "
|
||||||
|
"`POST /api/auth/login` (browsers) or the returned token as "
|
||||||
|
"`Authorization: Bearer <token>` (scripts/CLI). Sessions last 24 hours.\n\n"
|
||||||
|
"**gpu_index** — GPU-selecting routes take a 0-based `gpu_index` query "
|
||||||
|
"parameter (default `0`). Unknown indexes return 404; an uninitialized "
|
||||||
|
"GPU returns 503.\n\n"
|
||||||
|
"**Errors** — JSON `{'detail': '...'}`. Write endpoints return 400 with "
|
||||||
|
"`detail.errors[]` when a delta exceeds the server-side safety cap "
|
||||||
|
"(`max_delta_khz` in /etc/nvcurve/config.json)."
|
||||||
|
),
|
||||||
|
openapi_tags=OPENAPI_TAGS,
|
||||||
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -764,16 +845,29 @@ async def _run(fn, *args):
|
|||||||
return await loop.run_in_executor(None, fn, *args)
|
return await loop.run_in_executor(None, fn, *args)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Shared OpenAPI response metadata ──────────────────────────────────────────
|
||||||
|
# Reused `responses=` entries so every route documents the same error shapes.
|
||||||
|
R_UNAUTHORIZED = {"description": "No valid session (auth is enabled)."}
|
||||||
|
R_GPU_NOT_FOUND = {"description": "Unknown gpu_index."}
|
||||||
|
R_GPU_NOT_INIT = {"description": "GPU not initialized."}
|
||||||
|
R_HW_FAILED = {"description": "Hardware operation failed (NvAPI/NVML error)."}
|
||||||
|
|
||||||
|
# Convenience bundles for routes that take a gpu_index. Annotated to match
|
||||||
|
# FastAPI's `responses` parameter type (keys may be int or str).
|
||||||
|
R_AUTH: dict[int | str, dict[str, Any]] = {401: R_UNAUTHORIZED}
|
||||||
|
R_GPU: dict[int | str, dict[str, Any]] = {404: R_GPU_NOT_FOUND, 503: R_GPU_NOT_INIT}
|
||||||
|
|
||||||
|
|
||||||
# ── Auth endpoints ────────────────────────────────────────────────────────────
|
# ── Auth endpoints ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/ping")
|
@app.get("/api/ping", tags=["Auth"])
|
||||||
async def api_ping():
|
async def api_ping():
|
||||||
"""Public liveness probe (no auth). Used by the CLI to detect a running server."""
|
"""Public liveness probe (no auth). Used by the CLI to detect a running server."""
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/auth/status")
|
@app.get("/api/auth/status", tags=["Auth"])
|
||||||
async def api_auth_status(request: Request):
|
async def api_auth_status(request: Request):
|
||||||
"""Report whether auth is required and whether this request is authenticated."""
|
"""Report whether auth is required and whether this request is authenticated."""
|
||||||
cfg: Config = _state["config"]
|
cfg: Config = _state["config"]
|
||||||
@@ -789,7 +883,15 @@ async def api_auth_status(request: Request):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/login")
|
@app.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
tags=["Auth"],
|
||||||
|
responses={
|
||||||
|
401: {"description": "Invalid username or password."},
|
||||||
|
404: {"description": "Authentication is not enabled (no users configured)."},
|
||||||
|
429: {"description": "Too many failed login attempts — client is temporarily locked out."},
|
||||||
|
},
|
||||||
|
)
|
||||||
async def api_auth_login(req: LoginRequest, request: Request):
|
async def api_auth_login(req: LoginRequest, request: Request):
|
||||||
"""Authenticate with username+password. Sets a 24-hour session cookie.
|
"""Authenticate with username+password. Sets a 24-hour session cookie.
|
||||||
|
|
||||||
@@ -834,7 +936,7 @@ async def api_auth_login(req: LoginRequest, request: Request):
|
|||||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/logout")
|
@app.post("/api/auth/logout", tags=["Auth"])
|
||||||
async def api_auth_logout(request: Request):
|
async def api_auth_logout(request: Request):
|
||||||
"""End the current session (idempotent)."""
|
"""End the current session (idempotent)."""
|
||||||
token = auth.extract_token(request)
|
token = auth.extract_token(request)
|
||||||
@@ -844,7 +946,7 @@ async def api_auth_logout(request: Request):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/auth/users")
|
@app.get("/api/auth/users", tags=["Auth"], responses=R_AUTH)
|
||||||
async def api_auth_users():
|
async def api_auth_users():
|
||||||
"""List configured usernames (requires auth)."""
|
"""List configured usernames (requires auth)."""
|
||||||
cfg: Config = _state["config"]
|
cfg: Config = _state["config"]
|
||||||
@@ -892,7 +994,7 @@ def _client_ip(request: _ClientIpSource, trusted_proxies: list[str]) -> str:
|
|||||||
# ── REST endpoints ────────────────────────────────────────────────────────────
|
# ── REST endpoints ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/gpus")
|
@app.get("/api/gpus", tags=["GPU"])
|
||||||
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
|
||||||
@@ -909,7 +1011,7 @@ async def api_gpus():
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/gpu")
|
@app.get("/api/gpu", tags=["GPU"], responses={**R_AUTH, **R_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."""
|
||||||
gpu, g_state = _require_gpu(gpu_index)
|
gpu, g_state = _require_gpu(gpu_index)
|
||||||
@@ -924,7 +1026,7 @@ async def api_gpu(gpu_index: int = 0):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/dashboard")
|
@app.get("/api/dashboard", tags=["GPU"], responses={**R_AUTH, **R_GPU})
|
||||||
async def api_dashboard(gpu_index: int = 0):
|
async def api_dashboard(gpu_index: int = 0):
|
||||||
"""Static GPU info for the Dashboard tab (VBIOS, CUDA cores, PCIe, BAR1, etc.).
|
"""Static GPU info for the Dashboard tab (VBIOS, CUDA cores, PCIe, BAR1, etc.).
|
||||||
|
|
||||||
@@ -935,7 +1037,11 @@ async def api_dashboard(gpu_index: int = 0):
|
|||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/curve")
|
@app.get(
|
||||||
|
"/api/curve",
|
||||||
|
tags=["Curve"],
|
||||||
|
responses={**R_AUTH, **R_GPU, 500: R_HW_FAILED},
|
||||||
|
)
|
||||||
async def api_curve(gpu_index: int = 0):
|
async def api_curve(gpu_index: int = 0):
|
||||||
"""Full CurveState: all V/F points with base freq, voltage, delta, effective freq."""
|
"""Full CurveState: all V/F points with base freq, voltage, delta, effective freq."""
|
||||||
gpu, g_state = _require_gpu(gpu_index)
|
gpu, g_state = _require_gpu(gpu_index)
|
||||||
@@ -948,7 +1054,16 @@ async def api_curve(gpu_index: int = 0):
|
|||||||
return _curve_state_dict(state)
|
return _curve_state_dict(state)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/curve/{point}")
|
@app.get(
|
||||||
|
"/api/curve/{point}",
|
||||||
|
tags=["Curve"],
|
||||||
|
responses={
|
||||||
|
**R_AUTH,
|
||||||
|
**R_GPU,
|
||||||
|
400: {"description": "Point index out of range."},
|
||||||
|
500: R_HW_FAILED,
|
||||||
|
},
|
||||||
|
)
|
||||||
async def api_curve_point(point: int, gpu_index: int = 0):
|
async def api_curve_point(point: int, gpu_index: int = 0):
|
||||||
"""Single V/F point detail."""
|
"""Single V/F point detail."""
|
||||||
gpu, g_state = _require_gpu(gpu_index)
|
gpu, g_state = _require_gpu(gpu_index)
|
||||||
@@ -962,7 +1077,7 @@ async def api_curve_point(point: int, gpu_index: int = 0):
|
|||||||
return _vfpoint_dict(state.points[point])
|
return _vfpoint_dict(state.points[point])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/ranges")
|
@app.get("/api/ranges", tags=["Curve"], responses={**R_AUTH, **R_GPU, 500: R_HW_FAILED})
|
||||||
async def api_ranges(gpu_index: int = 0):
|
async def api_ranges(gpu_index: int = 0):
|
||||||
"""Clock boost domain ranges (min/max offset per domain)."""
|
"""Clock boost domain ranges (min/max offset per domain)."""
|
||||||
gpu, g_state = _require_gpu(gpu_index)
|
gpu, g_state = _require_gpu(gpu_index)
|
||||||
@@ -972,7 +1087,7 @@ async def api_ranges(gpu_index: int = 0):
|
|||||||
return ranges
|
return ranges
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/voltage")
|
@app.get("/api/voltage", tags=["Curve"], responses={**R_AUTH, **R_GPU, 500: R_HW_FAILED})
|
||||||
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
|
||||||
@@ -984,7 +1099,7 @@ async def api_voltage(gpu_index: int = 0):
|
|||||||
return {"voltage_uv": voltage_uv, "voltage_mv": voltage_uv / 1000.0}
|
return {"voltage_uv": voltage_uv, "voltage_mv": voltage_uv / 1000.0}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/monitor")
|
@app.get("/api/monitor", tags=["GPU"], responses={**R_AUTH, **R_GPU})
|
||||||
async def api_monitor(gpu_index: int = 0):
|
async def api_monitor(gpu_index: int = 0):
|
||||||
"""One-shot monitoring snapshot: voltage, clock, temp, power, fan, p-state, VRAM, utilization."""
|
"""One-shot monitoring snapshot: voltage, clock, temp, power, fan, p-state, VRAM, utilization."""
|
||||||
gpu, g_state = _require_gpu(gpu_index)
|
gpu, g_state = _require_gpu(gpu_index)
|
||||||
@@ -992,7 +1107,7 @@ async def api_monitor(gpu_index: int = 0):
|
|||||||
return _sample_dict(sample)
|
return _sample_dict(sample)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/snapshots")
|
@app.get("/api/snapshots", tags=["Snapshots"], responses=R_AUTH)
|
||||||
async def api_snapshots():
|
async def api_snapshots():
|
||||||
"""List saved ClockBoostTable snapshots."""
|
"""List saved ClockBoostTable snapshots."""
|
||||||
cfg: Config = _state["config"]
|
cfg: Config = _state["config"]
|
||||||
@@ -1075,7 +1190,7 @@ def _power_cap_mode(cfg: Config, gpu_index: int) -> str:
|
|||||||
return mode if mode in ("nvml", "ioctl") else "nvml"
|
return mode if mode in ("nvml", "ioctl") else "nvml"
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/profiles")
|
@app.get("/api/profiles", tags=["Profiles"], responses=R_AUTH)
|
||||||
async def api_profiles(gpu_index: int = 0):
|
async def api_profiles(gpu_index: int = 0):
|
||||||
"""List saved native profiles, the active profile name, and the auto-load profile name."""
|
"""List saved native profiles, the active profile name, and the auto-load profile name."""
|
||||||
cfg: Config = _state["config"]
|
cfg: Config = _state["config"]
|
||||||
@@ -1089,7 +1204,7 @@ async def api_profiles(gpu_index: int = 0):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/profiles")
|
@app.post("/api/profiles", tags=["Profiles"], responses={**R_AUTH, **R_GPU, 500: R_HW_FAILED})
|
||||||
async def api_profile_save(req: ProfileSaveRequest, gpu_index: int = 0):
|
async def api_profile_save(req: ProfileSaveRequest, gpu_index: int = 0):
|
||||||
"""Save current GPU state (curve deltas + limits) as a named profile."""
|
"""Save current GPU state (curve deltas + limits) as a named profile."""
|
||||||
gpu, g_state = _require_gpu(gpu_index)
|
gpu, g_state = _require_gpu(gpu_index)
|
||||||
@@ -1284,7 +1399,16 @@ async def _apply_profile(name: str, gpu_index: int = 0) -> list[str]:
|
|||||||
return errs
|
return errs
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/profiles/{name}/apply")
|
@app.post(
|
||||||
|
"/api/profiles/{name}/apply",
|
||||||
|
tags=["Profiles"],
|
||||||
|
responses={
|
||||||
|
**R_AUTH,
|
||||||
|
**R_GPU,
|
||||||
|
404: {"description": "Profile not found."},
|
||||||
|
500: {"description": "Profile failed to apply (per-part errors in detail)."},
|
||||||
|
},
|
||||||
|
)
|
||||||
async def api_profile_apply(name: str, gpu_index: int = 0):
|
async def api_profile_apply(name: str, gpu_index: int = 0):
|
||||||
"""Apply a saved profile to hardware (curve deltas + limits)."""
|
"""Apply a saved profile to hardware (curve deltas + limits)."""
|
||||||
_require_gpu(gpu_index)
|
_require_gpu(gpu_index)
|
||||||
@@ -1303,7 +1427,11 @@ async def api_profile_apply(name: str, gpu_index: int = 0):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/profiles/{name}")
|
@app.delete(
|
||||||
|
"/api/profiles/{name}",
|
||||||
|
tags=["Profiles"],
|
||||||
|
responses={**R_AUTH, 404: {"description": "Profile not found."}},
|
||||||
|
)
|
||||||
async def api_profile_delete(name: str):
|
async def api_profile_delete(name: str):
|
||||||
"""Delete a saved profile by name."""
|
"""Delete a saved profile by name."""
|
||||||
cfg: Config = _state["config"]
|
cfg: Config = _state["config"]
|
||||||
@@ -1322,7 +1450,15 @@ async def api_profile_delete(name: str):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/profiles/{name}/rename")
|
@app.post(
|
||||||
|
"/api/profiles/{name}/rename",
|
||||||
|
tags=["Profiles"],
|
||||||
|
responses={
|
||||||
|
**R_AUTH,
|
||||||
|
400: {"description": "New name is empty."},
|
||||||
|
404: {"description": "Profile not found."},
|
||||||
|
},
|
||||||
|
)
|
||||||
async def api_profile_rename(name: str, req: ProfileRenameRequest):
|
async def api_profile_rename(name: str, req: ProfileRenameRequest):
|
||||||
"""Rename a profile."""
|
"""Rename a profile."""
|
||||||
cfg: Config = _state["config"]
|
cfg: Config = _state["config"]
|
||||||
@@ -1344,14 +1480,14 @@ async def api_profile_rename(name: str, req: ProfileRenameRequest):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/config")
|
@app.get("/api/config", tags=["Config"], responses=R_AUTH)
|
||||||
async def api_config_get(gpu_index: int = 0):
|
async def api_config_get(gpu_index: int = 0):
|
||||||
"""Get mutable server configuration for a specific GPU."""
|
"""Get mutable server configuration for a specific GPU."""
|
||||||
cfg: Config = _state["config"]
|
cfg: Config = _state["config"]
|
||||||
return {"auto_load_profile": cfg.auto_load_profiles.get(_gpu_stable_key(gpu_index))}
|
return {"auto_load_profile": cfg.auto_load_profiles.get(_gpu_stable_key(gpu_index))}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/config")
|
@app.post("/api/config", tags=["Config"], responses={**R_AUTH, 404: R_GPU_NOT_FOUND})
|
||||||
async def api_config_update(req: ConfigUpdateRequest):
|
async def api_config_update(req: ConfigUpdateRequest):
|
||||||
"""Update mutable server configuration. Changes persist to /etc/nvcurve/config.json if present."""
|
"""Update mutable server configuration. Changes persist to /etc/nvcurve/config.json if present."""
|
||||||
if req.gpu_index not in _state["gpus"]:
|
if req.gpu_index not in _state["gpus"]:
|
||||||
@@ -1366,7 +1502,7 @@ async def api_config_update(req: ConfigUpdateRequest):
|
|||||||
return {"ok": True, "auto_load_profile": cfg.auto_load_profiles.get(key)}
|
return {"ok": True, "auto_load_profile": cfg.auto_load_profiles.get(key)}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/limits")
|
@app.get("/api/limits", tags=["Limits"], responses={**R_AUTH, 404: R_GPU_NOT_FOUND})
|
||||||
async def api_limits(gpu_index: int = 0):
|
async def api_limits(gpu_index: int = 0):
|
||||||
"""Current performance limits: power and clock offsets."""
|
"""Current performance limits: power and clock offsets."""
|
||||||
cfg: Config = _state["config"]
|
cfg: Config = _state["config"]
|
||||||
@@ -1381,7 +1517,19 @@ async def api_limits(gpu_index: int = 0):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/limits")
|
@app.post(
|
||||||
|
"/api/limits",
|
||||||
|
tags=["Limits"],
|
||||||
|
responses={
|
||||||
|
**R_AUTH,
|
||||||
|
404: R_GPU_NOT_FOUND,
|
||||||
|
400: {"description": "Invalid power_cap_mode (must be 'nvml' or 'ioctl')."},
|
||||||
|
409: {
|
||||||
|
"description": "ioctl power mode requested but not supported on this GPU/driver."
|
||||||
|
},
|
||||||
|
500: R_HW_FAILED,
|
||||||
|
},
|
||||||
|
)
|
||||||
async def api_limits_update(req: LimitsRequest, gpu_index: int = 0):
|
async def api_limits_update(req: LimitsRequest, gpu_index: int = 0):
|
||||||
"""Update performance limits."""
|
"""Update performance limits."""
|
||||||
g_state = _get_gpu_state(gpu_index)
|
g_state = _get_gpu_state(gpu_index)
|
||||||
@@ -1473,7 +1621,7 @@ async def _update_offsets_and_broadcast(gpu_index: int) -> None:
|
|||||||
g_state["last_offsets"] = offsets
|
g_state["last_offsets"] = offsets
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/limits/reset")
|
@app.post("/api/limits/reset", tags=["Limits"], responses={**R_AUTH, 404: R_GPU_NOT_FOUND, 500: R_HW_FAILED})
|
||||||
async def api_limits_reset(gpu_index: int = 0):
|
async def api_limits_reset(gpu_index: int = 0):
|
||||||
"""Reset power limit to hardware default and memory clock offset to 0."""
|
"""Reset power limit to hardware default and memory clock offset to 0."""
|
||||||
g_state = _get_gpu_state(gpu_index)
|
g_state = _get_gpu_state(gpu_index)
|
||||||
@@ -1508,7 +1656,7 @@ async def api_limits_reset(gpu_index: int = 0):
|
|||||||
# ── Fan endpoints ──────────────────────────────────────────────────────────────
|
# ── Fan endpoints ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/fans")
|
@app.get("/api/fans", tags=["Fans"], responses={**R_AUTH, 404: R_GPU_NOT_FOUND})
|
||||||
async def api_fans(gpu_index: int = 0):
|
async def api_fans(gpu_index: int = 0):
|
||||||
"""Current fan state: per-fan %, curve, and whether curve control is active."""
|
"""Current fan state: per-fan %, curve, and whether curve control is active."""
|
||||||
_get_gpu_state(gpu_index)
|
_get_gpu_state(gpu_index)
|
||||||
@@ -1524,7 +1672,16 @@ async def api_fans(gpu_index: int = 0):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/fans")
|
@app.post(
|
||||||
|
"/api/fans",
|
||||||
|
tags=["Fans"],
|
||||||
|
responses={
|
||||||
|
**R_AUTH,
|
||||||
|
404: R_GPU_NOT_FOUND,
|
||||||
|
400: {"description": "Invalid fan curve (e.g. non-monotonic temperatures)."},
|
||||||
|
500: {"description": "Fan control not available on this GPU."},
|
||||||
|
},
|
||||||
|
)
|
||||||
async def api_fans_update(req: FanCurveRequest, gpu_index: int = 0):
|
async def api_fans_update(req: FanCurveRequest, gpu_index: int = 0):
|
||||||
"""Set or update the fan curve. Starts the fan control poller.
|
"""Set or update the fan curve. Starts the fan control poller.
|
||||||
|
|
||||||
@@ -1563,7 +1720,7 @@ async def api_fans_update(req: FanCurveRequest, gpu_index: int = 0):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/fans/reset")
|
@app.post("/api/fans/reset", tags=["Fans"], responses={**R_AUTH, 404: R_GPU_NOT_FOUND})
|
||||||
async def api_fans_reset(gpu_index: int = 0):
|
async def api_fans_reset(gpu_index: int = 0):
|
||||||
"""Deactivate fan curve control and restore automatic fan mode."""
|
"""Deactivate fan curve control and restore automatic fan mode."""
|
||||||
_get_gpu_state(gpu_index)
|
_get_gpu_state(gpu_index)
|
||||||
@@ -1575,7 +1732,7 @@ async def api_fans_reset(gpu_index: int = 0):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/fans/speed")
|
@app.post("/api/fans/speed", tags=["Fans"], responses={**R_AUTH, 404: R_GPU_NOT_FOUND, 500: R_HW_FAILED})
|
||||||
async def api_fans_speed(req: FanSpeedRequest, gpu_index: int = 0):
|
async def api_fans_speed(req: FanSpeedRequest, gpu_index: int = 0):
|
||||||
"""One-shot set fan(s) to an exact percentage (bypasses curve).
|
"""One-shot set fan(s) to an exact percentage (bypasses curve).
|
||||||
|
|
||||||
@@ -1593,7 +1750,7 @@ async def api_fans_speed(req: FanSpeedRequest, gpu_index: int = 0):
|
|||||||
# ── WireView Pro II (Thermal Grizzly) ─────────────────────────────────────────
|
# ── WireView Pro II (Thermal Grizzly) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/wireview")
|
@app.get("/api/wireview", tags=["WireView"], responses=R_AUTH)
|
||||||
async def api_wireview():
|
async def api_wireview():
|
||||||
"""WireView Pro II availability and the most recent sensor sample.
|
"""WireView Pro II availability and the most recent sensor sample.
|
||||||
|
|
||||||
@@ -1645,7 +1802,16 @@ async def _reconcile_check(gpu_index: int) -> dict | None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/curve/write")
|
@app.post(
|
||||||
|
"/api/curve/write",
|
||||||
|
tags=["Curve"],
|
||||||
|
responses={
|
||||||
|
**R_AUTH,
|
||||||
|
**R_GPU,
|
||||||
|
400: {"description": "Delta exceeds the server-side safety cap (max_delta_khz); detail.errors[] lists the offending points."},
|
||||||
|
500: R_HW_FAILED,
|
||||||
|
},
|
||||||
|
)
|
||||||
async def api_curve_write(req: WriteRequest, gpu_index: int = 0):
|
async def api_curve_write(req: WriteRequest, gpu_index: int = 0):
|
||||||
"""Write per-point frequency offsets. {deltas: {point_index: delta_kHz}}"""
|
"""Write per-point frequency offsets. {deltas: {point_index: delta_kHz}}"""
|
||||||
gpu, g_state = _require_gpu(gpu_index)
|
gpu, g_state = _require_gpu(gpu_index)
|
||||||
@@ -1696,7 +1862,16 @@ async def api_curve_write(req: WriteRequest, gpu_index: int = 0):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/curve/write/global")
|
@app.post(
|
||||||
|
"/api/curve/write/global",
|
||||||
|
tags=["Curve"],
|
||||||
|
responses={
|
||||||
|
**R_AUTH,
|
||||||
|
**R_GPU,
|
||||||
|
400: {"description": "Offset exceeds the server-side safety cap (max_delta_khz); detail.errors[] lists the offending points."},
|
||||||
|
500: R_HW_FAILED,
|
||||||
|
},
|
||||||
|
)
|
||||||
async def api_curve_write_global(req: GlobalOffsetRequest, gpu_index: int = 0):
|
async def api_curve_write_global(req: GlobalOffsetRequest, gpu_index: int = 0):
|
||||||
"""Apply a uniform frequency offset to all curve points."""
|
"""Apply a uniform frequency offset to all curve points."""
|
||||||
gpu, g_state = _require_gpu(gpu_index)
|
gpu, g_state = _require_gpu(gpu_index)
|
||||||
@@ -1746,7 +1921,7 @@ async def api_curve_write_global(req: GlobalOffsetRequest, gpu_index: int = 0):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/curve/reset")
|
@app.post("/api/curve/reset", tags=["Curve"], responses={**R_AUTH, **R_GPU, 500: R_HW_FAILED})
|
||||||
async def api_curve_reset(gpu_index: int = 0):
|
async def api_curve_reset(gpu_index: int = 0):
|
||||||
"""Reset all frequency offsets to zero."""
|
"""Reset all frequency offsets to zero."""
|
||||||
gpu, g_state = _require_gpu(gpu_index)
|
gpu, g_state = _require_gpu(gpu_index)
|
||||||
@@ -1777,7 +1952,16 @@ async def api_curve_reset(gpu_index: int = 0):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/curve/verify")
|
@app.post(
|
||||||
|
"/api/curve/verify",
|
||||||
|
tags=["Curve"],
|
||||||
|
responses={
|
||||||
|
**R_AUTH,
|
||||||
|
**R_GPU,
|
||||||
|
400: {"description": "Delta exceeds the server-side safety cap (max_delta_khz); detail.errors[] lists the offending points."},
|
||||||
|
500: R_HW_FAILED,
|
||||||
|
},
|
||||||
|
)
|
||||||
async def api_curve_verify(req: VerifyRequest, gpu_index: int = 0):
|
async def api_curve_verify(req: VerifyRequest, gpu_index: int = 0):
|
||||||
"""Write-verify-read cycle. Returns per-point match results and collateral changes."""
|
"""Write-verify-read cycle. Returns per-point match results and collateral changes."""
|
||||||
gpu, g_state = _require_gpu(gpu_index)
|
gpu, g_state = _require_gpu(gpu_index)
|
||||||
@@ -1847,7 +2031,14 @@ async def api_curve_verify(req: VerifyRequest, gpu_index: int = 0):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/shutdown")
|
@app.post(
|
||||||
|
"/api/shutdown",
|
||||||
|
tags=["Server"],
|
||||||
|
responses={
|
||||||
|
**R_AUTH,
|
||||||
|
403: {"description": "API shutdown disabled (allow_api_shutdown: false in config)."},
|
||||||
|
},
|
||||||
|
)
|
||||||
async def api_shutdown():
|
async def api_shutdown():
|
||||||
"""Gracefully shut down the server process.
|
"""Gracefully shut down the server process.
|
||||||
|
|
||||||
@@ -1869,7 +2060,7 @@ async def api_shutdown():
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/snapshot/save")
|
@app.post("/api/snapshot/save", tags=["Snapshots"], responses={**R_AUTH, **R_GPU, 500: R_HW_FAILED})
|
||||||
async def api_snapshot_save(gpu_index: int = 0):
|
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)
|
||||||
@@ -1882,7 +2073,7 @@ async def api_snapshot_save(gpu_index: int = 0):
|
|||||||
return {"ok": True, "filepath": path}
|
return {"ok": True, "filepath": path}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/snapshot/restore")
|
@app.post("/api/snapshot/restore", tags=["Snapshots"], responses={**R_AUTH, **R_GPU, 500: R_HW_FAILED})
|
||||||
async def api_snapshot_restore(req: SnapshotRestoreRequest, gpu_index: int = 0):
|
async def api_snapshot_restore(req: SnapshotRestoreRequest, gpu_index: int = 0):
|
||||||
"""Restore a ClockBoostTable snapshot. Uses most recent if filepath not specified."""
|
"""Restore a ClockBoostTable snapshot. Uses most recent if filepath not specified."""
|
||||||
gpu, g_state = _require_gpu(gpu_index)
|
gpu, g_state = _require_gpu(gpu_index)
|
||||||
@@ -2087,7 +2278,7 @@ if os.path.isdir(os.path.join(_dist_dir, "assets")):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/{catchall:path}")
|
@app.get("/{catchall:path}", include_in_schema=False)
|
||||||
async def serve_spa(catchall: str):
|
async def serve_spa(catchall: str):
|
||||||
if catchall.startswith(("api/", "ws/")):
|
if catchall.startswith(("api/", "ws/")):
|
||||||
raise HTTPException(status_code=404, detail="Not Found")
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
|
|||||||
Reference in new issue
Block a user