Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8462e696c | ||
|
|
d810c44478 | ||
|
|
39701c12ff | ||
|
|
8956fc9d7b | ||
|
|
6cb33187d3 | ||
|
|
34a9bc6d6e | ||
|
|
930e56bd07 | ||
|
|
fa944c9576 | ||
|
|
9006c22fde | ||
|
|
b9d40f007d | ||
|
|
418894a77b | ||
|
|
06db52e8ac | ||
|
|
d9260007d1 | ||
|
|
9cf2009d2c | ||
|
|
0320cea895 |
No files matched your search
@@ -15,3 +15,5 @@ node_modules/
|
|||||||
/build/
|
/build/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.claude
|
.claude
|
||||||
|
# Local tool data
|
||||||
|
.codegraph/
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Comprehensive Documentation**: Added structured docs covering overview, installation, usage guide, and tips and tricks. README pruned to essentials with links to docs.
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
## [0.5.0] - 2026-03-23
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
- **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.
|
|
||||||
- **Global Arguments**: Fixed global argument parsing in the CLI to allow global flags (like `--gpu`) to be placed after subcommands.
|
|
||||||
|
|
||||||
## [0.4.0] - 2026-03-17
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
- **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.
|
|
||||||
- CLI: `nvcurve profile default <name>` to set, `nvcurve profile default --clear` to unset.
|
|
||||||
- The web UI shows a filled star on the default profile and lets you toggle it with a single click.
|
|
||||||
- The setting persists to `/etc/nvcurve/config.json` (created by `service install`). If the config file is absent, the setting is in-memory for the current session only.
|
|
||||||
- **Curve Flattening**: Selecting two or more points and clicking "Flatten to [anchor]" in the toolbar sets each selected point to a different offset, such that all land on the same effective frequency as the anchor point. The anchor is the last explicitly clicked point (highlighted with an amber halo on the graph); bulk selections (box, range, Ctrl+A) preserve the existing anchor.
|
|
||||||
- **Server-Optional Profile Commands**: `profile apply`, `profile list`, and `profile default` no longer require the server to be running. When the server is absent they fall back to direct HAL calls or config-file writes, escalating to root via `sudo` automatically — the same pattern already used by snapshot commands.
|
|
||||||
- **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
|
|
||||||
|
|
||||||
- **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.
|
|
||||||
- **Offline Snapshots**: `snapshot save`, `restore`, and `list` bypass the server and fall back to direct HAL operations when the daemon is not running.
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# NVCurve — developer convenience targets.
|
||||||
|
#
|
||||||
|
# End users don't need make: run ./install.sh (see README "Installation").
|
||||||
|
|
||||||
|
UV ?= uv
|
||||||
|
NPM ?= npm
|
||||||
|
|
||||||
|
.PHONY: help frontend frontend-dev install dev test clean
|
||||||
|
|
||||||
|
help: ## Show available targets
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
frontend: ## Build the React frontend into frontend/dist
|
||||||
|
cd frontend && $(NPM) ci && $(NPM) run build
|
||||||
|
|
||||||
|
frontend-dev: ## Run the Vite dev server (hot reload)
|
||||||
|
cd frontend && $(NPM) run dev
|
||||||
|
|
||||||
|
install: ## Install nvcurve as a uv tool (builds frontend if missing/stale)
|
||||||
|
$(UV) tool install --force .
|
||||||
|
|
||||||
|
dev: ## Create/refresh the dev environment (uv sync)
|
||||||
|
$(UV) sync
|
||||||
|
|
||||||
|
test: ## Run the test suite
|
||||||
|
$(UV) run python tests/test_security.py
|
||||||
|
|
||||||
|
clean: ## Remove build artifacts
|
||||||
|
rm -rf frontend/dist frontend/node_modules
|
||||||
@@ -12,9 +12,22 @@ NVCurve brings MSI Afterburner-style per-point voltage-frequency curve control t
|
|||||||
> **Experimental software.** Undocumented NvAPI functions may change between driver releases. Write operations alter GPU operational parameters. Always run `nvcurve setup` before applying changes.
|
> **Experimental software.** Undocumented NvAPI functions may change between driver releases. Write operations alter GPU operational parameters. Always run `nvcurve setup` before applying changes.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> **Blackwell GPU Fork** — This is a specialized fork with extended memory offset support (> +1000 MHz) and custom fan curve control for Blackwell GPUs (RTX 50-series). Installing the pre-built PyPI package will NOT include these features. You must build from source.
|
> **Blackwell GPU memory** — This is a specialized fork with extended memory offset support (up to +3000 MHz) for Blackwell GPUs (RTX 50-series). \
|
||||||
|
> **Fan Controls** — There is an additional "Fans" tab to setup a customized fan curve, controlling all fans or individual fans. \
|
||||||
|
> **Dashboard** — The default tab is an Dashboard with additional information (PCIe link speed, VBIOS information, Max Core Clock, Throttle Reason and much much more.) \
|
||||||
|
> **Authentification** — For production deplyoment, I added authentification with bcrypt hashing to allow only one or multiple people to have access. \
|
||||||
|
> Installing the pre-built PyPI package will NOT include these features. You must build from source.
|
||||||
|
|
||||||

|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><img src="docs/dashboard.png" width="480" alt="Dashboard"></td>
|
||||||
|
<td align="center"><img src="docs/curve.png" width="480" alt="Curve Editor"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><img src="docs/performance.png" width="480" alt="Performance"></td>
|
||||||
|
<td align="center"><img src="docs/fans.png" width="480" alt="Fans"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -24,20 +37,28 @@ NVCurve brings MSI Afterburner-style per-point voltage-frequency curve control t
|
|||||||
- **[uv](https://docs.astral.sh/uv/)** — Python package manager
|
- **[uv](https://docs.astral.sh/uv/)** — Python package manager
|
||||||
- **Root/sudo access** (required for GPU hardware interactions)
|
- **Root/sudo access** (required for GPU hardware interactions)
|
||||||
|
|
||||||
## Installation from Source
|
## Installation
|
||||||
|
|
||||||
|
### One-liner
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <this-repo-url>.git
|
curl -fsSL https://gitea.zephyre.one/Pakobbix/nvcurve/raw/branch/main/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
The script checks prerequisites (installs `uv` if missing), clones the repo, and installs NVCurve — the React frontend is compiled automatically during the build.
|
||||||
|
|
||||||
|
### From a clone
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.zephyre.one/Pakobbix/nvcurve.git
|
||||||
cd nvcurve
|
cd nvcurve
|
||||||
|
./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
# Build the React frontend
|
### Direct from git (no clone, no script)
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
# Install the Python package (includes bundled frontend)
|
```bash
|
||||||
uv tool install .
|
uv tool install "git+https://gitea.zephyre.one/Pakobbix/nvcurve.git"
|
||||||
```
|
```
|
||||||
|
|
||||||
After installation, verify hardware compatibility:
|
After installation, verify hardware compatibility:
|
||||||
@@ -65,6 +86,20 @@ 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.
|
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.
|
||||||
|
|
||||||
|
## TLS (HTTPS)
|
||||||
|
|
||||||
|
The server speaks **plain HTTP by default**. For network access (e.g. behind a reverse proxy or on a LAN), you can enable TLS so the web UI, API, and WebSocket all run over HTTPS — the session cookie is then marked `Secure`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# One-off (this server run only)
|
||||||
|
nvcurve serve start --ssl-certfile /path/to/cert.pem --ssl-keyfile /path/to/key.pem
|
||||||
|
|
||||||
|
# Persistent (stored in /etc/nvcurve/config.json; used by the daemon too)
|
||||||
|
sudo nvcurve service configure --ssl-certfile /path/to/cert.pem --ssl-keyfile /path/to/key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
With TLS enabled the UI is at `https://<host>:8042` and the CLI switches to `https://` automatically. A self-signed certificate works for local use (the browser will warn); for multi-user setups use a certificate your browser trusts (e.g. via your internal CA or a reverse proxy).
|
||||||
|
|
||||||
## 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:
|
||||||
@@ -136,6 +171,10 @@ The daemon reads settings from `/etc/nvcurve/config.json`:
|
|||||||
"max_delta_khz": 3000000,
|
"max_delta_khz": 3000000,
|
||||||
"auto_snapshot": true,
|
"auto_snapshot": true,
|
||||||
"max_snapshots": 20,
|
"max_snapshots": 20,
|
||||||
|
"ssl_certfile": null,
|
||||||
|
"ssl_keyfile": null,
|
||||||
|
"trusted_proxies": [],
|
||||||
|
"allow_api_shutdown": true,
|
||||||
"auto_load_profiles": {
|
"auto_load_profiles": {
|
||||||
"idx:0": "my_profile"
|
"idx:0": "my_profile"
|
||||||
}
|
}
|
||||||
@@ -147,9 +186,12 @@ The daemon reads settings from `/etc/nvcurve/config.json`:
|
|||||||
| `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 |
|
||||||
| `max_delta_khz` | Safety cap for frequency offsets (default 3000 MHz) |
|
| `max_delta_khz` | Safety cap for frequency offsets (default 3000 MHz). Enforced server-side; API clients cannot raise it per request |
|
||||||
| `auto_snapshot` | Save snapshot before every write |
|
| `auto_snapshot` | Save snapshot before every write |
|
||||||
| `max_snapshots` | Max snapshots to keep (`0` = unlimited) |
|
| `max_snapshots` | Max snapshots to keep (`0` = unlimited) |
|
||||||
|
| `ssl_certfile` / `ssl_keyfile` | TLS certificate/key — enables HTTPS when both are set (default: off) |
|
||||||
|
| `trusted_proxies` | Proxy IPs whose `X-Forwarded-For` is trusted for the login lockout (e.g. `["127.0.0.1"]` for a local reverse proxy) |
|
||||||
|
| `allow_api_shutdown` | Allow authenticated users to stop the server via `POST /api/shutdown` (set `false` on shared systems; use systemd instead) |
|
||||||
| `auto_load_profiles` | Per-GPU profile to apply on boot (`{gpu_key: profile_name}`) |
|
| `auto_load_profiles` | Per-GPU profile to apply on boot (`{gpu_key: profile_name}`) |
|
||||||
|
|
||||||
The GPU key can be a UUID, `pci:XXXX`, or `idx:N` fallback. Find your GPU key with `nvcurve gpus`.
|
The GPU key can be a UUID, `pci:XXXX`, or `idx:N` fallback. Find your GPU key with `nvcurve gpus`.
|
||||||
@@ -166,10 +208,11 @@ The GPU key can be a UUID, `pci:XXXX`, or `idx:N` fallback. Find your GPU key wi
|
|||||||
```bash
|
```bash
|
||||||
cd nvcurve
|
cd nvcurve
|
||||||
git pull
|
git pull
|
||||||
cd frontend && npm run build && cd ..
|
uv tool install --force .
|
||||||
uv tool install .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The frontend is rebuilt automatically if it is missing or older than the frontend sources. If you modified frontend code locally, run `make frontend` first (or `rm -rf frontend/dist`).
|
||||||
|
|
||||||
If running as a systemd service:
|
If running as a systemd service:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+28
-19
@@ -29,37 +29,45 @@ sudo pacman -S uv
|
|||||||
pip install uv
|
pip install uv
|
||||||
```
|
```
|
||||||
|
|
||||||
## Installation from Source
|
## Installation
|
||||||
|
|
||||||
### Step 1: Clone the Repository
|
### Option 1: One-liner (recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <this-repo-url>.git
|
curl -fsSL https://gitea.zephyre.one/Pakobbix/nvcurve/raw/branch/main/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
The script checks prerequisites (installs `uv` if missing), clones the repository, and installs NVCurve. The React frontend is compiled automatically during the build by a hatchling build hook (`hatch_build.py`).
|
||||||
|
|
||||||
|
### Option 2: From a clone
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.zephyre.one/Pakobbix/nvcurve.git
|
||||||
cd nvcurve
|
cd nvcurve
|
||||||
|
./install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Build the Frontend
|
Equivalent manual steps (what the script does):
|
||||||
|
|
||||||
The frontend is a React + TypeScript + Vite application in the `frontend/` directory.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
git clone https://gitea.zephyre.one/Pakobbix/nvcurve.git
|
||||||
npm install
|
cd nvcurve
|
||||||
npm run build
|
uv tool install . # frontend is built automatically if missing/stale
|
||||||
cd ..
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This produces a `dist/` directory with the compiled static assets. The hatch build system bundles `frontend/dist` into the Python package.
|
### Option 3: Direct from git (no clone, no script)
|
||||||
|
|
||||||
### Step 3: Install the Python Package
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv tool install .
|
uv tool install "git+https://gitea.zephyre.one/Pakobbix/nvcurve.git"
|
||||||
```
|
```
|
||||||
|
|
||||||
This installs `nvcurve` as a system-wide tool with the bundled frontend.
|
To install a specific branch:
|
||||||
|
|
||||||
### Step 4: Verify
|
```bash
|
||||||
|
uv tool install "git+https://gitea.zephyre.one/Pakobbix/nvcurve.git@<branch>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nvcurve setup
|
nvcurve setup
|
||||||
@@ -94,10 +102,11 @@ nvcurve serve start
|
|||||||
```bash
|
```bash
|
||||||
cd nvcurve
|
cd nvcurve
|
||||||
git pull
|
git pull
|
||||||
cd frontend && npm run build && cd ..
|
uv tool install --force .
|
||||||
uv tool install .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The frontend is rebuilt automatically if it is missing or older than the frontend sources. If you modified frontend code locally, run `make frontend` first (or `rm -rf frontend/dist`).
|
||||||
|
|
||||||
If running as a systemd service:
|
If running as a systemd service:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -116,7 +125,7 @@ source ~/.local/bin/env # or wherever uv installed
|
|||||||
|
|
||||||
### Frontend not loading in the web UI
|
### Frontend not loading in the web UI
|
||||||
|
|
||||||
Verify that `frontend/dist` exists and contains built assets. If the directory is empty or missing, rebuild with `npm run build` and reinstall with `uv tool install .`.
|
Verify that the installed package contains the frontend. If `frontend/dist` is empty or missing, rebuild with `make frontend` (or `cd frontend && npm ci && npm run build`) and reinstall with `uv tool install --force .`.
|
||||||
|
|
||||||
### NvAPI functions not found
|
### NvAPI functions not found
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ NVCurve provides two ways to interact with your GPU:
|
|||||||
|
|
||||||
- **Per-Point Curve Editing** — Adjust the frequency offset for any individual voltage point on the V/F curve.
|
- **Per-Point Curve Editing** — Adjust the frequency offset for any individual voltage point on the V/F curve.
|
||||||
- **Extended Memory Offset** — Memory clock offset up to +3000 MHz (Blackwell GPUs). Standard NVCurve caps at +1000 MHz.
|
- **Extended Memory Offset** — Memory clock offset up to +3000 MHz (Blackwell GPUs). Standard NVCurve caps at +1000 MHz.
|
||||||
- **Fan Curve Control** — Custom temperature-to-fan-speed curves via NVML, adjustable through the web UI and savable in profiles.
|
- **Fan Curve Control** — Custom temperature-to-fan-speed curves via NVML (all fans or individual fans), adjustable through the web UI and savable in profiles.
|
||||||
- **Curve Flattening** — Select multiple points and flatten them to a common frequency using anchor-point targeting.
|
- **Curve Flattening** — Select multiple points and flatten them to a common frequency using anchor-point targeting.
|
||||||
- **Live Monitoring** — Track GPU voltage, clock speed, temperature, and power draw in real time via NvAPI and NVML.
|
- **Live Monitoring** — Track GPU voltage, clock speed, temperature, and power draw in real time via NvAPI and NVML.
|
||||||
- **Profile Management** — Save, load, and switch between named profiles. Set a default profile that auto-applies on startup.
|
- **Profile Management** — Save, load, and switch between named profiles. Set a default profile that auto-applies on startup.
|
||||||
|
|||||||
+23
-1
@@ -145,6 +145,27 @@ Adding the first user **switches the server into authenticated mode immediately*
|
|||||||
- The user store file should stay root-owned and `0600` (the CLI enforces this).
|
- 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 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.
|
- The `nvcurve user` commands and the user store require root; day-to-day sign-in does not.
|
||||||
|
- The login lockout is keyed by client IP. Behind a reverse proxy all clients share the proxy's IP — set `trusted_proxies` in `/etc/nvcurve/config.json` (e.g. `["127.0.0.1"]`) so the lockout uses the real client IP from `X-Forwarded-For`. The header is only honoured for peers you list there (it is spoofable otherwise).
|
||||||
|
- On shared systems consider setting `allow_api_shutdown: false` so users cannot stop the server via the API (manage it with systemd instead).
|
||||||
|
- The frequency safety cap (`max_delta_khz`) is enforced by the server from its config; API clients cannot raise it per request. The CLI's `--max-delta` (root-only, direct hardware path) can still override it for a single write.
|
||||||
|
|
||||||
|
## TLS (HTTPS)
|
||||||
|
|
||||||
|
The server speaks **plain HTTP by default**. When you expose it beyond localhost, enable TLS so credentials and session cookies are not sent in cleartext:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Persistent (stored in /etc/nvcurve/config.json, used by the daemon too)
|
||||||
|
sudo nvcurve service configure --ssl-certfile /path/to/cert.pem --ssl-keyfile /path/to/key.pem
|
||||||
|
|
||||||
|
# One-off
|
||||||
|
nvcurve serve start --ssl-certfile /path/to/cert.pem --ssl-keyfile /path/to/key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
- Both files must be set for TLS to activate; the UI then lives at `https://<host>:8042` and the WebSocket upgrades to `wss://` automatically.
|
||||||
|
- To disable TLS again: `sudo nvcurve service configure --no-ssl` (removes the certificate/key from the config).
|
||||||
|
- The session cookie gets the `Secure` flag, so it is only sent over HTTPS.
|
||||||
|
- A self-signed certificate is fine for a home LAN (the browser shows a warning); for multi-user setups use a certificate your browser trusts.
|
||||||
|
- The CLI detects TLS from the config/runtime info and switches to `https://` automatically.
|
||||||
|
|
||||||
## CLI Reference
|
## CLI Reference
|
||||||
|
|
||||||
@@ -251,13 +272,14 @@ nvcurve service uninstall
|
|||||||
sudo nvcurve service configure --auto-serve
|
sudo nvcurve service configure --auto-serve
|
||||||
sudo nvcurve service configure --no-auto-serve
|
sudo nvcurve service configure --no-auto-serve
|
||||||
sudo nvcurve service configure --host 0.0.0.0 --port 8042
|
sudo nvcurve service configure --host 0.0.0.0 --port 8042
|
||||||
|
sudo nvcurve service configure --ssl-certfile /path/to/cert.pem --ssl-keyfile /path/to/key.pem
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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, TLS, safety cap, 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 |
|
||||||
| `/run/nvcurve.json` | Runtime server info (host, port, PID) |
|
| `/run/nvcurve.json` | Runtime server info (host, port, PID) |
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
-271
@@ -1,271 +0,0 @@
|
|||||||
# Fan Tab Implementation Plan
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Add a third **Fans** tab to the WebUI alongside the existing `Curve` and `Performance` tabs. The tab presents a fan speed curve editor (temperature → target fan %) with a Live Monitor sidebar, and the ability to apply, save in profiles, and reset fan settings.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture Decision: Fan Control via NVML
|
|
||||||
|
|
||||||
Fan control will use **NVML (pynvml)**, not NvAPI. Rationale:
|
|
||||||
- `nvmlDeviceSetFanSpeed(handle, speed)` is well-documented and widely supported
|
|
||||||
- `nvmlDeviceGetFanSpeed(handle)` is already used in `hal/monitoring.py:108` for reading
|
|
||||||
- `nvmlDeviceGetFanSpeedInfo(handle)` returns current mode (0=auto, 1=manual) and current speed
|
|
||||||
- No need to reverse-engineer NvAPI fan functions — NVML provides a clean, stable API
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Plan
|
|
||||||
|
|
||||||
### Phase 1: Backend — HAL Layer
|
|
||||||
|
|
||||||
#### 1.1 New file: `nvcurve/hal/fans.py`
|
|
||||||
|
|
||||||
Fan curve model: a list of **temperature → fan %** target points, similar to the existing V/F curve concept but simpler (no NvAPI table, just user-defined targets).
|
|
||||||
|
|
||||||
```
|
|
||||||
FanPoint:
|
|
||||||
temp_c: int # temperature threshold in °C (e.g. 30, 40, 50, 60, 70, 80)
|
|
||||||
fan_pct: int # target fan speed at that temp (0-100 %)
|
|
||||||
```
|
|
||||||
|
|
||||||
Functions:
|
|
||||||
- `get_fan_state(gpu_index) -> dict` — returns current fan %, fan mode (auto/manual), min/max fan speeds
|
|
||||||
- `set_fan_speed(gpu_index, pct) -> tuple[bool, str]` — sets fan to a specific % via `nvmlDeviceSetFanSpeed`
|
|
||||||
- `reset_fan(gpu_index) -> tuple[bool, str]` — restores automatic fan control
|
|
||||||
- `get_fan_curve(gpu_index) -> list[dict]` — returns currently stored fan curve points (from config/profile)
|
|
||||||
- `apply_fan_curve(gpu_index, curve) -> None` — background thread that reads temp, interpolates fan % from curve, and calls `set_fan_speed` periodically
|
|
||||||
|
|
||||||
Key detail: Unlike V/F curve or power limits (one-shot writes), a fan curve needs a **continuous feedback loop**. The daemon/server needs a background task that:
|
|
||||||
1. Reads current GPU temp (already available via monitoring poller)
|
|
||||||
2. Interpolates the target fan % from the active fan curve
|
|
||||||
3. Calls `set_fan_speed` with the interpolated value
|
|
||||||
4. Runs at a configurable interval (e.g. every 2-5 seconds)
|
|
||||||
|
|
||||||
**Two approaches for the feedback loop:**
|
|
||||||
|
|
||||||
**A) Server-side poller (Recommended)** — Add a new asyncio task in `server.py` lifespan, similar to `_monitor_poller`. When a fan curve is active, the poller reads temp, interpolates, and sets fan speed each cycle.
|
|
||||||
|
|
||||||
**B) Daemon-side poller** — Run the loop in `daemon.py`. More complex, requires IPC coordination.
|
|
||||||
|
|
||||||
I recommend **approach A** for simplicity and consistency with the existing architecture.
|
|
||||||
|
|
||||||
#### 1.2 Modify: `nvcurve/server.py`
|
|
||||||
|
|
||||||
New REST endpoints:
|
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
|
||||||
|--------|------|---------|
|
|
||||||
| `GET` | `/api/fans` | Current fan state: `{fan_pct, fan_mode, min_fan_pct, max_fan_pct, curve}` |
|
|
||||||
| `POST` | `/api/fans` | Set fan curve: `{curve: [{temp_c, fan_pct}]}` — starts/updates the feedback loop |
|
|
||||||
| `POST` | `/api/fans/reset` | Reset to automatic fan control, stops feedback loop |
|
|
||||||
| `POST` | `/api/fans/speed` | One-shot set fan to exact %: `{fan_pct: 50}` |
|
|
||||||
|
|
||||||
New server state:
|
|
||||||
- Per-GPU: `fan_curve: list[dict] | None`, `fan_active: bool`, `fan_poller_task: asyncio.Task | None`
|
|
||||||
- New `_fan_poller(gpu_index)` async task, similar pattern to `_monitor_poller`
|
|
||||||
|
|
||||||
The fan poller reads temp from NVML, interpolates fan % from the stored curve using linear interpolation between nearest points (clamp at min/max), and calls `set_fan_speed`.
|
|
||||||
|
|
||||||
#### 1.3 Modify: `nvcurve/nvapi/types.py`
|
|
||||||
|
|
||||||
Add to `MonitoringSample` (optional — fan_pct already exists):
|
|
||||||
- No change needed; `fan_pct` is already present.
|
|
||||||
|
|
||||||
#### 1.4 Modify: `nvcurve/profiles/native.py`
|
|
||||||
|
|
||||||
Extend `ProfileData`:
|
|
||||||
```python
|
|
||||||
@dataclass
|
|
||||||
class ProfileData:
|
|
||||||
name: str
|
|
||||||
gpu_name: str
|
|
||||||
curve_deltas: Dict[str, int]
|
|
||||||
mem_offset_mhz: Optional[int] = None
|
|
||||||
power_limit_w: Optional[int] = None
|
|
||||||
fan_curve: Optional[List[Dict[str, int]]] = None # NEW: [{temp_c, fan_pct}, ...]
|
|
||||||
```
|
|
||||||
|
|
||||||
Add migration in `load_profile` to handle old profiles without `fan_curve`.
|
|
||||||
|
|
||||||
#### 1.5 Modify: `nvcurve/profiles/apply.py`
|
|
||||||
|
|
||||||
When applying a profile, if `fan_curve` is present, call the new `/api/fans` endpoint (or the HAL function directly) to activate the fan curve.
|
|
||||||
|
|
||||||
#### 1.6 Modify: `nvcurve/server.py` — Profile endpoints
|
|
||||||
|
|
||||||
In the profile save endpoint, include the current active fan curve in the saved profile data.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 2: Frontend — Types & API
|
|
||||||
|
|
||||||
#### 2.1 Modify: `frontend/src/types.ts`
|
|
||||||
|
|
||||||
New types:
|
|
||||||
```typescript
|
|
||||||
export interface FanPoint {
|
|
||||||
temp_c: number;
|
|
||||||
fan_pct: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FanState {
|
|
||||||
fan_pct: number | null;
|
|
||||||
fan_mode: number | null; // 0 = auto, 1 = manual
|
|
||||||
min_fan_pct: number | null;
|
|
||||||
max_fan_pct: number | null;
|
|
||||||
curve: FanPoint[];
|
|
||||||
curve_active: boolean;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Extend `ProfileData`:
|
|
||||||
```typescript
|
|
||||||
export interface ProfileData {
|
|
||||||
// ... existing fields
|
|
||||||
fan_curve: FanPoint[] | null;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2.2 Modify: `frontend/src/api/client.ts`
|
|
||||||
|
|
||||||
New API methods:
|
|
||||||
```typescript
|
|
||||||
fans: (gpuIndex: number) => get<FanState>('/fans', gpuIndex),
|
|
||||||
updateFans: (updates: { curve?: FanPoint[] }, gpuIndex: number) => post('/fans', updates, gpuIndex),
|
|
||||||
resetFans: (gpuIndex: number) => post('/fans/reset', undefined, gpuIndex),
|
|
||||||
setFanSpeed: (fanPct: number, gpuIndex: number) => post('/fans/speed', { fan_pct: fanPct }, gpuIndex),
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 3: Frontend — Components
|
|
||||||
|
|
||||||
#### 3.1 New file: `frontend/src/components/Fans/FanCurveEditor.tsx`
|
|
||||||
|
|
||||||
Main content area for the Fans tab. Similar visual style to `PerformancePanel` but with a curve visualization:
|
|
||||||
|
|
||||||
**Layout:**
|
|
||||||
- SVG chart: X-axis = temperature (°C, range ~20-100), Y-axis = fan speed (%)
|
|
||||||
- Interactive points on the curve that can be dragged vertically (adjust fan %) and horizontally (adjust temp threshold)
|
|
||||||
- Minimum 2 points, maximum ~10 points
|
|
||||||
- Click to add a new point, drag to adjust, double-click or delete button to remove
|
|
||||||
- Visual style matches `CurveEditor` but simpler (no domain toggle, no zoom/pan needed — the range is small)
|
|
||||||
|
|
||||||
**Controls (header bar, same pattern as PerformancePanel):**
|
|
||||||
- "pending" badge when curve has unsaved changes
|
|
||||||
- Apply / Discard / Reset buttons
|
|
||||||
- ConfirmDialog on apply and reset
|
|
||||||
|
|
||||||
**Data flow:**
|
|
||||||
- On mount: `GET /api/fans` to load current state
|
|
||||||
- User edits → local `pending` state
|
|
||||||
- Apply → `POST /api/fans` with new curve
|
|
||||||
- Reset → `POST /api/fans/reset` to restore auto fan control
|
|
||||||
|
|
||||||
Color scheme: Use `orange-400` / `amber-400` for the fan curve line and points (heat-themed), consistent with the existing zinc/pink/cyan palette.
|
|
||||||
|
|
||||||
#### 3.2 New file: `frontend/src/components/Monitor/FanMonitor.tsx`
|
|
||||||
|
|
||||||
Sidebar component matching `LiveMonitor` / `PerformanceMonitor` style:
|
|
||||||
|
|
||||||
```
|
|
||||||
Live Monitor (header)
|
|
||||||
├─ GaugeCard: Fan Speed (current %, sparkline from history)
|
|
||||||
├─ GaugeCard: GPU Temp (current °C, sparkline from history)
|
|
||||||
├─ GaugeCard: Target Fan (interpolated target %, sparkline)
|
|
||||||
└─ GaugeCard: Fan Mode ("Auto" / "Curve Active", no sparkline)
|
|
||||||
```
|
|
||||||
|
|
||||||
Reuses existing `GaugeCard` component. Data comes from the existing `monitor` and `monitorHistory` from `useMonitor()` hook, plus `fanState` from the new fan API.
|
|
||||||
|
|
||||||
No new WebSocket needed — the existing monitor poller already pushes `fan_pct` and `temp_c`. The "Target Fan" gauge can be computed client-side from the active curve + current temp.
|
|
||||||
|
|
||||||
#### 3.3 Modify: `frontend/src/App.tsx`
|
|
||||||
|
|
||||||
Add `fans` to the tab union type and rendering:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const [activeTab, setActiveTab] = useState<'curve' | 'performance' | 'fans'>('curve');
|
|
||||||
```
|
|
||||||
|
|
||||||
Add a third tab button between the existing buttons:
|
|
||||||
```tsx
|
|
||||||
<button onClick={() => setActiveTab('fans')}
|
|
||||||
className={`... ${activeTab === 'fans' ? 'border-pink-500 text-zinc-100' : '...'}`}>
|
|
||||||
Fans
|
|
||||||
</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
Add the fans tab content rendering:
|
|
||||||
```tsx
|
|
||||||
{activeTab === 'fans' && (
|
|
||||||
<div className="flex gap-4 items-start w-full">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<FanCurveEditor />
|
|
||||||
</div>
|
|
||||||
<div className="w-80 shrink-0 flex flex-col">
|
|
||||||
<FanMonitor monitor={monitor} history={monitorHistory} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
Import the new components.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase 4: Integration & Polish
|
|
||||||
|
|
||||||
#### 4.1 Profile Integration
|
|
||||||
- When saving a profile, include the active fan curve
|
|
||||||
- When applying a profile with a fan curve, activate it
|
|
||||||
- In `ProfilePanel`, display a small indicator if a profile contains a fan curve
|
|
||||||
|
|
||||||
#### 4.2 Safety Considerations
|
|
||||||
- Validate fan % values: clamp to 0-100
|
|
||||||
- Validate temp values: reasonable range (0-120°C)
|
|
||||||
- Ensure curve points are sorted by temp_c
|
|
||||||
- Warn user before resetting to auto (fan control was manual)
|
|
||||||
- On server disconnect, log a warning that fan curve control is lost
|
|
||||||
|
|
||||||
#### 4.3 Edge Cases
|
|
||||||
- GPU with no controllable fan (e.g., SFF passively cooled) — `nvmlDeviceSetFanSpeed` returns error; show "Fan control not available" message
|
|
||||||
- Multiple GPUs — each GPU has its own fan curve state
|
|
||||||
- Driver doesn't support `nvmlDeviceSetFanSpeed` — graceful degradation, show read-only fan info
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Summary
|
|
||||||
|
|
||||||
### New Files
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `nvcurve/hal/fans.py` | NVML fan control HAL (read, set, reset, fan info) |
|
|
||||||
| `frontend/src/components/Fans/FanCurveEditor.tsx` | Fan curve editor with SVG chart |
|
|
||||||
| `frontend/src/components/Monitor/FanMonitor.tsx` | Fan Live Monitor sidebar |
|
|
||||||
|
|
||||||
### Modified Files
|
|
||||||
| File | Changes |
|
|
||||||
|------|---------|
|
|
||||||
| `nvcurve/server.py` | New `/api/fans` endpoints, fan poller task, per-GPU fan state, profile save/apply includes fan curve |
|
|
||||||
| `nvcurve/nvapi/types.py` | No change (fan_pct already exists) |
|
|
||||||
| `nvcurve/profiles/native.py` | `ProfileData` + `fan_curve` field, migration in `load_profile` |
|
|
||||||
| `nvcurve/profiles/apply.py` | Apply fan curve when loading profile |
|
|
||||||
| `frontend/src/types.ts` | `FanPoint`, `FanState` types; extend `ProfileData` |
|
|
||||||
| `frontend/src/api/client.ts` | `fans`, `updateFans`, `resetFans`, `setFanSpeed` methods |
|
|
||||||
| `frontend/src/App.tsx` | Third tab button + fan tab content rendering |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Order
|
|
||||||
|
|
||||||
1. **Backend HAL** — `hal/fans.py` (read fan, set fan, get fan info)
|
|
||||||
2. **Backend Server** — `/api/fans` endpoints + fan poller in `server.py`
|
|
||||||
3. **Backend Profiles** — extend `ProfileData`, save/apply integration
|
|
||||||
4. **Frontend Types & API** — `types.ts`, `client.ts`
|
|
||||||
5. **Frontend FanMonitor** — sidebar component (reuses existing data)
|
|
||||||
6. **Frontend FanCurveEditor** — main chart component
|
|
||||||
7. **Frontend App.tsx** — wire up the tab
|
|
||||||
8. **Testing** — manual verification of fan control, profile save/apply, reset
|
|
||||||
+18
-18
@@ -1,24 +1,24 @@
|
|||||||
import { useGpu } from "./hooks/useGpu";
|
import { useGpu } from "./hooks/useGpu.js";
|
||||||
import { useCurve } from "./hooks/useCurve";
|
import { useCurve } from "./hooks/useCurve.js";
|
||||||
import { useMonitor } from "./hooks/useMonitor";
|
import { useMonitor } from "./hooks/useMonitor.js";
|
||||||
import { useDashboard } from "./hooks/useDashboard";
|
import { useDashboard } from "./hooks/useDashboard.js";
|
||||||
import { StatusBar } from "./components/Monitor/StatusBar";
|
import { StatusBar } from "./components/Monitor/StatusBar.js";
|
||||||
import { LiveMonitor } from "./components/Monitor/LiveMonitor";
|
import { LiveMonitor } from "./components/Monitor/LiveMonitor.js";
|
||||||
import { Dashboard } from "./components/Dashboard/Dashboard";
|
import { Dashboard } from "./components/Dashboard/Dashboard.js";
|
||||||
import { CurveEditor } from "./components/CurveEditor/CurveEditor";
|
import { CurveEditor } from "./components/CurveEditor/CurveEditor.js";
|
||||||
import { PointTable } from "./components/PointTable/PointTable";
|
import { PointTable } from "./components/PointTable/PointTable.js";
|
||||||
import { PerformancePanel } from "./components/Limits/PerformancePanel";
|
import { PerformancePanel } from "./components/Limits/PerformancePanel.js";
|
||||||
import { PerformanceMonitor } from "./components/Monitor/PerformanceMonitor";
|
import { PerformanceMonitor } from "./components/Monitor/PerformanceMonitor.js";
|
||||||
import { FanMonitor } from "./components/Monitor/FanMonitor";
|
import { FanMonitor } from "./components/Monitor/FanMonitor.js";
|
||||||
import { FanCurveEditor } from "./components/Fans/FanCurveEditor";
|
import { FanCurveEditor } from "./components/Fans/FanCurveEditor.js";
|
||||||
import { ProfilePanel } from "./components/Profiles/ProfilePanel";
|
import { ProfilePanel } from "./components/Profiles/ProfilePanel.js";
|
||||||
import { api, onUnauthorized } from "./api/client";
|
import { api, onUnauthorized } from "./api/client.js";
|
||||||
import { LoginScreen } from "./components/Auth/LoginScreen";
|
import { LoginScreen } from "./components/Auth/LoginScreen.js";
|
||||||
import { useCurveStore } from "./store/curveStore";
|
import { useCurveStore } from "./store/curveStore.js";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { Loader, ChevronDown } from "lucide-react";
|
import { Loader, ChevronDown } from "lucide-react";
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import type { FanState } from "./types";
|
import type { FanState } from "./types.js";
|
||||||
|
|
||||||
type AuthState = "checking" | "login" | "ok";
|
type AuthState = "checking" | "login" | "ok";
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
FanState,
|
FanState,
|
||||||
FanPoint,
|
FanPoint,
|
||||||
DashboardInfo,
|
DashboardInfo,
|
||||||
} from "../types";
|
} from "../types.js";
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
@@ -168,9 +168,9 @@ export const api = {
|
|||||||
|
|
||||||
/** 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, fans?: number[] | null) =>
|
||||||
post("/fans", { curve }, gpuIndex),
|
post("/fans", { curve, fans: fans ?? null }, 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, fan?: number | null) =>
|
||||||
post("/fans/speed", { fan_pct: fanPct }, gpuIndex),
|
post("/fans/speed", { fan_pct: fanPct, fan: fan ?? null }, gpuIndex),
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Loader, Lock, User } from "lucide-react";
|
import { Loader, Lock, User } from "lucide-react";
|
||||||
import { api, ApiError } from "../../api/client";
|
import { api, ApiError } from "../../api/client.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSuccess: (username: string) => void;
|
onSuccess: (username: string) => void;
|
||||||
@@ -12,7 +12,7 @@ export function LoginScreen({ onSuccess }: Props) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
async function submit(e: React.FormEvent) {
|
async function submit(e: React.SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react';
|
import { useState, useMemo } from "react";
|
||||||
import { ZoomIn, RotateCcw, Minus } from 'lucide-react';
|
import { ZoomIn, RotateCcw, Minus } from "lucide-react";
|
||||||
import { useCurveStore } from '../../store/curveStore';
|
import { useCurveStore } from "../../store/curveStore.js";
|
||||||
import type { VFPoint } from '../../types';
|
import type { VFPoint } from "../../types.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** All curve points — used by global offset slider */
|
/** All curve points — used by global offset slider */
|
||||||
@@ -18,23 +18,44 @@ interface Props {
|
|||||||
onZoomChange: (factor: number) => void;
|
onZoomChange: (factor: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomFactor, onZoomChange }: Props) {
|
export function CurveToolbar({
|
||||||
const { pendingDeltas, selectedPoints, anchorPoint, curve, stageRangeEdit, flattenToAnchor } = useCurveStore();
|
activePts,
|
||||||
|
onResetZoom,
|
||||||
|
isZoomed,
|
||||||
|
readOnly,
|
||||||
|
zoomFactor,
|
||||||
|
onZoomChange,
|
||||||
|
}: Props) {
|
||||||
|
const {
|
||||||
|
pendingDeltas,
|
||||||
|
selectedPoints,
|
||||||
|
anchorPoint,
|
||||||
|
curve,
|
||||||
|
stageRangeEdit,
|
||||||
|
flattenToAnchor,
|
||||||
|
} = useCurveStore();
|
||||||
|
|
||||||
const [offsetMhz, setOffsetMhz] = useState(0);
|
const [offsetMhz, setOffsetMhz] = useState(0);
|
||||||
|
|
||||||
const uniformDeltaMhz = useMemo(() => {
|
const uniformDeltaMhz = useMemo(() => {
|
||||||
if (activePts.length === 0) return 0;
|
if (activePts.length === 0) return 0;
|
||||||
const firstD = pendingDeltas.get(activePts[0].index) ?? activePts[0].delta_khz;
|
const firstD =
|
||||||
const uniform = activePts.every((p) => (pendingDeltas.get(p.index) ?? p.delta_khz) === firstD);
|
pendingDeltas.get(activePts[0].index) ?? activePts[0].delta_khz;
|
||||||
|
const uniform = activePts.every(
|
||||||
|
(p) => (pendingDeltas.get(p.index) ?? p.delta_khz) === firstD,
|
||||||
|
);
|
||||||
return uniform ? firstD / 1000 : null;
|
return uniform ? firstD / 1000 : null;
|
||||||
}, [activePts, pendingDeltas]);
|
}, [activePts, pendingDeltas]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Sync the slider to the uniform delta when it changes (adjust state during
|
||||||
if (uniformDeltaMhz !== null) {
|
// render instead of an effect; undefined sentinel so the first render syncs).
|
||||||
|
const [lastUniformDelta, setLastUniformDelta] = useState<
|
||||||
|
number | null | undefined
|
||||||
|
>();
|
||||||
|
if (uniformDeltaMhz !== null && lastUniformDelta !== uniformDeltaMhz) {
|
||||||
|
setLastUniformDelta(uniformDeltaMhz);
|
||||||
setOffsetMhz(uniformDeltaMhz);
|
setOffsetMhz(uniformDeltaMhz);
|
||||||
}
|
}
|
||||||
}, [uniformDeltaMhz]);
|
|
||||||
|
|
||||||
function handleOffsetChange(mhz: number) {
|
function handleOffsetChange(mhz: number) {
|
||||||
setOffsetMhz(mhz);
|
setOffsetMhz(mhz);
|
||||||
@@ -44,7 +65,10 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2 px-1 pb-2">
|
<div className="flex flex-wrap items-center gap-2 px-1 pb-2">
|
||||||
{/* Zoom control */}
|
{/* Zoom control */}
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800/60 border border-zinc-700/40" title="Zoom x-axis (Alt+scroll also works)">
|
<div
|
||||||
|
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800/60 border border-zinc-700/40"
|
||||||
|
title="Zoom x-axis (Alt+scroll also works)"
|
||||||
|
>
|
||||||
<ZoomIn size={11} className="text-zinc-500 shrink-0" />
|
<ZoomIn size={11} className="text-zinc-500 shrink-0" />
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -55,7 +79,9 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
|
|||||||
onChange={(e) => onZoomChange(Number(e.target.value))}
|
onChange={(e) => onZoomChange(Number(e.target.value))}
|
||||||
className="w-20 h-1 cursor-pointer accent-cyan-400"
|
className="w-20 h-1 cursor-pointer accent-cyan-400"
|
||||||
/>
|
/>
|
||||||
<span className={`text-xs font-mono w-8 tabular-nums ${isZoomed ? 'text-cyan-400' : 'text-zinc-600'}`}>
|
<span
|
||||||
|
className={`text-xs font-mono w-8 tabular-nums ${isZoomed ? "text-cyan-400" : "text-zinc-600"}`}
|
||||||
|
>
|
||||||
{zoomFactor.toFixed(1)}×
|
{zoomFactor.toFixed(1)}×
|
||||||
</span>
|
</span>
|
||||||
{isZoomed && (
|
{isZoomed && (
|
||||||
@@ -75,7 +101,9 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
|
|||||||
{/* Global offset slider — GPU only */}
|
{/* Global offset slider — GPU only */}
|
||||||
{!readOnly && uniformDeltaMhz !== null && (
|
{!readOnly && uniformDeltaMhz !== null && (
|
||||||
<div className="flex items-center gap-1.5 min-w-[260px]">
|
<div className="flex items-center gap-1.5 min-w-[260px]">
|
||||||
<span className="text-zinc-500 text-xs whitespace-nowrap">Global Offset</span>
|
<span className="text-zinc-500 text-xs whitespace-nowrap">
|
||||||
|
Global Offset
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={-1000}
|
min={-1000}
|
||||||
@@ -84,34 +112,42 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
|
|||||||
value={offsetMhz}
|
value={offsetMhz}
|
||||||
onChange={(e) => handleOffsetChange(Number(e.target.value))}
|
onChange={(e) => handleOffsetChange(Number(e.target.value))}
|
||||||
className="w-32 accent-cyan-400"
|
className="w-32 accent-cyan-400"
|
||||||
title={`${offsetMhz > 0 ? '+' : ''}${offsetMhz} MHz`}
|
title={`${offsetMhz > 0 ? "+" : ""}${offsetMhz} MHz`}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
'text-xs font-mono w-16',
|
"text-xs font-mono w-16",
|
||||||
offsetMhz > 0 ? 'text-cyan-400' : offsetMhz < 0 ? 'text-orange-400' : 'text-zinc-500',
|
offsetMhz > 0
|
||||||
].join(' ')}
|
? "text-cyan-400"
|
||||||
|
: offsetMhz < 0
|
||||||
|
? "text-orange-400"
|
||||||
|
: "text-zinc-500",
|
||||||
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{offsetMhz > 0 ? '+' : ''}{offsetMhz} MHz
|
{offsetMhz > 0 ? "+" : ""}
|
||||||
|
{offsetMhz} MHz
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Flatten — visible when 2+ points are selected */}
|
{/* Flatten — visible when 2+ points are selected */}
|
||||||
{!readOnly && selectedPoints.size >= 2 && (() => {
|
{!readOnly &&
|
||||||
const anchor = anchorPoint !== null && selectedPoints.has(anchorPoint)
|
selectedPoints.size >= 2 &&
|
||||||
|
(() => {
|
||||||
|
const anchor =
|
||||||
|
anchorPoint !== null && selectedPoints.has(anchorPoint)
|
||||||
? anchorPoint
|
? anchorPoint
|
||||||
: Math.min(...selectedPoints);
|
: Math.min(...selectedPoints);
|
||||||
const anchorDelta =
|
const anchorDelta =
|
||||||
pendingDeltas.get(anchor) ??
|
pendingDeltas.get(anchor) ??
|
||||||
curve?.points.find(p => p.index === anchor)?.delta_khz ??
|
curve?.points.find((p) => p.index === anchor)?.delta_khz ??
|
||||||
0;
|
0;
|
||||||
const label = `·${anchor} ${anchorDelta >= 0 ? '+' : ''}${anchorDelta / 1000} MHz`;
|
const label = `·${anchor} ${anchorDelta >= 0 ? "+" : ""}${anchorDelta / 1000} MHz`;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={flattenToAnchor}
|
onClick={flattenToAnchor}
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium text-amber-400 hover:text-amber-300 hover:bg-zinc-800 border border-zinc-700/40 transition"
|
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium text-amber-400 hover:text-amber-300 hover:bg-zinc-800 border border-zinc-700/40 transition"
|
||||||
title={`Flatten all selected points to anchor point ${anchor} (${anchorDelta >= 0 ? '+' : ''}${anchorDelta / 1000} MHz)`}
|
title={`Flatten all selected points to anchor point ${anchor} (${anchorDelta >= 0 ? "+" : ""}${anchorDelta / 1000} MHz)`}
|
||||||
>
|
>
|
||||||
<Minus size={11} />
|
<Minus size={11} />
|
||||||
Flatten to {label}
|
Flatten to {label}
|
||||||
@@ -121,9 +157,18 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
|
|||||||
|
|
||||||
{/* Legend — right-aligned */}
|
{/* Legend — right-aligned */}
|
||||||
<div className="flex items-center gap-3 text-xs text-zinc-500 ml-auto">
|
<div className="flex items-center gap-3 text-xs text-zinc-500 ml-auto">
|
||||||
<span className="flex items-center gap-1"><span className="inline-block w-3 h-0.5 bg-emerald-400 rounded" /> effective</span>
|
<span className="flex items-center gap-1">
|
||||||
<span className="flex items-center gap-1"><span className="inline-block w-3 h-px border-t-2 border-dashed border-cyan-400" /> pending</span>
|
<span className="inline-block w-3 h-0.5 bg-emerald-400 rounded" />{" "}
|
||||||
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-yellow-400" /> current</span>
|
effective
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block w-3 h-px border-t-2 border-dashed border-cyan-400" />{" "}
|
||||||
|
pending
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="inline-block w-2 h-2 rounded-full bg-yellow-400" />{" "}
|
||||||
|
current
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { fmt } from '../../utils/units';
|
import { fmt } from '../../utils/units.js';
|
||||||
import type { VFPoint } from '../../types';
|
import type { VFPoint } from '../../types.js';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
point: VFPoint;
|
point: VFPoint;
|
||||||
@@ -21,15 +21,7 @@ export function CurveTooltip({ point, pendingDeltaKhz, isClamped }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
className="absolute right-4 bottom-4 pointer-events-none z-50 w-[172px] bg-zinc-800 border border-zinc-700 rounded-md p-2 text-xs shadow-xl"
|
||||||
position: 'absolute',
|
|
||||||
right: 16,
|
|
||||||
bottom: 16,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
zIndex: 50,
|
|
||||||
width: 172,
|
|
||||||
}}
|
|
||||||
className="bg-zinc-800 border border-zinc-700 rounded-md p-2 text-xs shadow-xl"
|
|
||||||
>
|
>
|
||||||
<div className="text-zinc-400 mb-1">Point {point.index}</div>
|
<div className="text-zinc-400 mb-1">Point {point.index}</div>
|
||||||
<div className="text-zinc-200">
|
<div className="text-zinc-200">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Loader } from "lucide-react";
|
import { Loader } from "lucide-react";
|
||||||
import { GaugeCard } from "../Monitor/GaugeCard";
|
import { GaugeCard } from "../Monitor/GaugeCard.js";
|
||||||
import { fmt } from "../../utils/units";
|
import { fmt } from "../../utils/units.js";
|
||||||
import type { MonitoringSample, DashboardInfo } from "../../types";
|
import type { MonitoringSample, DashboardInfo } from "../../types.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
monitor: MonitoringSample | null;
|
monitor: MonitoringSample | null;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { Check, X, RotateCcw, Plus } from 'lucide-react';
|
import { Check, X, RotateCcw, Plus } from "lucide-react";
|
||||||
import { api } from '../../api/client';
|
import { api } from "../../api/client.js";
|
||||||
import { useCurveStore } from '../../store/curveStore';
|
import { useCurveStore } from "../../store/curveStore.js";
|
||||||
import type { FanPoint, FanState } from '../../types';
|
import type { FanInfo, FanPoint, FanState } from "../../types.js";
|
||||||
import { toast } from 'sonner';
|
import { toast } from "sonner";
|
||||||
import { ConfirmDialog } from '../common/ConfirmDialog';
|
import { ConfirmDialog } from "../common/ConfirmDialog.js";
|
||||||
|
|
||||||
function defaultCurve(): FanPoint[] {
|
function defaultCurve(): FanPoint[] {
|
||||||
return [
|
return [
|
||||||
@@ -35,13 +35,38 @@ function fanToY(f: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function xToTemp(x: number) {
|
function xToTemp(x: number) {
|
||||||
return Math.round(TEMP_MIN + ((x - PAD.left) / PLOT_W) * (TEMP_MAX - TEMP_MIN));
|
return Math.round(
|
||||||
|
TEMP_MIN + ((x - PAD.left) / PLOT_W) * (TEMP_MAX - TEMP_MIN),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function yToFan(y: number) {
|
function yToFan(y: number) {
|
||||||
return Math.round(FAN_MAX - ((y - PAD.top) / PLOT_H) * (FAN_MAX - FAN_MIN));
|
return Math.round(FAN_MAX - ((y - PAD.top) / PLOT_H) * (FAN_MAX - FAN_MIN));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sameTargets(
|
||||||
|
a: number[] | null | undefined,
|
||||||
|
b: number[] | null | undefined,
|
||||||
|
) {
|
||||||
|
if (a === null || a === undefined) return b === null || b === undefined;
|
||||||
|
if (b === null || b === undefined) return false;
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return a.every((v, i) => v === b[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fanLabel(targets: number[] | null): string {
|
||||||
|
return targets === null
|
||||||
|
? "all fans"
|
||||||
|
: targets.map((i) => `Fan ${i + 1}`).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const chipCls = (selected: boolean) =>
|
||||||
|
`px-2 py-0.5 rounded-full border text-xs font-medium transition-colors ${
|
||||||
|
selected
|
||||||
|
? "bg-cyan-500/15 border-cyan-500/40 text-cyan-300"
|
||||||
|
: "bg-zinc-800 border-zinc-700 text-zinc-500 hover:text-zinc-300"
|
||||||
|
}`;
|
||||||
|
|
||||||
export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
||||||
const { selectedGpuIndex } = useCurveStore();
|
const { selectedGpuIndex } = useCurveStore();
|
||||||
const [fanState, setFanState] = useState<FanState | null>(null);
|
const [fanState, setFanState] = useState<FanState | null>(null);
|
||||||
@@ -52,6 +77,9 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
const [confirmApply, setConfirmApply] = useState(false);
|
const [confirmApply, setConfirmApply] = useState(false);
|
||||||
const [confirmReset, setConfirmReset] = useState(false);
|
const [confirmReset, setConfirmReset] = useState(false);
|
||||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||||
|
// Fan selection: undefined = no pending change (follow server state),
|
||||||
|
// null = all fans, list = specific fan indices.
|
||||||
|
const [fanSel, setFanSel] = useState<number[] | null | undefined>(undefined);
|
||||||
const svgRef = useRef<SVGSVGElement>(null);
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
|
|
||||||
async function fetchFans() {
|
async function fetchFans() {
|
||||||
@@ -59,37 +87,70 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await api.fans(selectedGpuIndex);
|
const data = await api.fans(selectedGpuIndex);
|
||||||
setFanState(data);
|
setFanState(data);
|
||||||
|
setFanSel(undefined);
|
||||||
if (data.curve && data.curve.length > 0) {
|
if (data.curve && data.curve.length > 0) {
|
||||||
setPending(null);
|
setPending(null);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to load fan state');
|
toast.error("Failed to load fan state");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { fetchFans(); }, [selectedGpuIndex]);
|
// Data fetch on GPU change — setState calls happen after the await, not
|
||||||
|
// synchronously in the effect body (rule false-positive on async fetch).
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
fetchFans();
|
||||||
|
}, [selectedGpuIndex]);
|
||||||
|
|
||||||
const activeCurve = pending ?? fanState?.curve ?? defaultCurve();
|
const activeCurve = pending ?? fanState?.curve ?? defaultCurve();
|
||||||
const hasPending = pending !== null;
|
const hasPending = pending !== null;
|
||||||
const curveActive = fanState?.curve_active ?? false;
|
const curveActive = fanState?.curve_active ?? false;
|
||||||
const isDefaults = !pending && !fanState?.curve;
|
const isDefaults = !pending && !fanState?.curve;
|
||||||
|
|
||||||
|
const fanTargets =
|
||||||
|
fanSel === undefined ? (fanState?.fan_targets ?? null) : fanSel;
|
||||||
|
const fansChanged =
|
||||||
|
fanSel !== undefined && !sameTargets(fanSel, fanState?.fan_targets ?? null);
|
||||||
|
const fanList: FanInfo[] =
|
||||||
|
fanState?.fans && fanState.fans.length > 0
|
||||||
|
? fanState.fans
|
||||||
|
: [{ index: 0, fan_pct: null }];
|
||||||
|
|
||||||
|
function toggleFan(idx: number) {
|
||||||
|
const numFans = fanState?.num_fans ?? 1;
|
||||||
|
let list: number[];
|
||||||
|
if (fanTargets === null) {
|
||||||
|
// Start from all fans, then drop the toggled one
|
||||||
|
list = Array.from({ length: numFans }, (_, i) => i).filter(
|
||||||
|
(i) => i !== idx,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
list = fanTargets.includes(idx)
|
||||||
|
? fanTargets.filter((i) => i !== idx)
|
||||||
|
: [...fanTargets, idx].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
if (list.length === 0) return; // keep at least one fan selected
|
||||||
|
setFanSel(list.length === numFans ? null : list);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleApply() {
|
async function handleApply() {
|
||||||
const curveToApply = pending ?? fanState?.curve ?? defaultCurve();
|
const curveToApply = pending ?? fanState?.curve ?? defaultCurve();
|
||||||
if (curveToApply.length < 2) return;
|
if (curveToApply.length < 2) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await api.updateFans(curveToApply, selectedGpuIndex);
|
await api.updateFans(curveToApply, selectedGpuIndex, fanTargets);
|
||||||
setPending(null);
|
setPending(null);
|
||||||
|
setFanSel(undefined);
|
||||||
setConfirmApply(false);
|
setConfirmApply(false);
|
||||||
await fetchFans();
|
await fetchFans();
|
||||||
onChanged?.();
|
onChanged?.();
|
||||||
toast.success('Fan curve applied');
|
toast.success("Fan curve applied");
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
setError(e.message ?? String(e));
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
setConfirmApply(false);
|
setConfirmApply(false);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -102,12 +163,13 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
try {
|
try {
|
||||||
await api.resetFans(selectedGpuIndex);
|
await api.resetFans(selectedGpuIndex);
|
||||||
setPending(null);
|
setPending(null);
|
||||||
|
setFanSel(undefined);
|
||||||
setConfirmReset(false);
|
setConfirmReset(false);
|
||||||
await fetchFans();
|
await fetchFans();
|
||||||
onChanged?.();
|
onChanged?.();
|
||||||
toast.success('Fan control reset to automatic');
|
toast.success("Fan control reset to automatic");
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
setError(e.message ?? String(e));
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
setConfirmReset(false);
|
setConfirmReset(false);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -128,10 +190,12 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
const fan = Math.max(FAN_MIN, Math.min(FAN_MAX, yToFan(y)));
|
const fan = Math.max(FAN_MIN, Math.min(FAN_MAX, yToFan(y)));
|
||||||
|
|
||||||
const baseCurve = pending ?? fanState?.curve ?? defaultCurve();
|
const baseCurve = pending ?? fanState?.curve ?? defaultCurve();
|
||||||
const existing = baseCurve.findIndex(p => p.temp_c === temp);
|
const existing = baseCurve.findIndex((p) => p.temp_c === temp);
|
||||||
if (existing >= 0) return;
|
if (existing >= 0) return;
|
||||||
|
|
||||||
const updated = [...baseCurve, { temp_c: temp, fan_pct: fan }].sort((a, b) => a.temp_c - b.temp_c);
|
const updated = [...baseCurve, { temp_c: temp, fan_pct: fan }].sort(
|
||||||
|
(a, b) => a.temp_c - b.temp_c,
|
||||||
|
);
|
||||||
setPending(updated);
|
setPending(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +210,8 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
setDragIdx(idx);
|
setDragIdx(idx);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePointerMove = useCallback((e: React.PointerEvent<SVGSVGElement>) => {
|
const handlePointerMove = useCallback(
|
||||||
|
(e: React.PointerEvent<SVGSVGElement>) => {
|
||||||
if (dragIdx === null) return;
|
if (dragIdx === null) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const svg = svgRef.current;
|
const svg = svgRef.current;
|
||||||
@@ -164,22 +229,27 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
const updated = [...curve];
|
const updated = [...curve];
|
||||||
updated[dragIdx] = { temp_c: temp, fan_pct: fan };
|
updated[dragIdx] = { temp_c: temp, fan_pct: fan };
|
||||||
setPending(updated);
|
setPending(updated);
|
||||||
}, [dragIdx, pending, fanState]);
|
},
|
||||||
|
[dragIdx, pending, fanState],
|
||||||
|
);
|
||||||
|
|
||||||
const handlePointerUp = useCallback(() => {
|
const handlePointerUp = useCallback(() => {
|
||||||
setDragIdx(null);
|
setDragIdx(null);
|
||||||
setPending((p) => p ? [...p].sort((a, b) => a.temp_c - b.temp_c) : p);
|
setPending((p) => (p ? [...p].sort((a, b) => a.temp_c - b.temp_c) : p));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dragIdx === null) return;
|
if (dragIdx === null) return;
|
||||||
window.addEventListener('pointerup', handlePointerUp);
|
window.addEventListener("pointerup", handlePointerUp);
|
||||||
return () => window.removeEventListener('pointerup', handlePointerUp);
|
return () => window.removeEventListener("pointerup", handlePointerUp);
|
||||||
}, [dragIdx, handlePointerUp]);
|
}, [dragIdx, handlePointerUp]);
|
||||||
|
|
||||||
// Build polyline path from curve points
|
// Build polyline path from curve points
|
||||||
const curvePath = activeCurve && activeCurve.length >= 2
|
const curvePath =
|
||||||
? activeCurve.map((p) => `${tempToX(p.temp_c)},${fanToY(p.fan_pct)}`).join(' ')
|
activeCurve && activeCurve.length >= 2
|
||||||
|
? activeCurve
|
||||||
|
.map((p) => `${tempToX(p.temp_c)},${fanToY(p.fan_pct)}`)
|
||||||
|
.join(" ")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Grid lines
|
// Grid lines
|
||||||
@@ -202,7 +272,9 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
|
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 shrink-0">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 shrink-0">
|
||||||
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Fan Curve</span>
|
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
|
||||||
|
Fan Curve
|
||||||
|
</span>
|
||||||
|
|
||||||
{isDefaults && (
|
{isDefaults && (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-orange-500/15 border border-orange-500/30 text-orange-400 text-xs">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-orange-500/15 border border-orange-500/30 text-orange-400 text-xs">
|
||||||
@@ -232,8 +304,12 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
Apply
|
Apply
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setPending(null); setError(null); }}
|
onClick={() => {
|
||||||
disabled={!hasPending || busy}
|
setPending(null);
|
||||||
|
setFanSel(undefined);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
disabled={(!hasPending && !fansChanged) || busy}
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<X size={12} />
|
<X size={12} />
|
||||||
@@ -250,11 +326,58 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Fan selection */}
|
||||||
|
<div className="px-4 py-2 border-b border-zinc-800/60 flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
|
||||||
|
Fans
|
||||||
|
</span>
|
||||||
|
{fanState?.num_fans === 0 ? (
|
||||||
|
<span className="text-xs text-zinc-600">
|
||||||
|
No fans available on this GPU
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setFanSel(null)}
|
||||||
|
className={chipCls(fanTargets === null)}
|
||||||
|
title="Control all fans"
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{fanList.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.index}
|
||||||
|
onClick={() => toggleFan(f.index)}
|
||||||
|
className={chipCls(
|
||||||
|
fanTargets === null || fanTargets.includes(f.index),
|
||||||
|
)}
|
||||||
|
title={`Control Fan ${f.index + 1} with the curve`}
|
||||||
|
>
|
||||||
|
Fan {f.index + 1}
|
||||||
|
<span className="ml-1.5 font-mono text-[10px] opacity-80">
|
||||||
|
{f.fan_pct !== null ? `${Math.round(f.fan_pct)}%` : "—"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{fansChanged && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs">
|
||||||
|
Fans: {fanLabel(fanTargets)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Error banner */}
|
{/* Error banner */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="px-3 py-1.5 bg-red-900/40 border-b border-red-700 text-red-300 text-xs flex items-center justify-between">
|
<div className="px-3 py-1.5 bg-red-900/40 border-b border-red-700 text-red-300 text-xs flex items-center justify-between">
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
<button onClick={() => setError(null)} className="ml-2 text-red-400 hover:text-red-200">x</button>
|
<button
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
className="ml-2 text-red-400 hover:text-red-200"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -263,7 +386,8 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
{isDefaults && (
|
{isDefaults && (
|
||||||
<div className="absolute inset-x-4 top-4 z-10 pointer-events-none text-center">
|
<div className="absolute inset-x-4 top-4 z-10 pointer-events-none text-center">
|
||||||
<span className="inline-block bg-zinc-900/90 backdrop-blur-sm border border-orange-500/30 rounded-lg px-4 py-2 text-xs text-orange-400/90 font-medium">
|
<span className="inline-block bg-zinc-900/90 backdrop-blur-sm border border-orange-500/30 rounded-lg px-4 py-2 text-xs text-orange-400/90 font-medium">
|
||||||
Default curve — edit points or apply as-is to enable curve control
|
Default curve — edit points or apply as-is to enable curve
|
||||||
|
control
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -271,52 +395,95 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
ref={svgRef}
|
ref={svgRef}
|
||||||
width="100%"
|
width="100%"
|
||||||
viewBox={`0 0 ${CHART_W} ${CHART_H}`}
|
viewBox={`0 0 ${CHART_W} ${CHART_H}`}
|
||||||
className="max-w-full cursor-crosshair select-none"
|
className="max-w-full cursor-crosshair select-none touch-none"
|
||||||
style={{ touchAction: 'none' }}
|
|
||||||
onClick={handleCanvasClick}
|
onClick={handleCanvasClick}
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
>
|
>
|
||||||
{/* Background */}
|
{/* Background */}
|
||||||
<rect x={PAD.left} y={PAD.top} width={PLOT_W} height={PLOT_H} fill="#09090b" rx="4" />
|
<rect
|
||||||
|
x={PAD.left}
|
||||||
|
y={PAD.top}
|
||||||
|
width={PLOT_W}
|
||||||
|
height={PLOT_H}
|
||||||
|
fill="#09090b"
|
||||||
|
rx="4"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Grid lines - horizontal (fan %) */}
|
{/* Grid lines - horizontal (fan %) */}
|
||||||
{fanTicks.map(f => (
|
{fanTicks.map((f) => (
|
||||||
<g key={`fy-${f}`}>
|
<g key={`fy-${f}`}>
|
||||||
<line
|
<line
|
||||||
x1={PAD.left} y1={fanToY(f)}
|
x1={PAD.left}
|
||||||
x2={PAD.left + PLOT_W} y2={fanToY(f)}
|
y1={fanToY(f)}
|
||||||
stroke="#27272a" strokeWidth="0.5"
|
x2={PAD.left + PLOT_W}
|
||||||
|
y2={fanToY(f)}
|
||||||
|
stroke="#27272a"
|
||||||
|
strokeWidth="0.5"
|
||||||
/>
|
/>
|
||||||
<text x={PAD.left - 6} y={fanToY(f) + 3} textAnchor="end" fill="#71717a" fontSize="9" fontFamily="monospace">
|
<text
|
||||||
|
x={PAD.left - 6}
|
||||||
|
y={fanToY(f) + 3}
|
||||||
|
textAnchor="end"
|
||||||
|
fill="#71717a"
|
||||||
|
fontSize="9"
|
||||||
|
fontFamily="monospace"
|
||||||
|
>
|
||||||
{f}%
|
{f}%
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Grid lines - vertical (temp) */}
|
{/* Grid lines - vertical (temp) */}
|
||||||
{tempTicks.map(t => (
|
{tempTicks.map((t) => (
|
||||||
<g key={`tx-${t}`}>
|
<g key={`tx-${t}`}>
|
||||||
<line
|
<line
|
||||||
x1={tempToX(t)} y1={PAD.top}
|
x1={tempToX(t)}
|
||||||
x2={tempToX(t)} y2={PAD.top + PLOT_H}
|
y1={PAD.top}
|
||||||
stroke="#27272a" strokeWidth="0.5"
|
x2={tempToX(t)}
|
||||||
|
y2={PAD.top + PLOT_H}
|
||||||
|
stroke="#27272a"
|
||||||
|
strokeWidth="0.5"
|
||||||
/>
|
/>
|
||||||
<text x={tempToX(t)} y={PAD.top + PLOT_H + 16} textAnchor="middle" fill="#71717a" fontSize="9" fontFamily="monospace">
|
<text
|
||||||
|
x={tempToX(t)}
|
||||||
|
y={PAD.top + PLOT_H + 16}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#71717a"
|
||||||
|
fontSize="9"
|
||||||
|
fontFamily="monospace"
|
||||||
|
>
|
||||||
{t}°
|
{t}°
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Axis labels */}
|
{/* Axis labels */}
|
||||||
<text x={PAD.left + PLOT_W / 2} y={CHART_H - 2} textAnchor="middle" fill="#52525b" fontSize="9">Temperature (°C)</text>
|
<text
|
||||||
<text x={8} y={PAD.top + PLOT_H / 2} textAnchor="middle" fill="#52525b" fontSize="9" transform={`rotate(-90, 8, ${PAD.top + PLOT_H / 2})`}>Fan Speed (%)</text>
|
x={PAD.left + PLOT_W / 2}
|
||||||
|
y={CHART_H - 2}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#52525b"
|
||||||
|
fontSize="9"
|
||||||
|
>
|
||||||
|
Temperature (°C)
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x={8}
|
||||||
|
y={PAD.top + PLOT_H / 2}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#52525b"
|
||||||
|
fontSize="9"
|
||||||
|
transform={`rotate(-90, 8, ${PAD.top + PLOT_H / 2})`}
|
||||||
|
>
|
||||||
|
Fan Speed (%)
|
||||||
|
</text>
|
||||||
|
|
||||||
{/* Curve line */}
|
{/* Curve line */}
|
||||||
{curvePath && (
|
{curvePath && (
|
||||||
<polyline
|
<polyline
|
||||||
points={curvePath}
|
points={curvePath}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={hasPending ? '#22d3ee' : '#fb923c'}
|
stroke={hasPending ? "#22d3ee" : "#fb923c"}
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
@@ -328,7 +495,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
{curvePath && activeCurve && activeCurve.length >= 2 && (
|
{curvePath && activeCurve && activeCurve.length >= 2 && (
|
||||||
<polygon
|
<polygon
|
||||||
points={`${tempToX(activeCurve[0].temp_c)},${PAD.top + PLOT_H} ${curvePath} ${tempToX(activeCurve[activeCurve.length - 1].temp_c)},${PAD.top + PLOT_H}`}
|
points={`${tempToX(activeCurve[0].temp_c)},${PAD.top + PLOT_H} ${curvePath} ${tempToX(activeCurve[activeCurve.length - 1].temp_c)},${PAD.top + PLOT_H}`}
|
||||||
fill={hasPending ? '#22d3ee' : '#fb923c'}
|
fill={hasPending ? "#22d3ee" : "#fb923c"}
|
||||||
opacity="0.07"
|
opacity="0.07"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -340,40 +507,61 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
cx={tempToX(p.temp_c)}
|
cx={tempToX(p.temp_c)}
|
||||||
cy={fanToY(p.fan_pct)}
|
cy={fanToY(p.fan_pct)}
|
||||||
r="6"
|
r="6"
|
||||||
fill={hasPending ? '#22d3ee' : '#fb923c'}
|
fill={hasPending ? "#22d3ee" : "#fb923c"}
|
||||||
stroke="#09090b"
|
stroke="#09090b"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
className="cursor-grab active:cursor-grabbing"
|
className="cursor-grab active:cursor-grabbing touch-none"
|
||||||
style={{ touchAction: 'none' }}
|
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handlePointerDown(i);
|
handlePointerDown(i);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Delete button on hover */}
|
{/* Remove button: faintly visible, brightens on hover */}
|
||||||
{activeCurve && activeCurve.length > 2 && (
|
{activeCurve && activeCurve.length > 2 && (
|
||||||
<circle
|
<g
|
||||||
cx={tempToX(p.temp_c) + 8}
|
className="cursor-pointer group/remove"
|
||||||
cy={fanToY(p.fan_pct) - 8}
|
|
||||||
r="7"
|
|
||||||
fill="#27272a"
|
|
||||||
stroke="#3f3f46"
|
|
||||||
strokeWidth="1"
|
|
||||||
className="cursor-pointer opacity-0 hover:opacity-100 transition-opacity"
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
removePoint(i);
|
removePoint(i);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<title>Remove point</title>
|
<title>Remove point</title>
|
||||||
</circle>
|
<circle
|
||||||
|
cx={tempToX(p.temp_c) + 9}
|
||||||
|
cy={fanToY(p.fan_pct) - 9}
|
||||||
|
r="7"
|
||||||
|
fill="#27272a"
|
||||||
|
stroke="#3f3f46"
|
||||||
|
strokeWidth="1"
|
||||||
|
className="opacity-50 group-hover/remove:opacity-100 group-hover/remove:stroke-red-400 transition-opacity"
|
||||||
|
/>
|
||||||
|
{/* X glyph */}
|
||||||
|
<line
|
||||||
|
x1={tempToX(p.temp_c) + 6.5}
|
||||||
|
y1={fanToY(p.fan_pct) - 11.5}
|
||||||
|
x2={tempToX(p.temp_c) + 11.5}
|
||||||
|
y2={fanToY(p.fan_pct) - 6.5}
|
||||||
|
stroke="#a1a1aa"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
className="group-hover/remove:stroke-red-400 transition-colors"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1={tempToX(p.temp_c) + 11.5}
|
||||||
|
y1={fanToY(p.fan_pct) - 11.5}
|
||||||
|
x2={tempToX(p.temp_c) + 6.5}
|
||||||
|
y2={fanToY(p.fan_pct) - 6.5}
|
||||||
|
stroke="#a1a1aa"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
className="group-hover/remove:stroke-red-400 transition-colors"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
)}
|
)}
|
||||||
{/* Value label */}
|
{/* Value label */}
|
||||||
<text
|
<text
|
||||||
x={tempToX(p.temp_c)}
|
x={tempToX(p.temp_c)}
|
||||||
y={fanToY(p.fan_pct) - 12}
|
y={fanToY(p.fan_pct) - 12}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
fill={hasPending ? '#22d3ee' : '#fb923c'}
|
fill={hasPending ? "#22d3ee" : "#fb923c"}
|
||||||
fontSize="8"
|
fontSize="8"
|
||||||
fontFamily="monospace"
|
fontFamily="monospace"
|
||||||
fontWeight="600"
|
fontWeight="600"
|
||||||
@@ -386,7 +574,13 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
|
|
||||||
{/* No-curve hint (only when curve is truly empty) */}
|
{/* No-curve hint (only when curve is truly empty) */}
|
||||||
{!activeCurve && (
|
{!activeCurve && (
|
||||||
<text x={CHART_W / 2} y={CHART_H / 2} textAnchor="middle" fill="#52525b" fontSize="11">
|
<text
|
||||||
|
x={CHART_W / 2}
|
||||||
|
y={CHART_H / 2}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#52525b"
|
||||||
|
fontSize="11"
|
||||||
|
>
|
||||||
Click to add points
|
Click to add points
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
@@ -397,7 +591,9 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
{activeCurve && activeCurve.length > 0 && (
|
{activeCurve && activeCurve.length > 0 && (
|
||||||
<div className="px-4 pb-3">
|
<div className="px-4 pb-3">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs text-zinc-500 uppercase tracking-wider">Curve Points</span>
|
<span className="text-xs text-zinc-500 uppercase tracking-wider">
|
||||||
|
Curve Points
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const curve = pending ?? fanState?.curve ?? defaultCurve();
|
const curve = pending ?? fanState?.curve ?? defaultCurve();
|
||||||
@@ -423,16 +619,29 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{activeCurve.map((p, i) => (
|
{activeCurve.map((p, i) => (
|
||||||
<tr key={i} className="border-t border-zinc-800/50">
|
<tr key={i} className="border-t border-zinc-800/50">
|
||||||
<td className="py-1 font-mono text-zinc-300">{p.temp_c}</td>
|
<td className="py-1 font-mono text-zinc-300">
|
||||||
<td className="py-1 font-mono text-zinc-300">{p.fan_pct}</td>
|
{p.temp_c}
|
||||||
<td className="py-1">
|
</td>
|
||||||
{activeCurve.length > 2 && (
|
<td className="py-1 font-mono text-zinc-300">
|
||||||
|
{p.fan_pct}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 text-right">
|
||||||
|
{activeCurve.length > 2 ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => removePoint(i)}
|
onClick={() => removePoint(i)}
|
||||||
className="text-zinc-600 hover:text-red-400 transition-colors"
|
title="Remove point"
|
||||||
|
aria-label="Remove point"
|
||||||
|
className="p-1 text-zinc-600 hover:text-red-400 hover:bg-red-900/30 rounded transition-colors"
|
||||||
>
|
>
|
||||||
<X size={10} />
|
<X size={12} />
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="text-zinc-700"
|
||||||
|
title="A curve needs at least 2 points"
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -446,17 +655,22 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="px-4 pb-3 text-[10px] text-zinc-600">
|
<div className="px-4 pb-3 text-[10px] text-zinc-600">
|
||||||
{curveActive
|
{curveActive
|
||||||
? 'Fan curve is active. Server adjusts fan speed based on GPU temperature.'
|
? `Fan curve is active — controlling ${fanLabel(fanTargets)}. Server adjusts fan speed based on GPU temperature.`
|
||||||
: isDefaults
|
: isDefaults
|
||||||
? 'These are default values. Click Apply to enable curve control, or edit points first.'
|
? "These are default values. Click Apply to enable curve control, or edit points first."
|
||||||
: 'Apply a curve to enable automatic fan control based on temperature.'}
|
: "Apply a curve to enable automatic fan control based on temperature."}
|
||||||
|
<span className="block mt-1 text-zinc-700">
|
||||||
|
Drag points to adjust · click the chart to add a point · click the ✕
|
||||||
|
(chart or table) to remove one · pick which fans the curve controls
|
||||||
|
above.
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{confirmApply && (
|
{confirmApply && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
message="Apply fan curve?"
|
message="Apply fan curve?"
|
||||||
detail="Fan control will switch to curve-based mode. The server will adjust fan speed based on GPU temperature."
|
detail={`Fan control will switch to curve-based mode for ${fanLabel(fanTargets)}. The server will adjust fan speed based on GPU temperature.`}
|
||||||
confirmLabel="Apply"
|
confirmLabel="Apply"
|
||||||
onConfirm={handleApply}
|
onConfirm={handleApply}
|
||||||
onCancel={() => setConfirmApply(false)}
|
onCancel={() => setConfirmApply(false)}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
import { Check, X, RotateCcw } from 'lucide-react';
|
import { Check, X, RotateCcw } from "lucide-react";
|
||||||
import { api } from '../../api/client';
|
import { api } from "../../api/client.js";
|
||||||
import { useCurveStore } from '../../store/curveStore';
|
import { useCurveStore } from "../../store/curveStore.js";
|
||||||
import type { LimitsState } from '../../types';
|
import type { LimitsState } from "../../types.js";
|
||||||
import { toast } from 'sonner';
|
import { toast } from "sonner";
|
||||||
import { ConfirmDialog } from '../common/ConfirmDialog';
|
import { ConfirmDialog } from "../common/ConfirmDialog.js";
|
||||||
|
|
||||||
type Pending = Pick<LimitsState, 'power_limit_w' | 'mem_offset_mhz'>;
|
type Pending = Pick<LimitsState, "power_limit_w" | "mem_offset_mhz">;
|
||||||
|
|
||||||
export function PerformancePanel() {
|
export function PerformancePanel() {
|
||||||
const { selectedGpuIndex } = useCurveStore();
|
const { selectedGpuIndex } = useCurveStore();
|
||||||
@@ -23,13 +23,18 @@ export function PerformancePanel() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setLimits(await api.limits(selectedGpuIndex));
|
setLimits(await api.limits(selectedGpuIndex));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to load performance limits');
|
toast.error("Failed to load performance limits");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { fetchLimits(); }, [selectedGpuIndex]);
|
// Data fetch on GPU change — setState calls happen after the await, not
|
||||||
|
// synchronously in the effect body (rule false-positive on async fetch).
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
fetchLimits();
|
||||||
|
}, [selectedGpuIndex]);
|
||||||
|
|
||||||
async function handleApply() {
|
async function handleApply() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -40,9 +45,9 @@ export function PerformancePanel() {
|
|||||||
setPending({});
|
setPending({});
|
||||||
setConfirmApply(false);
|
setConfirmApply(false);
|
||||||
await fetchLimits();
|
await fetchLimits();
|
||||||
toast.success('Performance limits applied');
|
toast.success("Performance limits applied");
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
setError(e.message ?? String(e));
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
setConfirmApply(false);
|
setConfirmApply(false);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -58,9 +63,9 @@ export function PerformancePanel() {
|
|||||||
setPending({});
|
setPending({});
|
||||||
setConfirmReset(false);
|
setConfirmReset(false);
|
||||||
await fetchLimits();
|
await fetchLimits();
|
||||||
toast.success('Performance limits reset to defaults');
|
toast.success("Performance limits reset to defaults");
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
setError(e.message ?? String(e));
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
setConfirmReset(false);
|
setConfirmReset(false);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -96,10 +101,11 @@ export function PerformancePanel() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
|
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
|
||||||
|
|
||||||
{/* ── Header ─────────────────────────────────────────────────────── */}
|
{/* ── Header ─────────────────────────────────────────────────────── */}
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 shrink-0">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 shrink-0">
|
||||||
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Performance</span>
|
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
|
||||||
|
Performance
|
||||||
|
</span>
|
||||||
|
|
||||||
{hasPending && (
|
{hasPending && (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs">
|
||||||
@@ -117,7 +123,10 @@ export function PerformancePanel() {
|
|||||||
Apply
|
Apply
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setPending({}); setError(null); }}
|
onClick={() => {
|
||||||
|
setPending({});
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
disabled={!hasPending || busy}
|
disabled={!hasPending || busy}
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
@@ -139,25 +148,32 @@ export function PerformancePanel() {
|
|||||||
{error && (
|
{error && (
|
||||||
<div className="px-3 py-1.5 bg-red-900/40 border-b border-red-700 text-red-300 text-xs flex items-center justify-between">
|
<div className="px-3 py-1.5 bg-red-900/40 border-b border-red-700 text-red-300 text-xs flex items-center justify-between">
|
||||||
<span>⚠ {error}</span>
|
<span>⚠ {error}</span>
|
||||||
<button onClick={() => setError(null)} className="ml-2 text-red-400 hover:text-red-200">✕</button>
|
<button
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
className="ml-2 text-red-400 hover:text-red-200"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col divide-y divide-zinc-800">
|
<div className="flex flex-col divide-y divide-zinc-800">
|
||||||
|
|
||||||
{/* ── Board Power Limit ─────────────────────────────────────────── */}
|
{/* ── Board Power Limit ─────────────────────────────────────────── */}
|
||||||
<div className="px-4 py-4 flex flex-col gap-3">
|
<div className="px-4 py-4 flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs text-zinc-500 uppercase tracking-wider">Board Power Limit</span>
|
<span className="text-xs text-zinc-500 uppercase tracking-wider">
|
||||||
|
Board Power Limit
|
||||||
|
</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={pwrMin}
|
min={pwrMin}
|
||||||
max={pwrMax}
|
max={pwrMax}
|
||||||
value={pwrVal}
|
value={pwrVal}
|
||||||
onChange={e => {
|
onChange={(e) => {
|
||||||
const v = parseInt(e.target.value);
|
const v = parseInt(e.target.value);
|
||||||
if (!isNaN(v)) setPending(p => ({ ...p, power_limit_w: v }));
|
if (!isNaN(v))
|
||||||
|
setPending((p) => ({ ...p, power_limit_w: v }));
|
||||||
}}
|
}}
|
||||||
className="w-14 bg-zinc-950 border border-zinc-800 rounded text-xs px-2 py-1 text-right font-mono focus:outline-none focus:border-cyan-500"
|
className="w-14 bg-zinc-950 border border-zinc-800 rounded text-xs px-2 py-1 text-right font-mono focus:outline-none focus:border-cyan-500"
|
||||||
/>
|
/>
|
||||||
@@ -165,32 +181,44 @@ export function PerformancePanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-zinc-600 font-mono w-8 text-right">{pwrMin}</span>
|
<span className="text-xs text-zinc-600 font-mono w-8 text-right">
|
||||||
|
{pwrMin}
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={pwrMin}
|
min={pwrMin}
|
||||||
max={pwrMax}
|
max={pwrMax}
|
||||||
value={pwrVal}
|
value={pwrVal}
|
||||||
onChange={e => setPending(p => ({ ...p, power_limit_w: parseInt(e.target.value) }))}
|
onChange={(e) =>
|
||||||
|
setPending((p) => ({
|
||||||
|
...p,
|
||||||
|
power_limit_w: parseInt(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
className="flex-1 accent-cyan-400 h-1 cursor-pointer"
|
className="flex-1 accent-cyan-400 h-1 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-zinc-600 font-mono w-8">{pwrMax}</span>
|
<span className="text-xs text-zinc-600 font-mono w-8">
|
||||||
|
{pwrMax}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Memory Clock Offset ───────────────────────────────────────── */}
|
{/* ── Memory Clock Offset ───────────────────────────────────────── */}
|
||||||
<div className="px-4 py-4 flex flex-col gap-3">
|
<div className="px-4 py-4 flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs text-zinc-500 uppercase tracking-wider">Memory Clock Offset</span>
|
<span className="text-xs text-zinc-500 uppercase tracking-wider">
|
||||||
|
Memory Clock Offset
|
||||||
|
</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={memMin}
|
min={memMin}
|
||||||
max={memMax}
|
max={memMax}
|
||||||
value={memVal}
|
value={memVal}
|
||||||
onChange={e => {
|
onChange={(e) => {
|
||||||
const v = parseInt(e.target.value);
|
const v = parseInt(e.target.value);
|
||||||
if (!isNaN(v)) setPending(p => ({ ...p, mem_offset_mhz: v }));
|
if (!isNaN(v))
|
||||||
|
setPending((p) => ({ ...p, mem_offset_mhz: v }));
|
||||||
}}
|
}}
|
||||||
className="w-16 bg-zinc-950 border border-zinc-800 rounded text-xs px-2 py-1 text-right font-mono focus:outline-none focus:border-cyan-500"
|
className="w-16 bg-zinc-950 border border-zinc-800 rounded text-xs px-2 py-1 text-right font-mono focus:outline-none focus:border-cyan-500"
|
||||||
/>
|
/>
|
||||||
@@ -198,20 +226,28 @@ export function PerformancePanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-zinc-600 font-mono w-10 text-right">{memMin}</span>
|
<span className="text-xs text-zinc-600 font-mono w-10 text-right">
|
||||||
|
{memMin}
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={memMin}
|
min={memMin}
|
||||||
max={memMax}
|
max={memMax}
|
||||||
step={1}
|
step={1}
|
||||||
value={memVal}
|
value={memVal}
|
||||||
onChange={e => setPending(p => ({ ...p, mem_offset_mhz: parseInt(e.target.value) }))}
|
onChange={(e) =>
|
||||||
|
setPending((p) => ({
|
||||||
|
...p,
|
||||||
|
mem_offset_mhz: parseInt(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
className="flex-1 accent-cyan-400 h-1 cursor-pointer"
|
className="flex-1 accent-cyan-400 h-1 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-zinc-600 font-mono w-10">+{memMax}</span>
|
<span className="text-xs text-zinc-600 font-mono w-10">
|
||||||
|
+{memMax}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { GaugeCard } from './GaugeCard';
|
import { GaugeCard } from "./GaugeCard.js";
|
||||||
import { fmt } from '../../utils/units';
|
import { fmt } from "../../utils/units.js";
|
||||||
import type { MonitoringSample, FanPoint } from '../../types';
|
import type { MonitoringSample, FanPoint } from "../../types.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
monitor: MonitoringSample | null;
|
monitor: MonitoringSample | null;
|
||||||
@@ -16,7 +16,10 @@ function pluck<K extends keyof MonitoringSample>(
|
|||||||
return history.map((s) => (s[key] as number | null) ?? 0);
|
return history.map((s) => (s[key] as number | null) ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeTargetFan(curve: FanPoint[] | null, tempC: number | null): number | null {
|
function computeTargetFan(
|
||||||
|
curve: FanPoint[] | null,
|
||||||
|
tempC: number | null,
|
||||||
|
): number | null {
|
||||||
if (!curve || !tempC || curve.length < 2) return null;
|
if (!curve || !tempC || curve.length < 2) return null;
|
||||||
|
|
||||||
for (let i = 0; i < curve.length - 1; i++) {
|
for (let i = 0; i < curve.length - 1; i++) {
|
||||||
@@ -36,19 +39,30 @@ function computeTargetFan(curve: FanPoint[] | null, tempC: number | null): numbe
|
|||||||
return curve[curve.length - 1].fan_pct;
|
return curve[curve.length - 1].fan_pct;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props) {
|
export function FanMonitor({
|
||||||
const fanHistory = pluck(history, 'fan_pct');
|
monitor,
|
||||||
const tempHistory = pluck(history, 'temp_c');
|
history,
|
||||||
|
fanCurve,
|
||||||
|
fanCurveActive,
|
||||||
|
}: Props) {
|
||||||
|
const fanHistory = pluck(history, "fan_pct");
|
||||||
|
const tempHistory = pluck(history, "temp_c");
|
||||||
|
const fans = monitor?.fans ?? null;
|
||||||
|
const multiFan = fans !== null && fans.length > 1;
|
||||||
|
|
||||||
const currentTemp = monitor?.temp_c ?? null;
|
const currentTemp = monitor?.temp_c ?? null;
|
||||||
const targetFan = computeTargetFan(fanCurve, currentTemp);
|
const targetFan = computeTargetFan(fanCurve, currentTemp);
|
||||||
const targetFanHistory = history.map((s) => computeTargetFan(fanCurve, s.temp_c) ?? 0);
|
const targetFanHistory = history.map(
|
||||||
|
(s) => computeTargetFan(fanCurve, s.temp_c) ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 w-full h-full">
|
<div className="flex flex-col gap-2 w-full h-full">
|
||||||
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full">
|
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full">
|
||||||
<div className="flex items-center justify-between pb-2 border-b border-zinc-800">
|
<div className="flex items-center justify-between pb-2 border-b border-zinc-800">
|
||||||
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Live Monitor</span>
|
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
|
||||||
|
Live Monitor
|
||||||
|
</span>
|
||||||
{fanCurveActive && (
|
{fanCurveActive && (
|
||||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-orange-500/15 border border-orange-500/30 text-orange-400 text-[10px] font-semibold">
|
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-orange-500/15 border border-orange-500/30 text-orange-400 text-[10px] font-semibold">
|
||||||
Curve Active
|
Curve Active
|
||||||
@@ -56,6 +70,18 @@ export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 mt-1">
|
<div className="flex flex-col gap-2 mt-1">
|
||||||
|
{multiFan ? (
|
||||||
|
fans!.map((_, i) => (
|
||||||
|
<GaugeCard
|
||||||
|
key={i}
|
||||||
|
label={`Fan ${i + 1}`}
|
||||||
|
value={fmt.pct(fans![i])}
|
||||||
|
history={history.map((s) => s.fans?.[i] ?? s.fan_pct ?? 0)}
|
||||||
|
color="#fb923c"
|
||||||
|
max={100}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
<GaugeCard
|
<GaugeCard
|
||||||
label="Fan Speed"
|
label="Fan Speed"
|
||||||
value={fmt.pct(monitor?.fan_pct)}
|
value={fmt.pct(monitor?.fan_pct)}
|
||||||
@@ -63,6 +89,7 @@ export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props
|
|||||||
color="#fb923c"
|
color="#fb923c"
|
||||||
max={100}
|
max={100}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<GaugeCard
|
<GaugeCard
|
||||||
label="GPU Temp"
|
label="GPU Temp"
|
||||||
value={fmt.celsius(monitor?.temp_c)}
|
value={fmt.celsius(monitor?.temp_c)}
|
||||||
@@ -73,7 +100,7 @@ export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props
|
|||||||
{fanCurveActive && (
|
{fanCurveActive && (
|
||||||
<GaugeCard
|
<GaugeCard
|
||||||
label="Target Fan"
|
label="Target Fan"
|
||||||
value={targetFan !== null ? `${targetFan}%` : '—'}
|
value={targetFan !== null ? `${targetFan}%` : "—"}
|
||||||
history={targetFanHistory}
|
history={targetFanHistory}
|
||||||
color="#fbbf24"
|
color="#fbbf24"
|
||||||
max={100}
|
max={100}
|
||||||
@@ -82,7 +109,7 @@ export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props
|
|||||||
<div className="mt-auto">
|
<div className="mt-auto">
|
||||||
<GaugeCard
|
<GaugeCard
|
||||||
label="Fan Mode"
|
label="Fan Mode"
|
||||||
value={fanCurveActive ? 'Curve' : 'Auto'}
|
value={fanCurveActive ? "Curve" : "Auto"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { GaugeCard } from './GaugeCard';
|
import { GaugeCard } from "./GaugeCard.js";
|
||||||
import { fmt } from '../../utils/units';
|
import { fmt } from "../../utils/units.js";
|
||||||
import type { MonitoringSample } from '../../types';
|
import type { MonitoringSample } from "../../types.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
monitor: MonitoringSample | null;
|
monitor: MonitoringSample | null;
|
||||||
@@ -19,42 +19,44 @@ export function LiveMonitor({ monitor, history }: Props) {
|
|||||||
<div className="flex flex-col gap-2 w-full h-full">
|
<div className="flex flex-col gap-2 w-full h-full">
|
||||||
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full">
|
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full">
|
||||||
<div className="flex items-center justify-between pb-2 border-b border-zinc-800">
|
<div className="flex items-center justify-between pb-2 border-b border-zinc-800">
|
||||||
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Live Monitor</span>
|
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
|
||||||
|
Live Monitor
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 mt-1">
|
<div className="flex flex-col gap-2 mt-1">
|
||||||
<GaugeCard
|
<GaugeCard
|
||||||
label="Core Clock"
|
label="Core Clock"
|
||||||
value={fmt.mhz(monitor?.clock_mhz)}
|
value={fmt.mhz(monitor?.clock_mhz)}
|
||||||
history={pluck(history, 'clock_mhz')}
|
history={pluck(history, "clock_mhz")}
|
||||||
color="#34d399"
|
color="#34d399"
|
||||||
max={3000}
|
max={3000}
|
||||||
/>
|
/>
|
||||||
<GaugeCard
|
<GaugeCard
|
||||||
label="Voltage"
|
label="Voltage"
|
||||||
value={fmt.mv(monitor?.voltage_mv)}
|
value={fmt.mv(monitor?.voltage_mv)}
|
||||||
history={pluck(history, 'voltage_mv')}
|
history={pluck(history, "voltage_mv")}
|
||||||
color="#a78bfa"
|
color="#a78bfa"
|
||||||
max={1100}
|
max={1100}
|
||||||
/>
|
/>
|
||||||
<GaugeCard
|
<GaugeCard
|
||||||
label="Power Draw"
|
label="Power Draw"
|
||||||
value={fmt.watts(monitor?.power_w)}
|
value={fmt.watts(monitor?.power_w)}
|
||||||
history={pluck(history, 'power_w')}
|
history={pluck(history, "power_w")}
|
||||||
color="#f472b6"
|
color="#f472b6"
|
||||||
max={600}
|
max={600}
|
||||||
/>
|
/>
|
||||||
<GaugeCard
|
<GaugeCard
|
||||||
label="GPU Util"
|
label="GPU Util"
|
||||||
value={fmt.pct(monitor?.gpu_util_pct)}
|
value={fmt.pct(monitor?.gpu_util_pct)}
|
||||||
history={pluck(history, 'gpu_util_pct')}
|
history={pluck(history, "gpu_util_pct")}
|
||||||
color="#facc15"
|
color="#facc15"
|
||||||
max={100}
|
max={100}
|
||||||
/>
|
/>
|
||||||
<div className="mt-auto">
|
<div className="mt-auto">
|
||||||
<GaugeCard
|
<GaugeCard
|
||||||
label="P-State"
|
label="P-State"
|
||||||
value={monitor?.pstate_label ?? 'Unknown'}
|
value={monitor?.pstate_label ?? "Unknown"}
|
||||||
history={pluck(history, 'pstate')}
|
history={pluck(history, "pstate")}
|
||||||
color="#a8a29e"
|
color="#a8a29e"
|
||||||
max={15}
|
max={15}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { GaugeCard } from './GaugeCard';
|
import { GaugeCard } from "./GaugeCard.js";
|
||||||
import { fmt } from '../../utils/units';
|
import { fmt } from "../../utils/units.js";
|
||||||
import type { MonitoringSample } from '../../types';
|
import type { MonitoringSample } from "../../types.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
monitor: MonitoringSample | null;
|
monitor: MonitoringSample | null;
|
||||||
@@ -17,35 +17,38 @@ function pluck<K extends keyof MonitoringSample>(
|
|||||||
export function PerformanceMonitor({ monitor, history }: Props) {
|
export function PerformanceMonitor({ monitor, history }: Props) {
|
||||||
const memUsed = monitor?.mem_used_mib ?? null;
|
const memUsed = monitor?.mem_used_mib ?? null;
|
||||||
const memTotal = monitor?.mem_total_mib ?? null;
|
const memTotal = monitor?.mem_total_mib ?? null;
|
||||||
const memLabel = memUsed != null && memTotal != null
|
const memLabel =
|
||||||
|
memUsed != null && memTotal != null
|
||||||
? `${memUsed.toFixed(0)} / ${memTotal.toFixed(0)} MiB`
|
? `${memUsed.toFixed(0)} / ${memTotal.toFixed(0)} MiB`
|
||||||
: '—';
|
: "—";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 w-full h-full">
|
<div className="flex flex-col gap-2 w-full h-full">
|
||||||
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full">
|
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full">
|
||||||
<div className="flex items-center justify-between pb-2 border-b border-zinc-800">
|
<div className="flex items-center justify-between pb-2 border-b border-zinc-800">
|
||||||
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Live Monitor</span>
|
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
|
||||||
|
Live Monitor
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 mt-1">
|
<div className="flex flex-col gap-2 mt-1">
|
||||||
<GaugeCard
|
<GaugeCard
|
||||||
label="Mem Clock"
|
label="Mem Clock"
|
||||||
value={fmt.mhz(monitor?.mem_clock_mhz)}
|
value={fmt.mhz(monitor?.mem_clock_mhz)}
|
||||||
history={pluck(history, 'mem_clock_mhz')}
|
history={pluck(history, "mem_clock_mhz")}
|
||||||
color="#67e8f9"
|
color="#67e8f9"
|
||||||
max={20000}
|
max={20000}
|
||||||
/>
|
/>
|
||||||
<GaugeCard
|
<GaugeCard
|
||||||
label="Power Draw"
|
label="Power Draw"
|
||||||
value={fmt.watts(monitor?.power_w)}
|
value={fmt.watts(monitor?.power_w)}
|
||||||
history={pluck(history, 'power_w')}
|
history={pluck(history, "power_w")}
|
||||||
color="#f472b6"
|
color="#f472b6"
|
||||||
max={600}
|
max={600}
|
||||||
/>
|
/>
|
||||||
<GaugeCard
|
<GaugeCard
|
||||||
label="VRAM Used"
|
label="VRAM Used"
|
||||||
value={memLabel}
|
value={memLabel}
|
||||||
history={pluck(history, 'mem_used_mib')}
|
history={pluck(history, "mem_used_mib")}
|
||||||
color="#a78bfa"
|
color="#a78bfa"
|
||||||
max={memTotal ?? 32768}
|
max={memTotal ?? 32768}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { GpuInfo, MonitoringSample } from "../../types";
|
import type { GpuInfo, MonitoringSample } from "../../types.js";
|
||||||
import { fmt } from "../../utils/units";
|
import { fmt } from "../../utils/units.js";
|
||||||
import { useCurveStore } from "../../store/curveStore";
|
import { useCurveStore } from "../../store/curveStore.js";
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { fmt } from '../../utils/units';
|
import { fmt } from "../../utils/units.js";
|
||||||
import type { VFPoint } from '../../types';
|
import type { VFPoint } from "../../types.js";
|
||||||
import { useCurveStore } from '../../store/curveStore';
|
import { useCurveStore } from "../../store/curveStore.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
point: VFPoint;
|
point: VFPoint;
|
||||||
@@ -14,17 +14,28 @@ interface Props {
|
|||||||
onMouseEnter?: () => void;
|
onMouseEnter?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDeltaKhz, shouldAutoScroll, onMouseDown, onMouseEnter }: Props) {
|
export function PointRow({
|
||||||
|
point,
|
||||||
|
isCurrent,
|
||||||
|
isSelected,
|
||||||
|
isClamped,
|
||||||
|
pendingDeltaKhz,
|
||||||
|
shouldAutoScroll,
|
||||||
|
onMouseDown,
|
||||||
|
onMouseEnter,
|
||||||
|
}: Props) {
|
||||||
const { stageEdit } = useCurveStore();
|
const { stageEdit } = useCurveStore();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState("");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const trRef = useRef<HTMLTableRowElement>(null);
|
const trRef = useRef<HTMLTableRowElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shouldAutoScroll && trRef.current) {
|
if (shouldAutoScroll && trRef.current) {
|
||||||
// @ts-expect-error: the typing seems to not include the valid 'container' option
|
trRef.current.scrollIntoView({
|
||||||
trRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', container: 'nearest' });
|
behavior: "smooth",
|
||||||
|
block: "nearest",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [shouldAutoScroll]);
|
}, [shouldAutoScroll]);
|
||||||
|
|
||||||
@@ -36,11 +47,19 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
|
|||||||
const displayEffMhz = point.freq_mhz + deltaChange / 1000;
|
const displayEffMhz = point.freq_mhz + deltaChange / 1000;
|
||||||
|
|
||||||
const deltaColor = hasPending
|
const deltaColor = hasPending
|
||||||
? displayDeltaKhz > 0 ? 'text-cyan-400' : displayDeltaKhz < 0 ? 'text-orange-400' : 'text-zinc-400'
|
? displayDeltaKhz > 0
|
||||||
: point.delta_khz > 0 ? 'text-emerald-400' : point.delta_khz < 0 ? 'text-red-400' : 'text-zinc-500';
|
? "text-cyan-400"
|
||||||
|
: displayDeltaKhz < 0
|
||||||
|
? "text-orange-400"
|
||||||
|
: "text-zinc-400"
|
||||||
|
: point.delta_khz > 0
|
||||||
|
? "text-emerald-400"
|
||||||
|
: point.delta_khz < 0
|
||||||
|
? "text-red-400"
|
||||||
|
: "text-zinc-500";
|
||||||
|
|
||||||
function startEdit() {
|
function startEdit() {
|
||||||
setInputValue((displayDeltaMhz).toFixed(1));
|
setInputValue(displayDeltaMhz.toFixed(1));
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
inputRef.current?.select();
|
inputRef.current?.select();
|
||||||
@@ -64,9 +83,13 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
|
|||||||
<tr
|
<tr
|
||||||
ref={trRef}
|
ref={trRef}
|
||||||
className={[
|
className={[
|
||||||
'border-b border-zinc-800 text-xs font-mono cursor-pointer',
|
"border-b border-zinc-800 text-xs font-mono cursor-pointer",
|
||||||
isCurrent ? 'bg-yellow-400/10' : isSelected ? 'bg-cyan-500/10' : 'hover:bg-zinc-800/50',
|
isCurrent
|
||||||
].join(' ')}
|
? "bg-yellow-400/10"
|
||||||
|
: isSelected
|
||||||
|
? "bg-cyan-500/10"
|
||||||
|
: "hover:bg-zinc-800/50",
|
||||||
|
].join(" ")}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (editing) return;
|
if (editing) return;
|
||||||
onMouseDown?.(e);
|
onMouseDown?.(e);
|
||||||
@@ -80,7 +103,13 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
|
|||||||
<td className="px-3 py-1 text-zinc-300">{fmt.mv(point.volt_mv, 0)}</td>
|
<td className="px-3 py-1 text-zinc-300">{fmt.mv(point.volt_mv, 0)}</td>
|
||||||
|
|
||||||
{/* Offset — click to edit inline */}
|
{/* Offset — click to edit inline */}
|
||||||
<td className={`px-3 py-1 ${deltaColor}`} onClick={(e) => { e.stopPropagation(); startEdit(); }}>
|
<td
|
||||||
|
className={`px-3 py-1 ${deltaColor}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
startEdit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -90,28 +119,35 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
|
|||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
onBlur={commitEdit}
|
onBlur={commitEdit}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); commitEdit(); }
|
if (e.key === "Enter" || e.key === "Tab") {
|
||||||
if (e.key === 'Escape') cancelEdit();
|
e.preventDefault();
|
||||||
|
commitEdit();
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") cancelEdit();
|
||||||
}}
|
}}
|
||||||
className="w-20 bg-zinc-700 text-cyan-300 rounded px-1 py-0 border border-cyan-500 outline-none text-xs"
|
className="w-20 bg-zinc-700 text-cyan-300 rounded px-1 py-0 border border-cyan-500 outline-none text-xs font-mono"
|
||||||
style={{ fontFamily: 'monospace' }}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span title="Click to edit">
|
<span title="Click to edit">
|
||||||
{hasPending && <span className="text-cyan-500 mr-0.5">✎</span>}
|
{hasPending && <span className="text-cyan-500 mr-0.5">✎</span>}
|
||||||
{displayDeltaKhz > 0 ? '+' : ''}{displayDeltaMhz.toFixed(1)} MHz
|
{displayDeltaKhz > 0 ? "+" : ""}
|
||||||
|
{displayDeltaMhz.toFixed(1)} MHz
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Eff. Freq */}
|
{/* Eff. Freq */}
|
||||||
<td className={`px-3 py-1 font-semibold ${hasPending ? 'text-cyan-200' : 'text-zinc-100'}`}>
|
<td
|
||||||
|
className={`px-3 py-1 font-semibold ${hasPending ? "text-cyan-200" : "text-zinc-100"}`}
|
||||||
|
>
|
||||||
{fmt.mhz(displayEffMhz, 0)}
|
{fmt.mhz(displayEffMhz, 0)}
|
||||||
{isClamped && !hasPending && (
|
{isClamped && !hasPending && (
|
||||||
<span
|
<span
|
||||||
className="ml-1 text-amber-500 cursor-help"
|
className="ml-1 text-amber-500 cursor-help"
|
||||||
title="Clamped by monotonicity — a lower-voltage point with a higher offset is holding this frequency up"
|
title="Clamped by monotonicity — a lower-voltage point with a higher offset is holding this frequency up"
|
||||||
>⇡</span>
|
>
|
||||||
|
⇡
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react';
|
import { useState, useMemo, useEffect } from "react";
|
||||||
import { PointRow } from './PointRow';
|
import { PointRow } from "./PointRow.js";
|
||||||
import type { VFPoint } from '../../types';
|
import type { VFPoint } from "../../types.js";
|
||||||
import { findCurrentPoint, detectClampedPoints } from '../../utils/curveHelpers';
|
import {
|
||||||
import { useCurveStore } from '../../store/curveStore';
|
findCurrentPoint,
|
||||||
|
detectClampedPoints,
|
||||||
|
} from "../../utils/curveHelpers.js";
|
||||||
|
import { useCurveStore } from "../../store/curveStore.js";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
points: VFPoint[];
|
points: VFPoint[];
|
||||||
@@ -11,22 +14,32 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
|
export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
|
||||||
const { pendingDeltas, selectedPoints, selectPoint, selectRange } = useCurveStore();
|
const {
|
||||||
|
pendingDeltas,
|
||||||
|
selectedPoints,
|
||||||
|
selectPoint,
|
||||||
|
togglePoint,
|
||||||
|
selectRange,
|
||||||
|
} = useCurveStore();
|
||||||
const currentPoint = findCurrentPoint(points, currentVoltageMv);
|
const currentPoint = findCurrentPoint(points, currentVoltageMv);
|
||||||
const clampedPoints = useMemo(() => detectClampedPoints(points), [points]);
|
const clampedPoints = useMemo(() => detectClampedPoints(points), [points]);
|
||||||
|
|
||||||
const [dragStartIdx, setDragStartIdx] = useState<number | null>(null);
|
const [dragStartIdx, setDragStartIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onUp() { setDragStartIdx(null); }
|
function onUp() {
|
||||||
window.addEventListener('mouseup', onUp);
|
setDragStartIdx(null);
|
||||||
return () => window.removeEventListener('mouseup', onUp);
|
}
|
||||||
|
window.addEventListener("mouseup", onUp);
|
||||||
|
return () => window.removeEventListener("mouseup", onUp);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
|
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 shrink-0">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 shrink-0">
|
||||||
<span className="text-xs text-zinc-500 uppercase tracking-wider mr-2">Points</span>
|
<span className="text-xs text-zinc-500 uppercase tracking-wider mr-2">
|
||||||
|
Points
|
||||||
|
</span>
|
||||||
{!readOnly && pendingDeltas.size > 0 && (
|
{!readOnly && pendingDeltas.size > 0 && (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs">
|
||||||
{pendingDeltas.size} staged
|
{pendingDeltas.size} staged
|
||||||
@@ -35,13 +48,17 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
|
|||||||
{readOnly && (
|
{readOnly && (
|
||||||
<span className="text-xs text-zinc-600 italic">read-only</span>
|
<span className="text-xs text-zinc-600 italic">read-only</span>
|
||||||
)}
|
)}
|
||||||
<span className="ml-auto text-xs text-zinc-600">{points.length} points</span>
|
<span className="ml-auto text-xs text-zinc-600">
|
||||||
|
{points.length} points
|
||||||
|
</span>
|
||||||
{!readOnly && selectedPoints.size === 1 && (
|
{!readOnly && selectedPoints.size === 1 && (
|
||||||
<div className="flex gap-1 ml-4 border-l border-zinc-800 pl-4">
|
<div className="flex gap-1 ml-4 border-l border-zinc-800 pl-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const idx = Array.from(selectedPoints)[0];
|
const idx = Array.from(selectedPoints)[0];
|
||||||
const beforeIdxs = points.filter(p => p.index <= idx).map(p => p.index);
|
const beforeIdxs = points
|
||||||
|
.filter((p) => p.index <= idx)
|
||||||
|
.map((p) => p.index);
|
||||||
selectRange(beforeIdxs);
|
selectRange(beforeIdxs);
|
||||||
}}
|
}}
|
||||||
className="px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors whitespace-nowrap"
|
className="px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors whitespace-nowrap"
|
||||||
@@ -51,7 +68,9 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const idx = Array.from(selectedPoints)[0];
|
const idx = Array.from(selectedPoints)[0];
|
||||||
const afterIdxs = points.filter(p => p.index >= idx).map(p => p.index);
|
const afterIdxs = points
|
||||||
|
.filter((p) => p.index >= idx)
|
||||||
|
.map((p) => p.index);
|
||||||
selectRange(afterIdxs);
|
selectRange(afterIdxs);
|
||||||
}}
|
}}
|
||||||
className="px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors whitespace-nowrap"
|
className="px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors whitespace-nowrap"
|
||||||
@@ -66,9 +85,15 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
|
|||||||
<thead className="sticky top-0 bg-zinc-900 z-10">
|
<thead className="sticky top-0 bg-zinc-900 z-10">
|
||||||
<tr className="text-xs text-zinc-500 uppercase tracking-wider border-b border-zinc-800">
|
<tr className="text-xs text-zinc-500 uppercase tracking-wider border-b border-zinc-800">
|
||||||
<th className="px-3 py-2 text-left font-normal bg-zinc-900">#</th>
|
<th className="px-3 py-2 text-left font-normal bg-zinc-900">#</th>
|
||||||
<th className="px-3 py-2 text-left font-normal bg-zinc-900">Voltage</th>
|
<th className="px-3 py-2 text-left font-normal bg-zinc-900">
|
||||||
<th className="px-3 py-2 text-left font-normal bg-zinc-900">Offset</th>
|
Voltage
|
||||||
<th className="px-3 py-2 text-left font-normal bg-zinc-900">Eff. Freq</th>
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left font-normal bg-zinc-900">
|
||||||
|
Offset
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left font-normal bg-zinc-900">
|
||||||
|
Eff. Freq
|
||||||
|
</th>
|
||||||
<th className="px-3 py-2 text-left font-normal bg-zinc-900" />
|
<th className="px-3 py-2 text-left font-normal bg-zinc-900" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -81,34 +106,48 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
|
|||||||
isSelected={selectedPoints.has(p.index)}
|
isSelected={selectedPoints.has(p.index)}
|
||||||
isClamped={clampedPoints.has(p.index)}
|
isClamped={clampedPoints.has(p.index)}
|
||||||
pendingDeltaKhz={pendingDeltas.get(p.index)}
|
pendingDeltaKhz={pendingDeltas.get(p.index)}
|
||||||
shouldAutoScroll={selectedPoints.size === 1 && selectedPoints.has(p.index)}
|
shouldAutoScroll={
|
||||||
onMouseDown={readOnly ? undefined : (e) => {
|
selectedPoints.size === 1 && selectedPoints.has(p.index)
|
||||||
|
}
|
||||||
|
onMouseDown={
|
||||||
|
readOnly
|
||||||
|
? undefined
|
||||||
|
: (e) => {
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
const currentSelected = Array.from(selectedPoints);
|
const currentSelected = Array.from(selectedPoints);
|
||||||
if (currentSelected.length > 0) {
|
if (currentSelected.length > 0) {
|
||||||
const last = Math.max(...currentSelected);
|
const last = Math.max(...currentSelected);
|
||||||
const min = Math.min(last, p.index);
|
const min = Math.min(last, p.index);
|
||||||
const max = Math.max(last, p.index);
|
const max = Math.max(last, p.index);
|
||||||
const toSelect = points.filter(a => a.index >= min && a.index <= max).map(a => a.index);
|
const toSelect = points
|
||||||
|
.filter((a) => a.index >= min && a.index <= max)
|
||||||
|
.map((a) => a.index);
|
||||||
selectRange(toSelect);
|
selectRange(toSelect);
|
||||||
} else {
|
} else {
|
||||||
selectPoint(p.index, false);
|
selectPoint(p.index);
|
||||||
}
|
}
|
||||||
} else if (e.ctrlKey || e.metaKey) {
|
} else if (e.ctrlKey || e.metaKey) {
|
||||||
selectPoint(p.index, true);
|
togglePoint(p.index);
|
||||||
} else {
|
} else {
|
||||||
setDragStartIdx(p.index);
|
setDragStartIdx(p.index);
|
||||||
selectPoint(p.index, false);
|
selectPoint(p.index);
|
||||||
}
|
}
|
||||||
}}
|
}
|
||||||
onMouseEnter={readOnly ? undefined : () => {
|
}
|
||||||
|
onMouseEnter={
|
||||||
|
readOnly
|
||||||
|
? undefined
|
||||||
|
: () => {
|
||||||
if (dragStartIdx !== null) {
|
if (dragStartIdx !== null) {
|
||||||
const min = Math.min(dragStartIdx, p.index);
|
const min = Math.min(dragStartIdx, p.index);
|
||||||
const max = Math.max(dragStartIdx, p.index);
|
const max = Math.max(dragStartIdx, p.index);
|
||||||
const toSelect = points.filter(a => a.index >= min && a.index <= max).map(a => a.index);
|
const toSelect = points
|
||||||
|
.filter((a) => a.index >= min && a.index <= max)
|
||||||
|
.map((a) => a.index);
|
||||||
selectRange(toSelect);
|
selectRange(toSelect);
|
||||||
}
|
}
|
||||||
}}
|
}
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,16 +1,31 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Save, Trash2, Check, ChevronRight, Pencil, Star } from 'lucide-react';
|
import {
|
||||||
import { api } from '../../api/client';
|
Save,
|
||||||
import type { ProfileData } from '../../types';
|
Trash2,
|
||||||
import { toast } from 'sonner';
|
Check,
|
||||||
import { useCurveStore } from '../../store/curveStore';
|
ChevronRight,
|
||||||
|
Pencil,
|
||||||
|
Star,
|
||||||
|
Fan,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { api } from "../../api/client.js";
|
||||||
|
import type { ProfileData } from "../../types.js";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useCurveStore } from "../../store/curveStore.js";
|
||||||
|
|
||||||
|
function errMsg(e: unknown): string {
|
||||||
|
return e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
|
||||||
interface ProfilePanelProps {
|
interface ProfilePanelProps {
|
||||||
activeProfile: string | null;
|
activeProfile: string | null;
|
||||||
onProfileApplied: (name: string | null) => void;
|
onProfileApplied: (name: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelProps) {
|
export function ProfilePanel({
|
||||||
|
activeProfile,
|
||||||
|
onProfileApplied,
|
||||||
|
}: ProfilePanelProps) {
|
||||||
const { selectedGpuIndex, gpuInfo } = useCurveStore();
|
const { selectedGpuIndex, gpuInfo } = useCurveStore();
|
||||||
const [profiles, setProfiles] = useState<ProfileData[]>([]);
|
const [profiles, setProfiles] = useState<ProfileData[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -18,7 +33,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
|
|
||||||
// Save form
|
// Save form
|
||||||
const [isSaveOpen, setIsSaveOpen] = useState(false);
|
const [isSaveOpen, setIsSaveOpen] = useState(false);
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState("");
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const saveInputRef = useRef<HTMLInputElement>(null);
|
const saveInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -28,7 +43,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
|
|
||||||
// Inline rename
|
// Inline rename
|
||||||
const [renamingName, setRenamingName] = useState<string | null>(null);
|
const [renamingName, setRenamingName] = useState<string | null>(null);
|
||||||
const [renameValue, setRenameValue] = useState('');
|
const [renameValue, setRenameValue] = useState("");
|
||||||
const [isRenaming, setIsRenaming] = useState(false);
|
const [isRenaming, setIsRenaming] = useState(false);
|
||||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -42,7 +57,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
onProfileApplied(data.active);
|
onProfileApplied(data.active);
|
||||||
setAutoLoadProfile(data.auto_load);
|
setAutoLoadProfile(data.auto_load);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to load profiles');
|
toast.error("Failed to load profiles");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -53,34 +68,43 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
await api.setAutoLoadProfile(name, selectedGpuIndex);
|
await api.setAutoLoadProfile(name, selectedGpuIndex);
|
||||||
setAutoLoadProfile(name);
|
setAutoLoadProfile(name);
|
||||||
if (name) toast.success(`"${name}" will load on server start`);
|
if (name) toast.success(`"${name}" will load on server start`);
|
||||||
else toast.success('Auto-load cleared');
|
else toast.success("Auto-load cleared");
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
toast.error('Failed to update default profile: ' + (e.message || String(e)));
|
toast.error("Failed to update default profile: " + errMsg(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { fetchProfiles(); }, [selectedGpuIndex]);
|
// Data fetch on GPU change — setState calls happen after the await, not
|
||||||
|
// synchronously in the effect body (rule false-positive on async fetch).
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
fetchProfiles();
|
||||||
|
}, [selectedGpuIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSaveOpen) saveInputRef.current?.focus();
|
if (isSaveOpen) saveInputRef.current?.focus();
|
||||||
else setNewName('');
|
|
||||||
}, [isSaveOpen]);
|
}, [isSaveOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (renamingName) renameInputRef.current?.focus();
|
if (renamingName) renameInputRef.current?.focus();
|
||||||
}, [renamingName]);
|
}, [renamingName]);
|
||||||
|
|
||||||
async function handleSave(e: React.FormEvent) {
|
function closeSaveForm() {
|
||||||
|
setIsSaveOpen(false);
|
||||||
|
setNewName("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(e: React.SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newName.trim()) return;
|
if (!newName.trim()) return;
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
await api.saveProfile(newName.trim(), selectedGpuIndex);
|
await api.saveProfile(newName.trim(), selectedGpuIndex);
|
||||||
toast.success(`Profile "${newName.trim()}" saved`);
|
toast.success(`Profile "${newName.trim()}" saved`);
|
||||||
setIsSaveOpen(false);
|
closeSaveForm();
|
||||||
await fetchProfiles();
|
await fetchProfiles();
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
toast.error('Failed to save: ' + (e.message || String(e)));
|
toast.error("Failed to save: " + errMsg(e));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -92,8 +116,8 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
await api.applyProfile(name, selectedGpuIndex);
|
await api.applyProfile(name, selectedGpuIndex);
|
||||||
onProfileApplied(name);
|
onProfileApplied(name);
|
||||||
toast.success(`"${name}" applied`);
|
toast.success(`"${name}" applied`);
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
toast.error(`Failed to apply "${name}": ` + (e.message || String(e)));
|
toast.error(`Failed to apply "${name}": ` + errMsg(e));
|
||||||
} finally {
|
} finally {
|
||||||
setApplyingName(null);
|
setApplyingName(null);
|
||||||
}
|
}
|
||||||
@@ -108,14 +132,14 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
if (autoLoadProfile === name) setAutoLoadProfile(null);
|
if (autoLoadProfile === name) setAutoLoadProfile(null);
|
||||||
setDeletingName(null);
|
setDeletingName(null);
|
||||||
await fetchProfiles();
|
await fetchProfiles();
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
toast.error('Failed to delete: ' + (e.message || String(e)));
|
toast.error("Failed to delete: " + errMsg(e));
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRename(e: React.FormEvent, oldName: string) {
|
async function handleRename(e: React.SubmitEvent, oldName: string) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!renameValue.trim() || renameValue.trim() === oldName) {
|
if (!renameValue.trim() || renameValue.trim() === oldName) {
|
||||||
setRenamingName(null);
|
setRenamingName(null);
|
||||||
@@ -129,8 +153,8 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
if (autoLoadProfile === oldName) setAutoLoadProfile(renameValue.trim());
|
if (autoLoadProfile === oldName) setAutoLoadProfile(renameValue.trim());
|
||||||
setRenamingName(null);
|
setRenamingName(null);
|
||||||
await fetchProfiles();
|
await fetchProfiles();
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
toast.error('Failed to rename: ' + (e.message || String(e)));
|
toast.error("Failed to rename: " + errMsg(e));
|
||||||
} finally {
|
} finally {
|
||||||
setIsRenaming(false);
|
setIsRenaming(false);
|
||||||
}
|
}
|
||||||
@@ -162,9 +186,9 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Profile name..."
|
placeholder="Profile name..."
|
||||||
value={newName}
|
value={newName}
|
||||||
onChange={e => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
onKeyDown={e => e.key === 'Escape' && setIsSaveOpen(false)}
|
onKeyDown={(e) => e.key === "Escape" && closeSaveForm()}
|
||||||
className="flex-1 min-w-0 bg-zinc-950 border border-zinc-700 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 disabled:opacity-50"
|
className="flex-1 min-w-0 bg-zinc-950 border border-zinc-700 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -176,7 +200,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsSaveOpen(false)}
|
onClick={closeSaveForm}
|
||||||
className="px-3 py-1.5 text-zinc-400 hover:text-zinc-200 rounded text-sm transition shrink-0"
|
className="px-3 py-1.5 text-zinc-400 hover:text-zinc-200 rounded text-sm transition shrink-0"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -201,13 +225,17 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-0.5 px-2">
|
<div className="flex flex-col gap-0.5 px-2">
|
||||||
{profiles.map(p => {
|
{profiles.map((p) => {
|
||||||
const pts = Object.keys(p.curve_deltas).length;
|
const pts = Object.keys(p.curve_deltas).length;
|
||||||
const badges = [
|
const badges = [
|
||||||
pts > 0 ? `${pts} pts` : null,
|
pts > 0 ? `${pts} pts` : null,
|
||||||
p.power_limit_w != null ? `${p.power_limit_w}W` : null,
|
p.power_limit_w != null ? `${p.power_limit_w}W` : null,
|
||||||
p.mem_offset_mhz != null ? `${p.mem_offset_mhz > 0 ? '+' : ''}${p.mem_offset_mhz} MHz mem` : null,
|
p.mem_offset_mhz != null
|
||||||
].filter(Boolean).join(' · ');
|
? `${p.mem_offset_mhz > 0 ? "+" : ""}${p.mem_offset_mhz} MHz mem`
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
|
||||||
const isActive = activeProfile === p.name;
|
const isActive = activeProfile === p.name;
|
||||||
const isAutoLoad = autoLoadProfile === p.name;
|
const isAutoLoad = autoLoadProfile === p.name;
|
||||||
@@ -218,11 +246,13 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={p.name}
|
key={p.name}
|
||||||
className={`group rounded-md transition ${isActive ? 'bg-zinc-800/60' : 'hover:bg-zinc-800/50'}`}
|
className={`group rounded-md transition ${isActive ? "bg-zinc-800/60" : "hover:bg-zinc-800/50"}`}
|
||||||
>
|
>
|
||||||
{isConfirmingDelete ? (
|
{isConfirmingDelete ? (
|
||||||
<div className="flex items-center justify-between px-3 py-2 gap-2">
|
<div className="flex items-center justify-between px-3 py-2 gap-2">
|
||||||
<span className="text-sm text-zinc-300 truncate min-w-0">Delete "{p.name}"?</span>
|
<span className="text-sm text-zinc-300 truncate min-w-0">
|
||||||
|
Delete "{p.name}"?
|
||||||
|
</span>
|
||||||
<div className="flex gap-1.5 shrink-0">
|
<div className="flex gap-1.5 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteConfirm(p.name)}
|
onClick={() => handleDeleteConfirm(p.name)}
|
||||||
@@ -240,14 +270,19 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : isRenaming_ ? (
|
) : isRenaming_ ? (
|
||||||
<form onSubmit={e => handleRename(e, p.name)} className="flex items-center gap-2 px-3 py-2">
|
<form
|
||||||
|
onSubmit={(e) => handleRename(e, p.name)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
ref={renameInputRef}
|
ref={renameInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={renameValue}
|
value={renameValue}
|
||||||
onChange={e => setRenameValue(e.target.value)}
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
disabled={isRenaming}
|
disabled={isRenaming}
|
||||||
onKeyDown={e => e.key === 'Escape' && setRenamingName(null)}
|
onKeyDown={(e) =>
|
||||||
|
e.key === "Escape" && setRenamingName(null)
|
||||||
|
}
|
||||||
className="flex-1 min-w-0 bg-zinc-950 border border-zinc-600 rounded px-2 py-1 text-sm focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 disabled:opacity-50"
|
className="flex-1 min-w-0 bg-zinc-950 border border-zinc-600 rounded px-2 py-1 text-sm focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -268,25 +303,48 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between px-3 py-2">
|
<div className="flex items-center justify-between px-3 py-2">
|
||||||
<div className="flex items-center gap-2 min-w-0 pr-2">
|
<div className="flex items-center gap-2 min-w-0 pr-2">
|
||||||
{isActive
|
{isActive ? (
|
||||||
? <Check size={13} className="text-emerald-400 shrink-0" />
|
<Check
|
||||||
: <span className="w-[13px] shrink-0" />
|
size={13}
|
||||||
}
|
className="text-emerald-400 shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="w-[13px] shrink-0" />
|
||||||
|
)}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<p className={`text-sm font-medium truncate ${isActive ? 'text-zinc-100' : 'text-zinc-300'}`}>
|
<p
|
||||||
|
className={`text-sm font-medium truncate ${isActive ? "text-zinc-100" : "text-zinc-300"}`}
|
||||||
|
>
|
||||||
{p.name}
|
{p.name}
|
||||||
</p>
|
</p>
|
||||||
{isAutoLoad && (
|
{isAutoLoad && (
|
||||||
<Star size={11} className="text-sky-400 shrink-0" fill="currentColor" />
|
<Star
|
||||||
|
size={11}
|
||||||
|
className="text-sky-400 shrink-0"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{p.fan_curve && p.fan_curve.length > 0 && (
|
||||||
|
<span
|
||||||
|
className="shrink-0 text-cyan-400"
|
||||||
|
title="Custom fan curve saved"
|
||||||
|
>
|
||||||
|
<Fan size={11} />
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{gpuInfo && p.gpu_name !== gpuInfo.name && (
|
{gpuInfo && p.gpu_name !== gpuInfo.name && (
|
||||||
<span className="text-xs text-zinc-600 truncate shrink-0" title={`Saved from ${p.gpu_name}`}>
|
<span
|
||||||
|
className="text-xs text-zinc-600 truncate shrink-0"
|
||||||
|
title={`Saved from ${p.gpu_name}`}
|
||||||
|
>
|
||||||
{p.gpu_name}
|
{p.gpu_name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{badges && <p className="text-xs text-zinc-500">{badges}</p>}
|
{badges && (
|
||||||
|
<p className="text-xs text-zinc-500">{badges}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -296,21 +354,34 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
disabled={isApplying}
|
disabled={isApplying}
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs rounded text-zinc-400 hover:text-emerald-400 hover:bg-zinc-700 transition disabled:opacity-50 font-medium"
|
className="flex items-center gap-1 px-2 py-1 text-xs rounded text-zinc-400 hover:text-emerald-400 hover:bg-zinc-700 transition disabled:opacity-50 font-medium"
|
||||||
>
|
>
|
||||||
{isApplying
|
{isApplying ? (
|
||||||
? <span className="w-3 h-3 border border-zinc-500 border-t-emerald-400 rounded-full animate-spin" />
|
<span className="w-3 h-3 border border-zinc-500 border-t-emerald-400 rounded-full animate-spin" />
|
||||||
: <ChevronRight size={13} />
|
) : (
|
||||||
}
|
<ChevronRight size={13} />
|
||||||
|
)}
|
||||||
Apply
|
Apply
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSetAutoLoad(isAutoLoad ? null : p.name)}
|
onClick={() =>
|
||||||
className={`p-1.5 rounded transition hover:bg-zinc-700 ${isAutoLoad ? 'text-sky-400 hover:text-sky-300' : 'text-zinc-600 hover:text-sky-400'}`}
|
handleSetAutoLoad(isAutoLoad ? null : p.name)
|
||||||
title={isAutoLoad ? 'Clear default profile' : 'Set as default profile'}
|
}
|
||||||
|
className={`p-1.5 rounded transition hover:bg-zinc-700 ${isAutoLoad ? "text-sky-400 hover:text-sky-300" : "text-zinc-600 hover:text-sky-400"}`}
|
||||||
|
title={
|
||||||
|
isAutoLoad
|
||||||
|
? "Clear default profile"
|
||||||
|
: "Set as default profile"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Star size={13} fill={isAutoLoad ? 'currentColor' : 'none'} />
|
<Star
|
||||||
|
size={13}
|
||||||
|
fill={isAutoLoad ? "currentColor" : "none"}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setRenamingName(p.name); setRenameValue(p.name); }}
|
onClick={() => {
|
||||||
|
setRenamingName(p.name);
|
||||||
|
setRenameValue(p.name);
|
||||||
|
}}
|
||||||
className="p-1.5 text-zinc-600 hover:text-zinc-300 hover:bg-zinc-700 rounded transition"
|
className="p-1.5 text-zinc-600 hover:text-zinc-300 hover:bg-zinc-700 rounded transition"
|
||||||
title="Rename"
|
title="Rename"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,16 +10,17 @@ interface Props {
|
|||||||
export function ConfirmDialog({
|
export function ConfirmDialog({
|
||||||
message,
|
message,
|
||||||
detail,
|
detail,
|
||||||
confirmLabel = 'Confirm',
|
confirmLabel = "Confirm",
|
||||||
isDestructive = false,
|
isDestructive = false,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
|
||||||
style={{ background: 'rgba(0,0,0,0.7)' }}
|
onMouseDown={(e) => {
|
||||||
onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
if (e.target === e.currentTarget) onCancel();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 w-96 max-w-[90vw]">
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 w-96 max-w-[90vw]">
|
||||||
<h2 className="text-zinc-100 font-semibold text-sm mb-1">{message}</h2>
|
<h2 className="text-zinc-100 font-semibold text-sm mb-1">{message}</h2>
|
||||||
@@ -35,11 +36,11 @@ export function ConfirmDialog({
|
|||||||
<button
|
<button
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
className={[
|
className={[
|
||||||
'px-3 py-1.5 rounded text-xs font-semibold transition-colors',
|
"px-3 py-1.5 rounded text-xs font-semibold transition-colors",
|
||||||
isDestructive
|
isDestructive
|
||||||
? 'bg-red-600 hover:bg-red-500 text-white'
|
? "bg-red-600 hover:bg-red-500 text-white"
|
||||||
: 'bg-emerald-600 hover:bg-emerald-500 text-white',
|
: "bg-emerald-600 hover:bg-emerald-500 text-white",
|
||||||
].join(' ')}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { api } from '../api/client';
|
import { api } from "../api/client.js";
|
||||||
import { createWsConnection } from '../api/websocket';
|
import { createWsConnection } from "../api/websocket.js";
|
||||||
import { useCurveStore } from '../store/curveStore';
|
import { useCurveStore } from "../store/curveStore.js";
|
||||||
import type { CurveState } from '../types';
|
import type { CurveState } from "../types.js";
|
||||||
|
|
||||||
export function useCurve() {
|
export function useCurve() {
|
||||||
const { curve, setCurve, selectedGpuIndex } = useCurveStore();
|
const { curve, setCurve, selectedGpuIndex } = useCurveStore();
|
||||||
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
|
const [wsStatus, setWsStatus] = useState<
|
||||||
|
"connecting" | "connected" | "disconnected"
|
||||||
|
>("connecting");
|
||||||
const wsRef = useRef<ReturnType<typeof createWsConnection> | null>(null);
|
const wsRef = useRef<ReturnType<typeof createWsConnection> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -15,7 +17,7 @@ export function useCurve() {
|
|||||||
|
|
||||||
// Subscribe to /ws/curve for push updates after writes
|
// Subscribe to /ws/curve for push updates after writes
|
||||||
wsRef.current = createWsConnection<CurveState>(
|
wsRef.current = createWsConnection<CurveState>(
|
||||||
'/ws/curve',
|
"/ws/curve",
|
||||||
(data) => setCurve(data),
|
(data) => setCurve(data),
|
||||||
setWsStatus,
|
setWsStatus,
|
||||||
selectedGpuIndex,
|
selectedGpuIndex,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client.js";
|
||||||
import { useCurveStore } from "../store/curveStore";
|
import { useCurveStore } from "../store/curveStore.js";
|
||||||
import type { DashboardInfo } from "../types";
|
import type { DashboardInfo } from "../types.js";
|
||||||
|
|
||||||
interface DashboardState {
|
interface DashboardState {
|
||||||
gpuIndex: number;
|
gpuIndex: number;
|
||||||
@@ -23,17 +23,17 @@ export function useDashboard() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
api
|
(async () => {
|
||||||
.dashboard(selectedGpuIndex)
|
try {
|
||||||
.then((data) => {
|
const data = await api.dashboard(selectedGpuIndex);
|
||||||
if (!cancelled)
|
if (!cancelled)
|
||||||
setState({ gpuIndex: selectedGpuIndex, data, done: true });
|
setState({ gpuIndex: selectedGpuIndex, data, done: true });
|
||||||
})
|
} catch (err) {
|
||||||
.catch((err) => {
|
|
||||||
console.error("Failed to load dashboard info:", err);
|
console.error("Failed to load dashboard info:", err);
|
||||||
if (!cancelled)
|
if (!cancelled)
|
||||||
setState({ gpuIndex: selectedGpuIndex, data: null, done: true });
|
setState({ gpuIndex: selectedGpuIndex, data: null, done: true });
|
||||||
});
|
}
|
||||||
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from "react";
|
||||||
import { api } from '../api/client';
|
import { api } from "../api/client.js";
|
||||||
import { useCurveStore } from '../store/curveStore';
|
import { useCurveStore } from "../store/curveStore.js";
|
||||||
|
|
||||||
export function useGpu() {
|
export function useGpu() {
|
||||||
const { gpuInfo, setGpuInfo, setAvailableGpus, selectedGpuIndex } = useCurveStore();
|
const { gpuInfo, setGpuInfo, setAvailableGpus, selectedGpuIndex } =
|
||||||
|
useCurveStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.gpus().then(setAvailableGpus).catch(console.error);
|
api.gpus().then(setAvailableGpus).catch(console.error);
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { createWsConnection } from '../api/websocket';
|
import { createWsConnection } from "../api/websocket.js";
|
||||||
import { useCurveStore } from '../store/curveStore';
|
import { useCurveStore } from "../store/curveStore.js";
|
||||||
import type { MonitoringSample } from '../types';
|
import type { MonitoringSample } from "../types.js";
|
||||||
|
|
||||||
export function useMonitor() {
|
export function useMonitor() {
|
||||||
const { monitor, monitorHistory, pushMonitor, selectedGpuIndex } = useCurveStore();
|
const { monitor, monitorHistory, pushMonitor, selectedGpuIndex } =
|
||||||
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
|
useCurveStore();
|
||||||
|
const [wsStatus, setWsStatus] = useState<
|
||||||
|
"connecting" | "connected" | "disconnected"
|
||||||
|
>("connecting");
|
||||||
const wsRef = useRef<ReturnType<typeof createWsConnection> | null>(null);
|
const wsRef = useRef<ReturnType<typeof createWsConnection> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
wsRef.current = createWsConnection<MonitoringSample>(
|
wsRef.current = createWsConnection<MonitoringSample>(
|
||||||
'/ws/monitor',
|
"/ws/monitor",
|
||||||
pushMonitor,
|
pushMonitor,
|
||||||
setWsStatus,
|
setWsStatus,
|
||||||
selectedGpuIndex,
|
selectedGpuIndex,
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from "zustand";
|
||||||
import type { CurveState, GpuInfo, MonitoringSample, VFPoint } from '../types';
|
import type {
|
||||||
import { api } from '../api/client';
|
CurveState,
|
||||||
import { toast } from 'sonner';
|
GpuInfo,
|
||||||
|
MonitoringSample,
|
||||||
|
VFPoint,
|
||||||
|
} from "../types.js";
|
||||||
|
import { api } from "../api/client.js";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const HISTORY_SIZE = 120; // ~60s at 2Hz
|
const HISTORY_SIZE = 120; // ~60s at 2Hz
|
||||||
|
|
||||||
@@ -50,7 +55,9 @@ interface CurveStore {
|
|||||||
resetAllDeltas: (onSuccess: () => void) => Promise<void>;
|
resetAllDeltas: (onSuccess: () => void) => Promise<void>;
|
||||||
|
|
||||||
// Selection actions
|
// Selection actions
|
||||||
selectPoint: (index: number, multi?: boolean) => void;
|
selectPoint: (index: number) => void;
|
||||||
|
/** Toggle a point in the selection (Shift/Ctrl+click); updates the anchor. */
|
||||||
|
togglePoint: (index: number) => void;
|
||||||
selectRange: (indices: number[]) => void;
|
selectRange: (indices: number[]) => void;
|
||||||
clearSelection: () => void;
|
clearSelection: () => void;
|
||||||
/**
|
/**
|
||||||
@@ -80,7 +87,16 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
|
|||||||
|
|
||||||
setAvailableGpus: (availableGpus) => set({ availableGpus }),
|
setAvailableGpus: (availableGpus) => set({ availableGpus }),
|
||||||
setSelectedGpuIndex: (selectedGpuIndex) => {
|
setSelectedGpuIndex: (selectedGpuIndex) => {
|
||||||
set({ selectedGpuIndex, curve: null, gpuInfo: null, monitor: null, monitorHistory: [], pendingDeltas: new Map(), selectedPoints: new Set(), anchorPoint: null });
|
set({
|
||||||
|
selectedGpuIndex,
|
||||||
|
curve: null,
|
||||||
|
gpuInfo: null,
|
||||||
|
monitor: null,
|
||||||
|
monitorHistory: [],
|
||||||
|
pendingDeltas: new Map(),
|
||||||
|
selectedPoints: new Set(),
|
||||||
|
anchorPoint: null,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
setCurve: (curve) => set({ curve }),
|
setCurve: (curve) => set({ curve }),
|
||||||
setGpuInfo: (gpuInfo) => set({ gpuInfo }),
|
setGpuInfo: (gpuInfo) => set({ gpuInfo }),
|
||||||
@@ -95,7 +111,7 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
|
|||||||
stageEdit: (pointIndex, deltaKhz) =>
|
stageEdit: (pointIndex, deltaKhz) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const next = new Map(s.pendingDeltas);
|
const next = new Map(s.pendingDeltas);
|
||||||
const point = s.curve?.points.find(p => p.index === pointIndex);
|
const point = s.curve?.points.find((p) => p.index === pointIndex);
|
||||||
if (point && point.delta_khz === deltaKhz) {
|
if (point && point.delta_khz === deltaKhz) {
|
||||||
next.delete(pointIndex);
|
next.delete(pointIndex);
|
||||||
} else {
|
} else {
|
||||||
@@ -108,7 +124,7 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
|
|||||||
set((s) => {
|
set((s) => {
|
||||||
const next = new Map(s.pendingDeltas);
|
const next = new Map(s.pendingDeltas);
|
||||||
edits.forEach((deltaKhz, index) => {
|
edits.forEach((deltaKhz, index) => {
|
||||||
const point = s.curve?.points.find(p => p.index === index);
|
const point = s.curve?.points.find((p) => p.index === index);
|
||||||
if (point && point.delta_khz === deltaKhz) {
|
if (point && point.delta_khz === deltaKhz) {
|
||||||
next.delete(index);
|
next.delete(index);
|
||||||
} else {
|
} else {
|
||||||
@@ -128,27 +144,39 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
discardEdits: () =>
|
discardEdits: () =>
|
||||||
set({ pendingDeltas: new Map(), selectedPoints: new Set(), anchorPoint: null }),
|
set({
|
||||||
|
pendingDeltas: new Map(),
|
||||||
|
selectedPoints: new Set(),
|
||||||
|
anchorPoint: null,
|
||||||
|
}),
|
||||||
|
|
||||||
applyEdits: async (onSuccess) => {
|
applyEdits: async (onSuccess) => {
|
||||||
const { pendingDeltas, selectedGpuIndex } = get();
|
const { pendingDeltas, selectedGpuIndex } = get();
|
||||||
if (pendingDeltas.size === 0) return;
|
if (pendingDeltas.size === 0) return;
|
||||||
|
|
||||||
// Convert Map to plain record for the API
|
// Convert Map to plain record for the API
|
||||||
const deltas: Record<number, number> = {};
|
const deltas: Record<number, number> = Object.fromEntries(pendingDeltas);
|
||||||
pendingDeltas.forEach((v, k) => { deltas[k] = v; });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.writeDeltas(deltas, selectedGpuIndex);
|
const result = await api.writeDeltas(deltas, selectedGpuIndex);
|
||||||
set({ pendingDeltas: new Map(), selectedPoints: new Set(), activeProfile: null });
|
set({
|
||||||
|
pendingDeltas: new Map(),
|
||||||
|
selectedPoints: new Set(),
|
||||||
|
activeProfile: null,
|
||||||
|
});
|
||||||
if (result?.freq_warnings?.length) {
|
if (result?.freq_warnings?.length) {
|
||||||
toast.warning('Curve applied — driver clamped some points to 0 MHz (negative freq delta)');
|
toast.warning(
|
||||||
|
"Curve applied — driver clamped some points to 0 MHz (negative freq delta)",
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.success('Curve applied successfully');
|
toast.success("Curve applied successfully");
|
||||||
}
|
}
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
toast.error('Failed to apply curve: ' + (e.message || String(e)));
|
toast.error(
|
||||||
|
"Failed to apply curve: " +
|
||||||
|
(e instanceof Error ? e.message : String(e)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -156,35 +184,48 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
|
|||||||
const { selectedGpuIndex } = get();
|
const { selectedGpuIndex } = get();
|
||||||
try {
|
try {
|
||||||
await api.resetCurve(selectedGpuIndex);
|
await api.resetCurve(selectedGpuIndex);
|
||||||
set({ pendingDeltas: new Map(), selectedPoints: new Set(), activeProfile: null });
|
set({
|
||||||
toast.success('Curve reset to hardware defaults');
|
pendingDeltas: new Map(),
|
||||||
|
selectedPoints: new Set(),
|
||||||
|
activeProfile: null,
|
||||||
|
});
|
||||||
|
toast.success("Curve reset to hardware defaults");
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
toast.error('Failed to reset curve: ' + (e.message || String(e)));
|
toast.error(
|
||||||
|
"Failed to reset curve: " +
|
||||||
|
(e instanceof Error ? e.message : String(e)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
selectPoint: (index, multi = false) =>
|
selectPoint: (index) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const next = new Set(s.selectedPoints);
|
const next = new Set(s.selectedPoints);
|
||||||
let anchor = s.anchorPoint;
|
let anchor: number | null = index;
|
||||||
if (multi) {
|
|
||||||
if (next.has(index)) {
|
|
||||||
next.delete(index);
|
|
||||||
if (anchor === index) anchor = next.size > 0 ? [...next].at(-1)! : null;
|
|
||||||
} else {
|
|
||||||
next.add(index);
|
|
||||||
anchor = index; // last explicitly added point is the new anchor
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (next.size === 1 && next.has(index)) {
|
if (next.size === 1 && next.has(index)) {
|
||||||
next.clear();
|
next.clear();
|
||||||
anchor = null;
|
anchor = null;
|
||||||
} else {
|
} else {
|
||||||
next.clear();
|
next.clear();
|
||||||
next.add(index);
|
next.add(index);
|
||||||
anchor = index;
|
|
||||||
}
|
}
|
||||||
|
return { selectedPoints: next, anchorPoint: anchor };
|
||||||
|
}),
|
||||||
|
|
||||||
|
togglePoint: (index) =>
|
||||||
|
set((s) => {
|
||||||
|
const next = new Set(s.selectedPoints);
|
||||||
|
let anchor = s.anchorPoint;
|
||||||
|
if (next.has(index)) {
|
||||||
|
next.delete(index);
|
||||||
|
if (anchor === index) {
|
||||||
|
const last = [...next].at(-1);
|
||||||
|
anchor = last ?? null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next.add(index);
|
||||||
|
anchor = index; // last explicitly added point is the new anchor
|
||||||
}
|
}
|
||||||
return { selectedPoints: next, anchorPoint: anchor };
|
return { selectedPoints: next, anchorPoint: anchor };
|
||||||
}),
|
}),
|
||||||
@@ -193,29 +234,38 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
|
|||||||
// Bulk selects don't change the anchor — preserve it if still in the new selection.
|
// Bulk selects don't change the anchor — preserve it if still in the new selection.
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const next = new Set(indices);
|
const next = new Set(indices);
|
||||||
const anchor = s.anchorPoint !== null && next.has(s.anchorPoint) ? s.anchorPoint : null;
|
const anchor =
|
||||||
|
s.anchorPoint !== null && next.has(s.anchorPoint)
|
||||||
|
? s.anchorPoint
|
||||||
|
: null;
|
||||||
return { selectedPoints: next, anchorPoint: anchor };
|
return { selectedPoints: next, anchorPoint: anchor };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
clearSelection: () =>
|
clearSelection: () => set({ selectedPoints: new Set(), anchorPoint: null }),
|
||||||
set({ selectedPoints: new Set(), anchorPoint: null }),
|
|
||||||
|
|
||||||
flattenToAnchor: () => {
|
flattenToAnchor: () => {
|
||||||
const { selectedPoints, anchorPoint, pendingDeltas, curve, stageMultiEdit } = get();
|
const {
|
||||||
|
selectedPoints,
|
||||||
|
anchorPoint,
|
||||||
|
pendingDeltas,
|
||||||
|
curve,
|
||||||
|
stageMultiEdit,
|
||||||
|
} = get();
|
||||||
if (selectedPoints.size < 2 || !curve) return;
|
if (selectedPoints.size < 2 || !curve) return;
|
||||||
|
|
||||||
const anchor = anchorPoint !== null && selectedPoints.has(anchorPoint)
|
const anchor =
|
||||||
|
anchorPoint !== null && selectedPoints.has(anchorPoint)
|
||||||
? anchorPoint
|
? anchorPoint
|
||||||
: Math.min(...selectedPoints);
|
: Math.min(...selectedPoints);
|
||||||
|
|
||||||
const anchorPt = curve.points.find(p => p.index === anchor);
|
const anchorPt = curve.points.find((p) => p.index === anchor);
|
||||||
if (!anchorPt) return;
|
if (!anchorPt) return;
|
||||||
const anchorPendingDelta = pendingDeltas.get(anchor) ?? anchorPt.delta_khz;
|
const anchorPendingDelta = pendingDeltas.get(anchor) ?? anchorPt.delta_khz;
|
||||||
const anchorEffectiveKhz = anchorPt.freq_khz + anchorPendingDelta;
|
const anchorEffectiveKhz = anchorPt.freq_khz + anchorPendingDelta;
|
||||||
|
|
||||||
const edits = new Map<number, number>();
|
const edits = new Map<number, number>();
|
||||||
for (const idx of selectedPoints) {
|
for (const idx of selectedPoints) {
|
||||||
const pt = curve.points.find(p => p.index === idx);
|
const pt = curve.points.find((p) => p.index === idx);
|
||||||
if (!pt) continue;
|
if (!pt) continue;
|
||||||
edits.set(idx, anchorEffectiveKhz - pt.freq_khz);
|
edits.set(idx, anchorEffectiveKhz - pt.freq_khz);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface MonitoringSample {
|
|||||||
temp_c: number | null;
|
temp_c: number | null;
|
||||||
power_w: number | null;
|
power_w: number | null;
|
||||||
fan_pct: number | null;
|
fan_pct: number | null;
|
||||||
|
fans: (number | null)[] | null;
|
||||||
pstate: number | null;
|
pstate: number | null;
|
||||||
pstate_label: string | null;
|
pstate_label: string | null;
|
||||||
mem_used_bytes: number | null;
|
mem_used_bytes: number | null;
|
||||||
@@ -111,13 +112,22 @@ export interface FanPoint {
|
|||||||
fan_pct: number;
|
fan_pct: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FanInfo {
|
||||||
|
index: number;
|
||||||
|
fan_pct: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FanState {
|
export interface FanState {
|
||||||
fan_pct: number | null;
|
fan_pct: number | null;
|
||||||
|
fans: FanInfo[] | null;
|
||||||
|
num_fans: number | null;
|
||||||
fan_mode: "auto" | "curve" | null;
|
fan_mode: "auto" | "curve" | null;
|
||||||
min_fan_pct: number | null;
|
min_fan_pct: number | null;
|
||||||
max_fan_pct: number | null;
|
max_fan_pct: number | null;
|
||||||
curve: FanPoint[] | null;
|
curve: FanPoint[] | null;
|
||||||
curve_active: boolean;
|
curve_active: boolean;
|
||||||
|
// Fan indices controlled by the active curve; null = all fans.
|
||||||
|
fan_targets: number[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileData {
|
export interface ProfileData {
|
||||||
@@ -127,4 +137,5 @@ export interface ProfileData {
|
|||||||
mem_offset_mhz: number | null;
|
mem_offset_mhz: number | null;
|
||||||
power_limit_w: number | null;
|
power_limit_w: number | null;
|
||||||
fan_curve: FanPoint[] | null;
|
fan_curve: FanPoint[] | null;
|
||||||
|
fan_targets: number[] | null;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { VFPoint } from '../types';
|
import type { VFPoint } from '../types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Approximate reference frequency (MHz) for a point: effective − delta.
|
* Approximate reference frequency (MHz) for a point: effective − delta.
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Custom hatchling build hook: build the React frontend if it is missing or stale.
|
||||||
|
|
||||||
|
This makes NVCurve installable with a single command, e.g.::
|
||||||
|
|
||||||
|
uv tool install "git+https://gitea.zephyre.one/Pakobbix/nvcurve.git"
|
||||||
|
|
||||||
|
The hook runs inside the isolated build environment right before the wheel
|
||||||
|
(or sdist) is assembled. If ``frontend/dist`` does not exist yet — or is older
|
||||||
|
than the frontend sources — it compiles the frontend using the host's ``npm``
|
||||||
|
(PATH is inherited from the environment).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||||
|
|
||||||
|
# Frontend inputs that must be newer than dist/index.html to trigger a rebuild.
|
||||||
|
_FRONTEND_INPUTS = ("src", "index.html", "vite.config.ts", "package.json", "tsconfig.json")
|
||||||
|
|
||||||
|
|
||||||
|
class FrontendBuildHook(BuildHookInterface):
|
||||||
|
"""Build ``frontend/dist`` with npm when it is missing or stale."""
|
||||||
|
|
||||||
|
PLUGIN_NAME = "custom"
|
||||||
|
|
||||||
|
def initialize(self, version: str, build_data: dict) -> None:
|
||||||
|
frontend = os.path.join(self.root, "frontend")
|
||||||
|
dist_index = os.path.join(frontend, "dist", "index.html")
|
||||||
|
|
||||||
|
if not self._needs_build(frontend, dist_index):
|
||||||
|
return
|
||||||
|
|
||||||
|
npm = shutil.which("npm")
|
||||||
|
if npm is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"npm not found on PATH. Node.js 18+ and npm are required to build "
|
||||||
|
"the NVCurve frontend. Install them and retry, or use install.sh "
|
||||||
|
"which checks prerequisites for you."
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"[nvcurve] frontend/dist missing or stale — building frontend with npm ...",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
subprocess.run([npm, "ci", "--no-audit", "--no-fund"], cwd=frontend, check=True)
|
||||||
|
subprocess.run([npm, "run", "build"], cwd=frontend, check=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _needs_build(frontend: str, dist_index: str) -> bool:
|
||||||
|
if not os.path.isfile(dist_index):
|
||||||
|
return True
|
||||||
|
dist_mtime = os.path.getmtime(dist_index)
|
||||||
|
for name in _FRONTEND_INPUTS:
|
||||||
|
path = os.path.join(frontend, name)
|
||||||
|
if os.path.isfile(path):
|
||||||
|
if os.path.getmtime(path) > dist_mtime:
|
||||||
|
return True
|
||||||
|
elif os.path.isdir(path):
|
||||||
|
for root, _dirs, files in os.walk(path):
|
||||||
|
for file in files:
|
||||||
|
if os.path.getmtime(os.path.join(root, file)) > dist_mtime:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
Executable
+62
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# NVCurve single-command installer.
|
||||||
|
#
|
||||||
|
# curl -fsSL https://gitea.zephyre.one/Pakobbix/nvcurve/raw/branch/main/install.sh | bash
|
||||||
|
#
|
||||||
|
# or from a local clone:
|
||||||
|
#
|
||||||
|
# git clone https://gitea.zephyre.one/Pakobbix/nvcurve.git && cd nvcurve && ./install.sh
|
||||||
|
#
|
||||||
|
# The React frontend is compiled automatically during the Python build
|
||||||
|
# (see hatch_build.py), so Node.js 18+ and npm must be available.
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# NVCURVE_BRANCH branch to install (default: main)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_URL="https://gitea.zephyre.one/Pakobbix/nvcurve.git"
|
||||||
|
BRANCH="${NVCURVE_BRANCH:-main}"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "error: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- prerequisites -----------------------------------------------------------
|
||||||
|
command -v git >/dev/null 2>&1 \
|
||||||
|
|| fail "git is required. Install it first."
|
||||||
|
command -v node >/dev/null 2>&1 \
|
||||||
|
|| fail "Node.js 18+ is required (e.g. 'sudo pacman -S nodejs npm' or 'sudo apt install nodejs npm')."
|
||||||
|
command -v npm >/dev/null 2>&1 \
|
||||||
|
|| fail "npm is required (usually installed together with Node.js)."
|
||||||
|
|
||||||
|
if ! command -v uv >/dev/null 2>&1; then
|
||||||
|
echo "uv not found — installing it from https://astral.sh/uv ..."
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
command -v uv >/dev/null 2>&1 \
|
||||||
|
|| fail "uv installation failed. Install uv manually: https://docs.astral.sh/uv/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- locate the source tree ---------------------------------------------------
|
||||||
|
if [ -f pyproject.toml ] && [ -d frontend ]; then
|
||||||
|
src="$(pwd)"
|
||||||
|
echo "Installing from current directory: $src"
|
||||||
|
else
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
echo "Cloning $REPO_URL (branch: $BRANCH) ..."
|
||||||
|
git clone --quiet --depth 1 --branch "$BRANCH" "$REPO_URL" "$tmp/nvcurve"
|
||||||
|
src="$tmp/nvcurve"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- install -------------------------------------------------------------------
|
||||||
|
# The frontend is built automatically by the build hook (hatch_build.py).
|
||||||
|
uv tool install --force "$src"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "NVCurve installed."
|
||||||
|
echo " Verify your GPU: nvcurve setup"
|
||||||
|
echo " Start the web UI: nvcurve"
|
||||||
+252
-48
@@ -31,13 +31,14 @@ First-time / diagnostic commands (bypass server, escalate to root):
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from .client import ApiError, NvCurveClient, ServerNotRunning
|
from .client import ApiError, NvCurveClient, ServerNotRunning
|
||||||
from .config import Config, default_config
|
from .config import Config, default_config, normalize_trusted_proxies, tls_enabled
|
||||||
from .nvapi.constants import (
|
from .nvapi.constants import (
|
||||||
CT_BASE,
|
CT_BASE,
|
||||||
CT_POINTS,
|
CT_POINTS,
|
||||||
@@ -48,6 +49,8 @@ from .nvapi.constants import (
|
|||||||
VFP_STRIDE,
|
VFP_STRIDE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
log = logging.getLogger("nvcurve.cli")
|
||||||
|
|
||||||
# ── Utilities ─────────────────────────────────────────────────────────────────
|
# ── Utilities ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -69,8 +72,8 @@ def parse_range(s: str):
|
|||||||
raise argparse.ArgumentTypeError(f"Expected A-B format, got '{s}'")
|
raise argparse.ArgumentTypeError(f"Expected A-B format, got '{s}'")
|
||||||
try:
|
try:
|
||||||
a, b = int(parts[0]), int(parts[1])
|
a, b = int(parts[0]), int(parts[1])
|
||||||
except ValueError:
|
except ValueError as exc:
|
||||||
raise argparse.ArgumentTypeError(f"Non-integer in range: '{s}'")
|
raise argparse.ArgumentTypeError(f"Non-integer in range: '{s}'") from exc
|
||||||
if a > b:
|
if a > b:
|
||||||
raise argparse.ArgumentTypeError(f"Start > end in range: {a}-{b}")
|
raise argparse.ArgumentTypeError(f"Start > end in range: {a}-{b}")
|
||||||
if a < 0 or b >= CT_POINTS:
|
if a < 0 or b >= CT_POINTS:
|
||||||
@@ -98,7 +101,7 @@ def print_curve(points, offsets, voltage, domains=None, full=False):
|
|||||||
|
|
||||||
current_idx = None
|
current_idx = None
|
||||||
if voltage:
|
if voltage:
|
||||||
for i, (f, v) in enumerate(points):
|
for i, (_f, v) in enumerate(points):
|
||||||
if v > 0 and abs(v - voltage) < 10000:
|
if v > 0 and abs(v - voltage) < 10000:
|
||||||
current_idx = i
|
current_idx = i
|
||||||
break
|
break
|
||||||
@@ -214,7 +217,7 @@ def print_curve(points, offsets, voltage, domains=None, full=False):
|
|||||||
if offsets:
|
if offsets:
|
||||||
nonzero = sum(1 for o in offsets if o != 0)
|
nonzero = sum(1 for o in offsets if o != 0)
|
||||||
if nonzero > 0:
|
if nonzero > 0:
|
||||||
vals = set(o for o in offsets if o != 0)
|
vals = {o for o in offsets if o != 0}
|
||||||
if len(vals) == 1:
|
if len(vals) == 1:
|
||||||
print(
|
print(
|
||||||
f"Global offset: {next(iter(vals)) / 1000:+.0f} MHz "
|
f"Global offset: {next(iter(vals)) / 1000:+.0f} MHz "
|
||||||
@@ -316,7 +319,7 @@ def run_diagnostics(gpu, gpu_name, gpu_index: int = 0):
|
|||||||
("SetClockBoostTable", FUNC["SetClockBoostTable"], CT_SIZE, 1, True),
|
("SetClockBoostTable", FUNC["SetClockBoostTable"], CT_SIZE, 1, True),
|
||||||
]
|
]
|
||||||
|
|
||||||
for name, fid, size, ver, needs_mask in probes:
|
for name, fid, size, ver, _needs_mask in probes:
|
||||||
ptr = query_interface(fid)
|
ptr = query_interface(fid)
|
||||||
resolved = "resolved" if ptr else "NOT FOUND"
|
resolved = "resolved" if ptr else "NOT FOUND"
|
||||||
print(f" {name:30s} 0x{fid:08X} size=0x{size:04X} ver={ver} {resolved}")
|
print(f" {name:30s} 0x{fid:08X} size=0x{size:04X} ver={ver} {resolved}")
|
||||||
@@ -420,8 +423,8 @@ def _open_browser_as_user(url: str) -> None:
|
|||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
log.debug("runuser xdg-open failed, falling back to webbrowser: %s", exc)
|
||||||
import webbrowser
|
import webbrowser
|
||||||
|
|
||||||
webbrowser.open(url)
|
webbrowser.open(url)
|
||||||
@@ -445,7 +448,7 @@ def require_root():
|
|||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
# PYTHONDONTWRITEBYTECODE prevents root-owned __pycache__ in site-packages.
|
# PYTHONDONTWRITEBYTECODE prevents root-owned __pycache__ in site-packages.
|
||||||
os.execvp(
|
os.execvp( # noqa: S606 — intentional re-exec via sudo
|
||||||
"sudo",
|
"sudo",
|
||||||
[
|
[
|
||||||
"sudo",
|
"sudo",
|
||||||
@@ -469,6 +472,21 @@ _PERSISTENT_CONFIG_FILE = (
|
|||||||
)
|
)
|
||||||
_DAEMON_SOCKET_PATH = "/run/nvcurve-daemon.sock"
|
_DAEMON_SOCKET_PATH = "/run/nvcurve-daemon.sock"
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_max_delta() -> int:
|
||||||
|
"""Frequency safety cap from the persistent config (built-in default fallback).
|
||||||
|
|
||||||
|
The operator-configured cap is authoritative for all direct-hardware
|
||||||
|
write paths (write, profile apply, verify) unless explicitly overridden
|
||||||
|
with --max-delta.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(_PERSISTENT_CONFIG_FILE) as f:
|
||||||
|
return json.load(f).get("max_delta_khz", default_config.max_delta_khz)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||||
|
return default_config.max_delta_khz
|
||||||
|
|
||||||
|
|
||||||
_ALLOWED_HOSTS = {"127.0.0.1", "::1", "localhost"}
|
_ALLOWED_HOSTS = {"127.0.0.1", "::1", "localhost"}
|
||||||
|
|
||||||
|
|
||||||
@@ -501,7 +519,7 @@ def _safe_host(host: str, cfg: Config) -> str:
|
|||||||
0.0.0.0 (bind-all) is silently remapped to 127.0.0.1 — it's a valid local
|
0.0.0.0 (bind-all) is silently remapped to 127.0.0.1 — it's a valid local
|
||||||
server address, just not usable as a client connection target.
|
server address, just not usable as a client connection target.
|
||||||
"""
|
"""
|
||||||
if host in ("0.0.0.0", "::"):
|
if host in ("0.0.0.0", "::"): # noqa: S104 — comparison only, no binding here
|
||||||
return "127.0.0.1"
|
return "127.0.0.1"
|
||||||
if host not in _ALLOWED_HOSTS:
|
if host not in _ALLOWED_HOSTS:
|
||||||
print(
|
print(
|
||||||
@@ -514,7 +532,7 @@ def _safe_host(host: str, cfg: Config) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _log_file() -> str:
|
def _log_file() -> str:
|
||||||
return "/var/log/nvcurve.log" if os.geteuid() == 0 else "/tmp/nvcurve.log"
|
return "/var/log/nvcurve.log" if os.geteuid() == 0 else "/tmp/nvcurve.log" # noqa: S108
|
||||||
|
|
||||||
|
|
||||||
def _read_server_info() -> dict | None:
|
def _read_server_info() -> dict | None:
|
||||||
@@ -545,12 +563,18 @@ def _discover_server_url(cfg: Config) -> str:
|
|||||||
1. /run/nvcurve.json — runtime info written by the running server process
|
1. /run/nvcurve.json — runtime info written by the running server process
|
||||||
2. /etc/nvcurve/config.json — persistent config written by `service install`
|
2. /etc/nvcurve/config.json — persistent config written by `service install`
|
||||||
3. Config defaults — 127.0.0.1:8042
|
3. Config defaults — 127.0.0.1:8042
|
||||||
|
|
||||||
|
The scheme is https when TLS is configured (or reported by the running
|
||||||
|
server), http otherwise.
|
||||||
"""
|
"""
|
||||||
|
scheme = "https" if tls_enabled(cfg) else "http"
|
||||||
|
|
||||||
# 1. Runtime info (most accurate — reflects the actual running port)
|
# 1. Runtime info (most accurate — reflects the actual running port)
|
||||||
info = _read_server_info()
|
info = _read_server_info()
|
||||||
if info:
|
if info:
|
||||||
host = _safe_host(info["host"], cfg)
|
host = _safe_host(info["host"], cfg)
|
||||||
return f"http://{host}:{info['port']}"
|
s = "https" if info.get("tls") else scheme
|
||||||
|
return f"{s}://{host}:{info['port']}"
|
||||||
|
|
||||||
# 2. Persistent config (survives reboots; written by `service install`)
|
# 2. Persistent config (survives reboots; written by `service install`)
|
||||||
try:
|
try:
|
||||||
@@ -558,12 +582,12 @@ def _discover_server_url(cfg: Config) -> str:
|
|||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
host = _safe_host(data.get("host", cfg.host), cfg)
|
host = _safe_host(data.get("host", cfg.host), cfg)
|
||||||
port = data.get("port", cfg.port)
|
port = data.get("port", cfg.port)
|
||||||
return f"http://{host}:{port}"
|
return f"{scheme}://{host}:{port}"
|
||||||
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 3. Hardcoded defaults
|
# 3. Hardcoded defaults
|
||||||
return f"http://{cfg.host}:{cfg.port}"
|
return f"{scheme}://{cfg.host}:{cfg.port}"
|
||||||
|
|
||||||
|
|
||||||
# ── Subcommand handlers ───────────────────────────────────────────────────────
|
# ── Subcommand handlers ───────────────────────────────────────────────────────
|
||||||
@@ -737,8 +761,14 @@ def cmd_inspect(args):
|
|||||||
|
|
||||||
|
|
||||||
def cmd_write(args):
|
def cmd_write(args):
|
||||||
|
try:
|
||||||
delta_khz = int(args.delta * 1000)
|
delta_khz = int(args.delta * 1000)
|
||||||
max_delta_khz = int(args.max_delta * 1000) if args.max_delta is not None else None
|
max_delta_khz = (
|
||||||
|
int(args.max_delta * 1000) if args.max_delta is not None else None
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError, OverflowError) as exc:
|
||||||
|
print(f"Error: invalid numeric argument: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
point_deltas = {}
|
point_deltas = {}
|
||||||
|
|
||||||
if args.reset:
|
if args.reset:
|
||||||
@@ -814,7 +844,7 @@ def cmd_write(args):
|
|||||||
}
|
}
|
||||||
|
|
||||||
effective_max = (
|
effective_max = (
|
||||||
max_delta_khz if max_delta_khz is not None else default_config.max_delta_khz
|
max_delta_khz if max_delta_khz is not None else _configured_max_delta()
|
||||||
)
|
)
|
||||||
errors = validate_write(point_deltas, effective_max)
|
errors = validate_write(point_deltas, effective_max)
|
||||||
if errors:
|
if errors:
|
||||||
@@ -837,14 +867,15 @@ def cmd_write(args):
|
|||||||
print(f"Write OK — {len(point_deltas)} point(s) updated.")
|
print(f"Write OK — {len(point_deltas)} point(s) updated.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
curve_state = None
|
||||||
if not args.glob:
|
if not args.glob:
|
||||||
curve_state, _ = read_curve(gpu, gpu_name)
|
curve_state, _ = read_curve(gpu, gpu_name)
|
||||||
if curve_state:
|
if curve_state:
|
||||||
vfp_freqs = [p.freq_khz for p in curve_state.points]
|
vfp_freqs = [p.freq_khz for p in curve_state.points]
|
||||||
for w in check_negative_freq_warnings(point_deltas, vfp_freqs, []):
|
for w in check_negative_freq_warnings(point_deltas, vfp_freqs, []):
|
||||||
print(f"WARNING: {w}")
|
print(f"WARNING: {w}")
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
log.debug("Post-write curve check failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
def cmd_verify(args):
|
def cmd_verify(args):
|
||||||
@@ -854,8 +885,13 @@ def cmd_verify(args):
|
|||||||
from .hal.gpu import get_gpu
|
from .hal.gpu import get_gpu
|
||||||
from .hal.snapshot import save as snapshot_save
|
from .hal.snapshot import save as snapshot_save
|
||||||
from .hal.vfcurve import read_clock_offsets, write_offsets
|
from .hal.vfcurve import read_clock_offsets, write_offsets
|
||||||
|
from .safety import validate_write
|
||||||
|
|
||||||
|
try:
|
||||||
delta_khz = int(args.delta * 1000)
|
delta_khz = int(args.delta * 1000)
|
||||||
|
except (TypeError, ValueError, OverflowError) as exc:
|
||||||
|
print(f"Error: invalid numeric argument: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
if args.point is not None:
|
if args.point is not None:
|
||||||
points = [args.point]
|
points = [args.point]
|
||||||
@@ -867,6 +903,13 @@ def cmd_verify(args):
|
|||||||
|
|
||||||
point_deltas = dict.fromkeys(points, delta_khz)
|
point_deltas = dict.fromkeys(points, delta_khz)
|
||||||
|
|
||||||
|
# Enforce the operator-configured safety cap (same as cmd_write).
|
||||||
|
errors = validate_write(point_deltas, _configured_max_delta())
|
||||||
|
if errors:
|
||||||
|
for e in errors:
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
gpu, gpu_name = get_gpu(index=getattr(args, "gpu_index", 0))
|
gpu, gpu_name = get_gpu(index=getattr(args, "gpu_index", 0))
|
||||||
|
|
||||||
print("=== Write-Verify Cycle ===")
|
print("=== Write-Verify Cycle ===")
|
||||||
@@ -1016,8 +1059,11 @@ def _profile_config_write(key: str, value) -> None:
|
|||||||
data.pop(key, None)
|
data.pop(key, None)
|
||||||
else:
|
else:
|
||||||
data[key] = value
|
data[key] = value
|
||||||
|
try:
|
||||||
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
|
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
|
||||||
_json.dump(data, f, indent=2)
|
_json.dump(data, f, indent=2)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError(f"Cannot write {_PERSISTENT_CONFIG_FILE}: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
def _gpu_stable_key_offline(gpu_index: int) -> str | None:
|
def _gpu_stable_key_offline(gpu_index: int) -> str | None:
|
||||||
@@ -1066,8 +1112,11 @@ def _profile_config_set_default(gpu_index: int, name: str | None) -> None:
|
|||||||
profiles[gpu_key] = name
|
profiles[gpu_key] = name
|
||||||
if not profiles:
|
if not profiles:
|
||||||
data.pop("auto_load_profiles", None)
|
data.pop("auto_load_profiles", None)
|
||||||
|
try:
|
||||||
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
|
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
|
||||||
_json.dump(data, f, indent=2)
|
_json.dump(data, f, indent=2)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError(f"Cannot write {_PERSISTENT_CONFIG_FILE}: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
def cmd_profile(args):
|
def cmd_profile(args):
|
||||||
@@ -1094,8 +1143,8 @@ def cmd_profile(args):
|
|||||||
profiles.append(
|
profiles.append(
|
||||||
{"name": name, "curve_deltas": p.get("curve_deltas", {})}
|
{"name": name, "curve_deltas": p.get("curve_deltas", {})}
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
log.debug("Skipping unreadable profile %s: %s", path, exc)
|
||||||
if not profiles:
|
if not profiles:
|
||||||
print("No profiles found.")
|
print("No profiles found.")
|
||||||
return
|
return
|
||||||
@@ -1117,7 +1166,7 @@ def cmd_profile(args):
|
|||||||
require_root()
|
require_root()
|
||||||
try:
|
try:
|
||||||
_profile_config_set_default(gpu_index, None if clearing else args.name)
|
_profile_config_set_default(gpu_index, None if clearing else args.name)
|
||||||
except ValueError as e:
|
except (ValueError, RuntimeError) as e:
|
||||||
print(f"Error: {e}", file=sys.stderr)
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
return
|
return
|
||||||
if clearing:
|
if clearing:
|
||||||
@@ -1205,8 +1254,15 @@ def cmd_profile(args):
|
|||||||
errs.append(f"Power limit: {msg}")
|
errs.append(f"Power limit: {msg}")
|
||||||
|
|
||||||
if profile.curve_deltas:
|
if profile.curve_deltas:
|
||||||
|
try:
|
||||||
deltas = {int(k): v for k, v in profile.curve_deltas.items()}
|
deltas = {int(k): v for k, v in profile.curve_deltas.items()}
|
||||||
errors = validate_write(deltas, default_config.max_delta_khz)
|
except ValueError:
|
||||||
|
print(
|
||||||
|
f"Profile '{args.name}' has invalid curve point keys.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
errors = validate_write(deltas, _configured_max_delta())
|
||||||
if errors:
|
if errors:
|
||||||
errs.append("Curve: " + "; ".join(errors))
|
errs.append("Curve: " + "; ".join(errors))
|
||||||
else:
|
else:
|
||||||
@@ -1328,7 +1384,11 @@ def cmd_setup(args):
|
|||||||
"""One-shot hardware compatibility check: diag → read → write-verify → restore."""
|
"""One-shot hardware compatibility check: diag → read → write-verify → restore."""
|
||||||
explicit_point = getattr(args, "point", None)
|
explicit_point = getattr(args, "point", None)
|
||||||
verify_delta_mhz = getattr(args, "delta", 5.0) or 5.0
|
verify_delta_mhz = getattr(args, "delta", 5.0) or 5.0
|
||||||
|
try:
|
||||||
verify_delta_khz = int(verify_delta_mhz * 1000)
|
verify_delta_khz = int(verify_delta_mhz * 1000)
|
||||||
|
except (TypeError, ValueError, OverflowError) as exc:
|
||||||
|
print(f"Error: invalid numeric argument: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
require_root()
|
require_root()
|
||||||
|
|
||||||
@@ -1452,12 +1512,19 @@ def cmd_setup(args):
|
|||||||
print()
|
print()
|
||||||
print("Step 4/4 Restoring snapshot")
|
print("Step 4/4 Restoring snapshot")
|
||||||
print()
|
print()
|
||||||
|
if snap_path is None:
|
||||||
|
print(
|
||||||
|
" WARNING: Snapshot save failed — cannot restore baseline.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
else:
|
||||||
ok = snapshot_restore(gpu, default_config.snapshot_dir, snap_path)
|
ok = snapshot_restore(gpu, default_config.snapshot_dir, snap_path)
|
||||||
if ok:
|
if ok:
|
||||||
print(" Hardware state restored to baseline.")
|
print(" Hardware state restored to baseline.")
|
||||||
else:
|
else:
|
||||||
print(
|
print(
|
||||||
" WARNING: Restore failed. Run: nvcurve snapshot restore", file=sys.stderr
|
" WARNING: Restore failed. Run: nvcurve snapshot restore",
|
||||||
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
@@ -1509,41 +1576,59 @@ def cmd_service(args):
|
|||||||
"WantedBy=multi-user.target\n"
|
"WantedBy=multi-user.target\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
with open(unit_path, "w") as f:
|
with open(unit_path, "w") as f:
|
||||||
f.write(unit)
|
f.write(unit)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Failed to write {unit_path}: {exc}", file=sys.stderr)
|
||||||
|
return
|
||||||
print(f"Unit file written to {unit_path}")
|
print(f"Unit file written to {unit_path}")
|
||||||
|
|
||||||
# Write persistent config.
|
# Write persistent config.
|
||||||
|
try:
|
||||||
os.makedirs("/etc/nvcurve", exist_ok=True)
|
os.makedirs("/etc/nvcurve", exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Failed to create /etc/nvcurve: {exc}", file=sys.stderr)
|
||||||
|
return
|
||||||
persistent_cfg: dict = {}
|
persistent_cfg: dict = {}
|
||||||
try:
|
try:
|
||||||
with open(_PERSISTENT_CONFIG_FILE) as f:
|
with open(_PERSISTENT_CONFIG_FILE) as f:
|
||||||
persistent_cfg = json.load(f)
|
persistent_cfg = json.load(f)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
log.debug("Could not read persistent config: %s", exc)
|
||||||
host = getattr(args, "host", "127.0.0.1")
|
host = getattr(args, "host", "127.0.0.1")
|
||||||
port = getattr(args, "port", 8042)
|
port = getattr(args, "port", 8042)
|
||||||
auto_serve = getattr(args, "auto_serve", False)
|
auto_serve = getattr(args, "auto_serve", False)
|
||||||
persistent_cfg.update({"host": host, "port": port, "auto_serve": auto_serve})
|
persistent_cfg.update({"host": host, "port": port, "auto_serve": auto_serve})
|
||||||
|
if getattr(args, "ssl_certfile", None):
|
||||||
|
persistent_cfg["ssl_certfile"] = args.ssl_certfile
|
||||||
|
if getattr(args, "ssl_keyfile", None):
|
||||||
|
persistent_cfg["ssl_keyfile"] = args.ssl_keyfile
|
||||||
|
try:
|
||||||
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
|
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
|
||||||
json.dump(persistent_cfg, f, indent=2)
|
json.dump(persistent_cfg, f, indent=2)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Failed to write {_PERSISTENT_CONFIG_FILE}: {exc}", file=sys.stderr)
|
||||||
|
return
|
||||||
print(f"Persistent config written to {_PERSISTENT_CONFIG_FILE}")
|
print(f"Persistent config written to {_PERSISTENT_CONFIG_FILE}")
|
||||||
|
scheme = (
|
||||||
|
"https"
|
||||||
|
if persistent_cfg.get("ssl_certfile") and persistent_cfg.get("ssl_keyfile")
|
||||||
|
else "http"
|
||||||
|
)
|
||||||
if auto_serve:
|
if auto_serve:
|
||||||
print(f" Web server will auto-start on boot at {host}:{port}")
|
print(f" Web server will auto-start on boot at {scheme}://{host}:{port}")
|
||||||
else:
|
else:
|
||||||
print(
|
print(
|
||||||
f" Web server default: {host}:{port} (start on demand: nvcurve serve start)"
|
f" Web server default: {scheme}://{host}:{port} "
|
||||||
|
"(start on demand: nvcurve serve start)"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(["systemctl", "daemon-reload"], check=True)
|
subprocess.run(["systemctl", "daemon-reload"], check=True)
|
||||||
|
|
||||||
was_active = (
|
probe = subprocess.run(["systemctl", "is-active", "--quiet", "nvcurve"])
|
||||||
subprocess.run(
|
was_active = probe.returncode == 0
|
||||||
["systemctl", "is-active", "--quiet", "nvcurve"],
|
|
||||||
).returncode
|
|
||||||
== 0
|
|
||||||
)
|
|
||||||
|
|
||||||
subprocess.run(["systemctl", "enable", "--now", "nvcurve"], check=True)
|
subprocess.run(["systemctl", "enable", "--now", "nvcurve"], check=True)
|
||||||
print("Service enabled and started.")
|
print("Service enabled and started.")
|
||||||
@@ -1560,7 +1645,7 @@ def cmd_service(args):
|
|||||||
print(" systemctl status nvcurve")
|
print(" systemctl status nvcurve")
|
||||||
print(" journalctl -u nvcurve -f")
|
print(" journalctl -u nvcurve -f")
|
||||||
print(" nvcurve service uninstall")
|
print(" nvcurve service uninstall")
|
||||||
except subprocess.CalledProcessError as e:
|
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
||||||
print(f"systemctl failed: {e}", file=sys.stderr)
|
print(f"systemctl failed: {e}", file=sys.stderr)
|
||||||
|
|
||||||
elif action == "uninstall":
|
elif action == "uninstall":
|
||||||
@@ -1660,25 +1745,31 @@ def cmd_service(args):
|
|||||||
auto_serve = pcfg.get("auto_serve", False)
|
auto_serve = pcfg.get("auto_serve", False)
|
||||||
host = pcfg.get("host", "127.0.0.1")
|
host = pcfg.get("host", "127.0.0.1")
|
||||||
port = pcfg.get("port", 8042)
|
port = pcfg.get("port", 8042)
|
||||||
|
tls = bool(pcfg.get("ssl_certfile") and pcfg.get("ssl_keyfile"))
|
||||||
print()
|
print()
|
||||||
print(f"web server auto-start: {'on' if auto_serve else 'off'}")
|
print(f"web server auto-start: {'on' if auto_serve else 'off'}")
|
||||||
print(f"web server address: {host}:{port}")
|
print(f"web server address: {'https' if tls else 'http'}://{host}:{port}")
|
||||||
|
print(f"web server TLS: {'on' if tls else 'off'}")
|
||||||
print()
|
print()
|
||||||
print(
|
print(
|
||||||
"Change with: nvcurve service configure [--auto-serve|--no-auto-serve] [--host H] [--port P]"
|
"Change with: nvcurve service configure [--auto-serve|--no-auto-serve] [--host H] [--port P] [--ssl-certfile C --ssl-keyfile K]"
|
||||||
)
|
)
|
||||||
|
|
||||||
elif action == "configure":
|
elif action == "configure":
|
||||||
require_root()
|
require_root()
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
try:
|
||||||
os.makedirs("/etc/nvcurve", exist_ok=True)
|
os.makedirs("/etc/nvcurve", exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Failed to create /etc/nvcurve: {exc}", file=sys.stderr)
|
||||||
|
return
|
||||||
pcfg: dict = {}
|
pcfg: dict = {}
|
||||||
try:
|
try:
|
||||||
with open(_PERSISTENT_CONFIG_FILE) as f:
|
with open(_PERSISTENT_CONFIG_FILE) as f:
|
||||||
pcfg = json.load(f)
|
pcfg = json.load(f)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
log.debug("Could not read persistent config: %s", exc)
|
||||||
|
|
||||||
if hasattr(args, "auto_serve") and args.auto_serve is not None:
|
if hasattr(args, "auto_serve") and args.auto_serve is not None:
|
||||||
pcfg["auto_serve"] = args.auto_serve
|
pcfg["auto_serve"] = args.auto_serve
|
||||||
@@ -1686,13 +1777,27 @@ def cmd_service(args):
|
|||||||
pcfg["host"] = args.host
|
pcfg["host"] = args.host
|
||||||
if hasattr(args, "port") and args.port is not None:
|
if hasattr(args, "port") and args.port is not None:
|
||||||
pcfg["port"] = args.port
|
pcfg["port"] = args.port
|
||||||
|
if getattr(args, "ssl_certfile", None):
|
||||||
|
pcfg["ssl_certfile"] = args.ssl_certfile
|
||||||
|
if getattr(args, "ssl_keyfile", None):
|
||||||
|
pcfg["ssl_keyfile"] = args.ssl_keyfile
|
||||||
|
if getattr(args, "no_ssl", False):
|
||||||
|
pcfg.pop("ssl_certfile", None)
|
||||||
|
pcfg.pop("ssl_keyfile", None)
|
||||||
|
|
||||||
|
try:
|
||||||
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
|
with open(_PERSISTENT_CONFIG_FILE, "w") as f:
|
||||||
json.dump(pcfg, f, indent=2)
|
json.dump(pcfg, f, indent=2)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Failed to write {_PERSISTENT_CONFIG_FILE}: {exc}", file=sys.stderr)
|
||||||
|
return
|
||||||
print(f"Config updated ({_PERSISTENT_CONFIG_FILE}):")
|
print(f"Config updated ({_PERSISTENT_CONFIG_FILE}):")
|
||||||
print(f" auto-serve: {'on' if pcfg.get('auto_serve', False) else 'off'}")
|
print(f" auto-serve: {'on' if pcfg.get('auto_serve', False) else 'off'}")
|
||||||
print(f" host: {pcfg.get('host', '127.0.0.1')}")
|
print(f" host: {pcfg.get('host', '127.0.0.1')}")
|
||||||
print(f" port: {pcfg.get('port', 8042)}")
|
print(f" port: {pcfg.get('port', 8042)}")
|
||||||
|
print(
|
||||||
|
f" TLS: {'on' if pcfg.get('ssl_certfile') and pcfg.get('ssl_keyfile') else 'off'}"
|
||||||
|
)
|
||||||
|
|
||||||
if os.path.exists(unit_path):
|
if os.path.exists(unit_path):
|
||||||
try:
|
try:
|
||||||
@@ -1712,11 +1817,31 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None:
|
|||||||
host = getattr(args, "host", cfg.host)
|
host = getattr(args, "host", cfg.host)
|
||||||
port = getattr(args, "port", cfg.port)
|
port = getattr(args, "port", cfg.port)
|
||||||
|
|
||||||
|
# Optional TLS (CLI flags override the persistent config).
|
||||||
|
ssl_certfile = getattr(args, "ssl_certfile", None)
|
||||||
|
ssl_keyfile = getattr(args, "ssl_keyfile", None)
|
||||||
|
if ssl_certfile:
|
||||||
|
cfg.ssl_certfile = ssl_certfile
|
||||||
|
if ssl_keyfile:
|
||||||
|
cfg.ssl_keyfile = ssl_keyfile
|
||||||
|
|
||||||
# --direct: skip daemon round-trip (used when the daemon itself spawns us).
|
# --direct: skip daemon round-trip (used when the daemon itself spawns us).
|
||||||
if getattr(args, "direct", False):
|
if getattr(args, "direct", False):
|
||||||
require_root()
|
require_root()
|
||||||
|
try:
|
||||||
with open(_SERVER_INFO_FILE, "w") as f:
|
with open(_SERVER_INFO_FILE, "w") as f:
|
||||||
json.dump({"pid": os.getpid(), "host": host, "port": port}, f)
|
json.dump(
|
||||||
|
{
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
"tls": tls_enabled(cfg),
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Failed to write {_SERVER_INFO_FILE}: {exc}", file=sys.stderr)
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
from .server import run as server_run
|
from .server import run as server_run
|
||||||
|
|
||||||
@@ -1733,13 +1858,34 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Prefer daemon socket: no root required, daemon manages the server process.
|
# Prefer daemon socket: no root required, daemon manages the server process.
|
||||||
resp = _daemon_send({"cmd": "serve_start", "host": host, "port": port})
|
# The daemon always binds the configured host/port (callers cannot choose
|
||||||
|
# the interface), so report the address from the daemon's response.
|
||||||
|
resp = _daemon_send({"cmd": "serve_start"})
|
||||||
if resp is not None:
|
if resp is not None:
|
||||||
if resp.get("ok"):
|
if resp.get("ok"):
|
||||||
print(f"Web server starting (PID {resp['pid']}) at http://{host}:{port}")
|
rhost = resp.get("host", host)
|
||||||
|
rport = resp.get("port", port)
|
||||||
|
if (rhost, rport) != (host, port):
|
||||||
|
print(
|
||||||
|
f"Note: daemon uses the configured bind address {rhost}:{rport} "
|
||||||
|
"(change with: nvcurve service configure --host/--port)",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
if (ssl_certfile or ssl_keyfile) and not resp.get("tls"):
|
||||||
|
print(
|
||||||
|
"Note: --ssl-certfile/--ssl-keyfile are ignored while the daemon "
|
||||||
|
"manages the server — the daemon uses the TLS settings from "
|
||||||
|
"/etc/nvcurve/config.json (set with: nvcurve service configure "
|
||||||
|
"--ssl-certfile/--ssl-keyfile)",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
scheme = "https" if resp.get("tls") else "http"
|
||||||
|
print(
|
||||||
|
f"Web server starting (PID {resp['pid']}) at {scheme}://{rhost}:{rport}"
|
||||||
|
)
|
||||||
if open_browser:
|
if open_browser:
|
||||||
time.sleep(1.5)
|
time.sleep(1.5)
|
||||||
_open_browser_as_user(f"http://{host}:{port}")
|
_open_browser_as_user(f"{scheme}://{rhost}:{rport}")
|
||||||
else:
|
else:
|
||||||
print(f"Daemon: {resp.get('error')}", file=sys.stderr)
|
print(f"Daemon: {resp.get('error')}", file=sys.stderr)
|
||||||
return
|
return
|
||||||
@@ -1749,7 +1895,8 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None:
|
|||||||
|
|
||||||
info = _read_server_info()
|
info = _read_server_info()
|
||||||
if info:
|
if info:
|
||||||
url = f"http://{info['host']}:{info['port']}"
|
scheme = "https" if info.get("tls") else "http"
|
||||||
|
url = f"{scheme}://{info['host']}:{info['port']}"
|
||||||
print(f"Server is already running (PID {info['pid']}) at {url}.")
|
print(f"Server is already running (PID {info['pid']}) at {url}.")
|
||||||
if open_browser:
|
if open_browser:
|
||||||
_open_browser_as_user(url)
|
_open_browser_as_user(url)
|
||||||
@@ -1769,12 +1916,20 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None:
|
|||||||
"--port",
|
"--port",
|
||||||
str(port),
|
str(port),
|
||||||
]
|
]
|
||||||
|
if ssl_certfile:
|
||||||
|
cmd += ["--ssl-certfile", ssl_certfile]
|
||||||
|
if ssl_keyfile:
|
||||||
|
cmd += ["--ssl-keyfile", ssl_keyfile]
|
||||||
if getattr(args, "gpu_index", 0):
|
if getattr(args, "gpu_index", 0):
|
||||||
cmd += ["--gpu", str(args.gpu_index)]
|
cmd += ["--gpu", str(args.gpu_index)]
|
||||||
log_path = _log_file()
|
log_path = _log_file()
|
||||||
print("Starting nvcurve server in background...")
|
print("Starting nvcurve server in background...")
|
||||||
|
try:
|
||||||
with open(log_path, "a") as lf:
|
with open(log_path, "a") as lf:
|
||||||
p = subprocess.Popen(cmd, stdout=lf, stderr=lf, start_new_session=True)
|
p = subprocess.Popen(cmd, stdout=lf, stderr=lf, start_new_session=True)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Failed to open log file {log_path}: {exc}", file=sys.stderr)
|
||||||
|
return
|
||||||
print(f"Server starting (PID {p.pid}). Logs: {log_path}")
|
print(f"Server starting (PID {p.pid}). Logs: {log_path}")
|
||||||
if open_browser:
|
if open_browser:
|
||||||
time.sleep(1.5)
|
time.sleep(1.5)
|
||||||
@@ -1782,8 +1937,20 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Foreground mode — write info file so clients can discover host:port.
|
# Foreground mode — write info file so clients can discover host:port.
|
||||||
|
try:
|
||||||
with open(_SERVER_INFO_FILE, "w") as f:
|
with open(_SERVER_INFO_FILE, "w") as f:
|
||||||
json.dump({"pid": os.getpid(), "host": host, "port": port}, f)
|
json.dump(
|
||||||
|
{
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
"tls": tls_enabled(cfg),
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Failed to write {_SERVER_INFO_FILE}: {exc}", file=sys.stderr)
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
from .server import run as server_run
|
from .server import run as server_run
|
||||||
|
|
||||||
@@ -1990,6 +2157,12 @@ Examples:
|
|||||||
"--host", default="127.0.0.1", help="Bind address (default 127.0.0.1)"
|
"--host", default="127.0.0.1", help="Bind address (default 127.0.0.1)"
|
||||||
)
|
)
|
||||||
p_start.add_argument("--port", type=int, default=8042, help="Port (default 8042)")
|
p_start.add_argument("--port", type=int, default=8042, help="Port (default 8042)")
|
||||||
|
p_start.add_argument(
|
||||||
|
"--ssl-certfile", default=None, help="TLS certificate (enables HTTPS)"
|
||||||
|
)
|
||||||
|
p_start.add_argument(
|
||||||
|
"--ssl-keyfile", default=None, help="TLS private key (enables HTTPS)"
|
||||||
|
)
|
||||||
p_start.add_argument(
|
p_start.add_argument(
|
||||||
"--detach", "-d", action="store_true", help="Run in background"
|
"--detach", "-d", action="store_true", help="Run in background"
|
||||||
)
|
)
|
||||||
@@ -2024,6 +2197,16 @@ Examples:
|
|||||||
default=8042,
|
default=8042,
|
||||||
help="Default web server port (stored in config)",
|
help="Default web server port (stored in config)",
|
||||||
)
|
)
|
||||||
|
p_install.add_argument(
|
||||||
|
"--ssl-certfile",
|
||||||
|
default=None,
|
||||||
|
help="TLS certificate (stored in config; enables HTTPS)",
|
||||||
|
)
|
||||||
|
p_install.add_argument(
|
||||||
|
"--ssl-keyfile",
|
||||||
|
default=None,
|
||||||
|
help="TLS private key (stored in config; enables HTTPS)",
|
||||||
|
)
|
||||||
|
|
||||||
p_configure = s_svc.add_parser(
|
p_configure = s_svc.add_parser(
|
||||||
"configure", help="Update config and restart daemon (escalates to root)"
|
"configure", help="Update config and restart daemon (escalates to root)"
|
||||||
@@ -2043,6 +2226,19 @@ Examples:
|
|||||||
)
|
)
|
||||||
p_configure.add_argument("--host", default=None, help="Web server bind address")
|
p_configure.add_argument("--host", default=None, help="Web server bind address")
|
||||||
p_configure.add_argument("--port", type=int, default=None, help="Web server port")
|
p_configure.add_argument("--port", type=int, default=None, help="Web server port")
|
||||||
|
p_configure.add_argument(
|
||||||
|
"--ssl-certfile",
|
||||||
|
default=None,
|
||||||
|
help="TLS certificate (stored in config; enables HTTPS)",
|
||||||
|
)
|
||||||
|
p_configure.add_argument(
|
||||||
|
"--ssl-keyfile", default=None, help="TLS private key (stored in config)"
|
||||||
|
)
|
||||||
|
p_configure.add_argument(
|
||||||
|
"--no-ssl",
|
||||||
|
action="store_true",
|
||||||
|
help="Disable TLS (remove certificate/key from config)",
|
||||||
|
)
|
||||||
|
|
||||||
s_svc.add_parser("uninstall", help="Remove systemd service (escalates to root)")
|
s_svc.add_parser("uninstall", help="Remove systemd service (escalates to root)")
|
||||||
s_svc.add_parser("start", help="Start systemd service (escalates to root)")
|
s_svc.add_parser("start", help="Start systemd service (escalates to root)")
|
||||||
@@ -2083,17 +2279,25 @@ def main():
|
|||||||
"users_file",
|
"users_file",
|
||||||
"host",
|
"host",
|
||||||
"port",
|
"port",
|
||||||
|
"ssl_certfile",
|
||||||
|
"ssl_keyfile",
|
||||||
|
"allow_api_shutdown",
|
||||||
):
|
):
|
||||||
if key in data:
|
if key in data:
|
||||||
setattr(cfg, key, data[key])
|
setattr(cfg, key, data[key])
|
||||||
|
if "trusted_proxies" in data:
|
||||||
|
cfg.trusted_proxies = normalize_trusted_proxies(data["trusted_proxies"])
|
||||||
if "auto_load_profiles" in data:
|
if "auto_load_profiles" in data:
|
||||||
# Keys are stable GPU identifiers (UUID, "pci:XXXX", or "idx:N")
|
# Keys are stable GPU identifiers (UUID, "pci:XXXX", or "idx:N")
|
||||||
cfg.auto_load_profiles = dict(data["auto_load_profiles"])
|
cfg.auto_load_profiles = dict(data["auto_load_profiles"])
|
||||||
elif "auto_load_profile" in data:
|
elif "auto_load_profile" in data:
|
||||||
# Migrate old single-string format — GPU 0, no UUID known at this point
|
# Migrate old single-string format — GPU 0, no UUID known at this point
|
||||||
cfg.auto_load_profiles = {"idx:0": data["auto_load_profile"]}
|
cfg.auto_load_profiles = {"idx:0": data["auto_load_profile"]}
|
||||||
except Exception:
|
if "fan_curves" in data:
|
||||||
pass
|
# Per-GPU active fan curves, restored on server startup.
|
||||||
|
cfg.fan_curves = dict(data["fan_curves"])
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("Could not load user config: %s", exc)
|
||||||
|
|
||||||
base_url = args.server or _discover_server_url(cfg)
|
base_url = args.server or _discover_server_url(cfg)
|
||||||
client = NvCurveClient(base=base_url, gpu_index=getattr(args, "gpu_index", 0))
|
client = NvCurveClient(base=base_url, gpu_index=getattr(args, "gpu_index", 0))
|
||||||
@@ -2145,8 +2349,8 @@ def main():
|
|||||||
if os.path.exists(_SERVER_INFO_FILE):
|
if os.path.exists(_SERVER_INFO_FILE):
|
||||||
try:
|
try:
|
||||||
os.remove(_SERVER_INFO_FILE)
|
os.remove(_SERVER_INFO_FILE)
|
||||||
except OSError:
|
except OSError as exc:
|
||||||
pass
|
log.debug("Could not remove %s: %s", _SERVER_INFO_FILE, exc)
|
||||||
except ApiError as e:
|
except ApiError as e:
|
||||||
if e.status_code == 401:
|
if e.status_code == 401:
|
||||||
print(
|
print(
|
||||||
|
|||||||
+5
-10
@@ -120,18 +120,13 @@ class NvCurveClient:
|
|||||||
def write_curve(
|
def write_curve(
|
||||||
self,
|
self,
|
||||||
deltas: dict[int, int],
|
deltas: dict[int, int],
|
||||||
max_delta_khz: int | None = None,
|
|
||||||
) -> dict:
|
) -> dict:
|
||||||
body: dict = {"deltas": deltas}
|
# The server enforces its configured safety cap; clients cannot
|
||||||
if max_delta_khz is not None:
|
# override it per request.
|
||||||
body["max_delta_khz"] = max_delta_khz
|
return self._post("/api/curve/write", {"deltas": deltas})
|
||||||
return self._post("/api/curve/write", body)
|
|
||||||
|
|
||||||
def write_global(self, delta_khz: int, max_delta_khz: int | None = None) -> dict:
|
def write_global(self, delta_khz: int) -> dict:
|
||||||
body: dict = {"delta_khz": delta_khz}
|
return self._post("/api/curve/write/global", {"delta_khz": delta_khz})
|
||||||
if max_delta_khz is not None:
|
|
||||||
body["max_delta_khz"] = max_delta_khz
|
|
||||||
return self._post("/api/curve/write/global", body)
|
|
||||||
|
|
||||||
def reset_curve(self) -> dict:
|
def reset_curve(self) -> dict:
|
||||||
return self._post("/api/curve/reset")
|
return self._post("/api/curve/reset")
|
||||||
|
|||||||
@@ -17,6 +17,21 @@ class Config:
|
|||||||
host: str = "127.0.0.1"
|
host: str = "127.0.0.1"
|
||||||
port: int = 8042
|
port: int = 8042
|
||||||
|
|
||||||
|
# Optional TLS: when both are set, the server serves HTTPS and the
|
||||||
|
# session cookie is marked Secure. Off by default (plain HTTP).
|
||||||
|
ssl_certfile: str | None = None
|
||||||
|
ssl_keyfile: str | None = None
|
||||||
|
|
||||||
|
# Proxy IPs (e.g. a reverse proxy on 127.0.0.1) whose X-Forwarded-For
|
||||||
|
# header is trusted for the login brute-force lockout. JSON array in
|
||||||
|
# config.json (a comma-separated string is also accepted and normalized).
|
||||||
|
# Without this, all proxied clients share the proxy's IP.
|
||||||
|
trusted_proxies: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Allow any authenticated user to stop the server via POST /api/shutdown.
|
||||||
|
# Set false on shared systems; manage the service via systemd instead.
|
||||||
|
allow_api_shutdown: bool = True
|
||||||
|
|
||||||
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"
|
||||||
|
|
||||||
@@ -30,6 +45,37 @@ class Config:
|
|||||||
# Value = profile name (str).
|
# Value = profile name (str).
|
||||||
auto_load_profiles: dict[str, str] = field(default_factory=dict)
|
auto_load_profiles: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# Per-GPU active fan curves: restored automatically on server startup so a
|
||||||
|
# curve applied via the UI survives server restarts (fan control itself is
|
||||||
|
# volatile — the driver reverts to automatic mode on reboot).
|
||||||
|
# Key = stable GPU identifier (same as auto_load_profiles).
|
||||||
|
# Value = {"curve": [{"temp_c": int, "fan_pct": int}, ...] sorted by temp_c,
|
||||||
|
# "fans": [fan indices] | None (None = all fans)}.
|
||||||
|
# Legacy entries (bare curve list) are migrated at load time.
|
||||||
|
fan_curves: dict[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
# Module-level default config instance.
|
# Module-level default config instance.
|
||||||
default_config = Config()
|
default_config = Config()
|
||||||
|
|
||||||
|
|
||||||
|
def tls_enabled(cfg: Config) -> bool:
|
||||||
|
"""True when both TLS files are configured (server serves HTTPS)."""
|
||||||
|
return bool(cfg.ssl_certfile and cfg.ssl_keyfile)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_trusted_proxies(value) -> list[str]:
|
||||||
|
"""Normalize a trusted_proxies config value to a list of IP strings.
|
||||||
|
|
||||||
|
Accepts a JSON array (the documented format) or a comma-separated string
|
||||||
|
(tolerated for convenience). Normalizing matters because the server does
|
||||||
|
exact list membership tests — a raw string would degrade to substring
|
||||||
|
matching (e.g. "127.0.0.1" in "127.0.0.10").
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [h.strip() for h in value.split(",") if h.strip()]
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [str(h).strip() for h in value if str(h).strip()]
|
||||||
|
return []
|
||||||
+34
-10
@@ -7,10 +7,16 @@ Protocol: newline-delimited JSON, one request → one response, connection close
|
|||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
{"cmd": "ping"}
|
{"cmd": "ping"}
|
||||||
{"cmd": "serve_start", "host": "127.0.0.1", "port": 8042}
|
{"cmd": "serve_start"}
|
||||||
{"cmd": "serve_stop"}
|
{"cmd": "serve_stop"}
|
||||||
{"cmd": "serve_status"}
|
{"cmd": "serve_status"}
|
||||||
|
|
||||||
|
The socket is world-connectable (unprivileged users drive it via the CLI),
|
||||||
|
so the command surface is deliberately minimal: serve_start ALWAYS binds the
|
||||||
|
configured host/port from /etc/nvcurve/config.json — callers cannot choose
|
||||||
|
the bind address (no ad-hoc 0.0.0.0 exposure). Changing the bind address is
|
||||||
|
an operator action via `nvcurve service configure`.
|
||||||
|
|
||||||
Requires root.
|
Requires root.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -23,7 +29,7 @@ import signal
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from .config import Config
|
from .config import Config, normalize_trusted_proxies
|
||||||
|
|
||||||
log = logging.getLogger("nvcurve.daemon")
|
log = logging.getLogger("nvcurve.daemon")
|
||||||
|
|
||||||
@@ -38,7 +44,13 @@ _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() -> dict:
|
||||||
|
"""Start the web server on the *configured* host/port.
|
||||||
|
|
||||||
|
The bind address is taken from /etc/nvcurve/config.json only — the
|
||||||
|
socket is reachable by unprivileged users, so callers must not be able
|
||||||
|
to choose the interface (e.g. binding 0.0.0.0 to expose the API).
|
||||||
|
"""
|
||||||
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 {
|
return {
|
||||||
@@ -47,6 +59,8 @@ async def _handle_serve_start(host: str, port: int) -> dict:
|
|||||||
"pid": _server_proc.pid,
|
"pid": _server_proc.pid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
host = _cfg.host if _cfg is not None else "127.0.0.1"
|
||||||
|
port = _cfg.port if _cfg is not None else 8042
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"-m",
|
"-m",
|
||||||
@@ -72,7 +86,13 @@ async def _handle_serve_start(host: str, port: int) -> dict:
|
|||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
return {"ok": False, "error": f"cannot open log file {log_path}: {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,
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
"tls": bool(_cfg and _cfg.ssl_certfile and _cfg.ssl_keyfile),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _handle_serve_stop() -> dict:
|
async def _handle_serve_stop() -> dict:
|
||||||
@@ -105,9 +125,7 @@ async def _dispatch(req: dict) -> dict:
|
|||||||
elif cmd == "serve_start":
|
elif cmd == "serve_start":
|
||||||
if _cfg is None:
|
if _cfg is None:
|
||||||
return {"ok": False, "error": "config not initialized"}
|
return {"ok": False, "error": "config not initialized"}
|
||||||
host = req.get("host", _cfg.host)
|
return await _handle_serve_start()
|
||||||
port = req.get("port", _cfg.port)
|
|
||||||
return await _handle_serve_start(host, port)
|
|
||||||
elif cmd == "serve_stop":
|
elif cmd == "serve_stop":
|
||||||
return await _handle_serve_stop()
|
return await _handle_serve_stop()
|
||||||
elif cmd == "serve_status":
|
elif cmd == "serve_status":
|
||||||
@@ -174,9 +192,13 @@ def run() -> None:
|
|||||||
"profile_dir",
|
"profile_dir",
|
||||||
"host",
|
"host",
|
||||||
"port",
|
"port",
|
||||||
|
"ssl_certfile",
|
||||||
|
"ssl_keyfile",
|
||||||
):
|
):
|
||||||
if key in cfg_data:
|
if key in cfg_data:
|
||||||
setattr(_cfg, key, cfg_data[key])
|
setattr(_cfg, key, cfg_data[key])
|
||||||
|
if "trusted_proxies" in cfg_data:
|
||||||
|
_cfg.trusted_proxies = normalize_trusted_proxies(cfg_data["trusted_proxies"])
|
||||||
|
|
||||||
# Apply auto-load profiles in a subprocess so the daemon process itself
|
# Apply auto-load profiles in a subprocess so the daemon process itself
|
||||||
# never loads NvAPI/NVML/HAL modules — keeps steady-state RSS low.
|
# never loads NvAPI/NVML/HAL modules — keeps steady-state RSS low.
|
||||||
@@ -207,10 +229,12 @@ async def _serve_socket(auto_serve: bool = False) -> None:
|
|||||||
server = await asyncio.start_unix_server(_handle_client, path=SOCKET_PATH)
|
server = await asyncio.start_unix_server(_handle_client, path=SOCKET_PATH)
|
||||||
# The socket must be connectable by unprivileged users: the CLI runs as the
|
# 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
|
# regular user and talks to this root daemon over the socket. 0o666 is
|
||||||
# intentional (standard for /run daemon sockets).
|
# intentional — the command surface is restricted accordingly (serve_start
|
||||||
|
# always uses the configured host/port; see module docstring).
|
||||||
# pi-lens-ignore: S103
|
# pi-lens-ignore: S103
|
||||||
|
_SOCKET_MODE = 0o666
|
||||||
os.chmod(
|
os.chmod(
|
||||||
SOCKET_PATH, 0o666
|
SOCKET_PATH, _SOCKET_MODE
|
||||||
) # nosemgrep: python.lang.security.audit.insecure-file-permissions.insecure-file-permissions
|
) # 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)
|
||||||
|
|
||||||
@@ -219,7 +243,7 @@ async def _serve_socket(auto_serve: bool = False) -> None:
|
|||||||
log.warning("auto_serve requested but config not initialized")
|
log.warning("auto_serve requested but config not initialized")
|
||||||
else:
|
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()
|
||||||
|
|
||||||
stop_event = asyncio.Event()
|
stop_event = asyncio.Event()
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
|||||||
+140
-28
@@ -1,26 +1,33 @@
|
|||||||
"""Hardware Abstraction Layer for Fan Control.
|
"""Hardware Abstraction Layer for Fan Control.
|
||||||
|
|
||||||
Uses NVML (via pynvml) for all operations:
|
Uses NVML (via pynvml) for all operations:
|
||||||
|
- nvmlDeviceGetNumFans : number of fans on the device
|
||||||
- nvmlDeviceGetFanSpeed_v2 : read current fan speed % for a fan index
|
- nvmlDeviceGetFanSpeed_v2 : read current fan speed % for a fan index
|
||||||
- nvmlDeviceSetFanSpeed_v2 : set fan speed % for a fan index
|
- nvmlDeviceSetFanSpeed_v2 : set fan speed % for a fan index
|
||||||
|
- nvmlDeviceSetDefaultFanSpeed_v2 : restore automatic control for a fan index
|
||||||
- nvmlDeviceGetMinMaxFanSpeed : get min/max fan speed constraints
|
- nvmlDeviceGetMinMaxFanSpeed : get min/max fan speed constraints
|
||||||
- nvmlDeviceGetTemperature : read GPU temp for curve interpolation
|
- nvmlDeviceGetTemperature : read GPU temp for curve interpolation
|
||||||
|
|
||||||
|
Fans are addressed by 0-based index. Passing ``fans=None`` to the set/reset
|
||||||
|
helpers means "all fans on the device".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import ctypes
|
import ctypes
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Optional
|
from typing import Any
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pynvml
|
import pynvml as _pynvml_import
|
||||||
|
|
||||||
_NVML_AVAILABLE = True
|
_NVML_AVAILABLE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
_pynvml_import = None
|
||||||
_NVML_AVAILABLE = False
|
_NVML_AVAILABLE = False
|
||||||
|
|
||||||
log = logging.getLogger("nvcurve.hal.fans")
|
# Aliased as Any so attribute access is not flagged when the import failed.
|
||||||
|
pynvml: Any = _pynvml_import
|
||||||
|
|
||||||
# We use fan index 0 (first/primary fan) for all operations.
|
log = logging.getLogger("nvcurve.hal.fans")
|
||||||
_FAN_INDEX = 0
|
|
||||||
|
|
||||||
|
|
||||||
def _get_handle(gpu_index: int):
|
def _get_handle(gpu_index: int):
|
||||||
@@ -30,13 +37,42 @@ def _get_handle(gpu_index: int):
|
|||||||
return pynvml.nvmlDeviceGetHandleByIndex(gpu_index)
|
return pynvml.nvmlDeviceGetHandleByIndex(gpu_index)
|
||||||
|
|
||||||
|
|
||||||
|
def _num_fans(handle) -> int:
|
||||||
|
"""Return the number of fans on the device (>= 1 on query failure)."""
|
||||||
|
try:
|
||||||
|
return max(0, int(pynvml.nvmlDeviceGetNumFans(handle)))
|
||||||
|
except pynvml.NVMLError:
|
||||||
|
# GetNumFans unsupported: assume at least the primary fan exists.
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def get_num_fans(gpu_index: int = 0) -> int:
|
||||||
|
"""Return the number of fans on the GPU (0 if NVML is unavailable)."""
|
||||||
|
if not _NVML_AVAILABLE:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return _num_fans(_get_handle(gpu_index))
|
||||||
|
except pynvml.NVMLError as exc:
|
||||||
|
log.warning("get_num_fans: %s", exc)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def get_fan_info(gpu_index: int = 0) -> dict:
|
def get_fan_info(gpu_index: int = 0) -> dict:
|
||||||
"""Return current fan state: fan_pct, fan_mode, min_fan_pct, max_fan_pct.
|
"""Return current fan state for all fans.
|
||||||
|
|
||||||
|
Returns a dict with:
|
||||||
|
fan_pct : current speed % of fan 0 (legacy, None on failure)
|
||||||
|
fans : [{"index": i, "fan_pct": pct | None}, ...] per fan
|
||||||
|
num_fans : number of fans on the device
|
||||||
|
fan_mode : None (the server derives "auto"/"curve")
|
||||||
|
min_fan_pct / max_fan_pct : device-wide speed constraints
|
||||||
|
|
||||||
Returns None values on failure.
|
Returns None values on failure.
|
||||||
"""
|
"""
|
||||||
out = {
|
out: dict[str, Any] = {
|
||||||
"fan_pct": None,
|
"fan_pct": None,
|
||||||
|
"fans": [],
|
||||||
|
"num_fans": 0,
|
||||||
"fan_mode": None,
|
"fan_mode": None,
|
||||||
"min_fan_pct": None,
|
"min_fan_pct": None,
|
||||||
"max_fan_pct": None,
|
"max_fan_pct": None,
|
||||||
@@ -45,16 +81,26 @@ def get_fan_info(gpu_index: int = 0) -> dict:
|
|||||||
return out
|
return out
|
||||||
try:
|
try:
|
||||||
handle = _get_handle(gpu_index)
|
handle = _get_handle(gpu_index)
|
||||||
|
except pynvml.NVMLError as exc:
|
||||||
|
log.warning("get_fan_info: %s", exc)
|
||||||
|
return out
|
||||||
|
|
||||||
# Get current fan speed using v2 API (fan index 0)
|
out["num_fans"] = _num_fans(handle)
|
||||||
|
|
||||||
|
# Per-fan speeds via the v2 API; fan 0 falls back to the legacy v1 API.
|
||||||
|
for i in range(out["num_fans"]):
|
||||||
|
pct: float | None = None
|
||||||
try:
|
try:
|
||||||
out["fan_pct"] = float(pynvml.nvmlDeviceGetFanSpeed_v2(handle, _FAN_INDEX))
|
pct = float(pynvml.nvmlDeviceGetFanSpeed_v2(handle, i))
|
||||||
except pynvml.NVMLError:
|
except pynvml.NVMLError:
|
||||||
# Fallback to legacy v1 API
|
if i == 0:
|
||||||
try:
|
try:
|
||||||
out["fan_pct"] = float(pynvml.nvmlDeviceGetFanSpeed(handle))
|
pct = float(pynvml.nvmlDeviceGetFanSpeed(handle))
|
||||||
except pynvml.NVMLError:
|
except pynvml.NVMLError:
|
||||||
pass
|
pct = None
|
||||||
|
out["fans"].append({"index": i, "fan_pct": pct})
|
||||||
|
|
||||||
|
out["fan_pct"] = out["fans"][0]["fan_pct"] if out["fans"] else None
|
||||||
|
|
||||||
# Get min/max fan speed constraints
|
# Get min/max fan speed constraints
|
||||||
try:
|
try:
|
||||||
@@ -67,20 +113,71 @@ def get_fan_info(gpu_index: int = 0) -> dict:
|
|||||||
out["min_fan_pct"] = 0
|
out["min_fan_pct"] = 0
|
||||||
out["max_fan_pct"] = 100
|
out["max_fan_pct"] = 100
|
||||||
|
|
||||||
except pynvml.NVMLError as exc:
|
|
||||||
log.warning("get_fan_info: %s", exc)
|
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def set_fan_speed(gpu_index: int, pct: int) -> tuple[bool, str]:
|
def set_fan_speed(
|
||||||
"""Set fan speed to a percentage (0-100) on the primary fan."""
|
gpu_index: int,
|
||||||
|
pct: int,
|
||||||
|
fans: list[int] | None = None,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Set fan speed to a percentage (0-100).
|
||||||
|
|
||||||
|
fans=None targets every fan on the device; fans=[0, 1] targets the
|
||||||
|
listed fan indices. In all-fans mode, fans the driver does not allow
|
||||||
|
manual control of (e.g. driver-mirrored secondary fans) are skipped
|
||||||
|
with a warning instead of failing the whole operation; explicit fan
|
||||||
|
lists are strict and fail if any selected fan cannot be set.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
pct = max(0, min(100, int(pct)))
|
pct = max(0, min(100, int(pct)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False, "Invalid fan speed"
|
||||||
if not _NVML_AVAILABLE:
|
if not _NVML_AVAILABLE:
|
||||||
return False, "NVML not available"
|
return False, "NVML not available"
|
||||||
try:
|
try:
|
||||||
handle = _get_handle(gpu_index)
|
handle = _get_handle(gpu_index)
|
||||||
pynvml.nvmlDeviceSetFanSpeed_v2(handle, _FAN_INDEX, pct)
|
num_fans = _num_fans(handle)
|
||||||
|
|
||||||
|
if fans is None:
|
||||||
|
targets = list(range(num_fans))
|
||||||
|
strict = False
|
||||||
|
else:
|
||||||
|
targets: list[int] = []
|
||||||
|
for f in fans:
|
||||||
|
try:
|
||||||
|
f = int(f)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False, f"Invalid fan index: {f!r}"
|
||||||
|
if f < 0 or f >= num_fans:
|
||||||
|
return False, f"Fan index {f} out of range (0-{num_fans - 1})"
|
||||||
|
targets.append(f)
|
||||||
|
strict = True
|
||||||
|
if not targets:
|
||||||
|
return False, "No fans selected"
|
||||||
|
|
||||||
|
if not targets:
|
||||||
|
return False, "No fans available on this GPU"
|
||||||
|
|
||||||
|
skipped: list[str] = []
|
||||||
|
for i in targets:
|
||||||
|
try:
|
||||||
|
pynvml.nvmlDeviceSetFanSpeed_v2(handle, i, pct)
|
||||||
|
except pynvml.NVMLError as exc:
|
||||||
|
if strict:
|
||||||
|
log.warning(
|
||||||
|
"set_fan_speed(%d, fan %d, %d): %s", gpu_index, i, pct, exc
|
||||||
|
)
|
||||||
|
return False, str(exc)
|
||||||
|
# All-fans mode: secondary fans may be driver-controlled and
|
||||||
|
# reject manual writes; skip them and report in the message.
|
||||||
|
log.debug("set_fan_speed: fan %d not settable: %s", i, exc)
|
||||||
|
skipped.append(f"fan {i + 1}")
|
||||||
|
if skipped:
|
||||||
|
return (
|
||||||
|
True,
|
||||||
|
f"OK ({len(skipped)} fan(s) not manually controllable: {', '.join(skipped)})",
|
||||||
|
)
|
||||||
return True, "OK"
|
return True, "OK"
|
||||||
except pynvml.NVMLError as exc:
|
except pynvml.NVMLError as exc:
|
||||||
log.warning("set_fan_speed(%d, %d): %s", gpu_index, pct, exc)
|
log.warning("set_fan_speed(%d, %d): %s", gpu_index, pct, exc)
|
||||||
@@ -88,10 +185,11 @@ def set_fan_speed(gpu_index: int, pct: int) -> tuple[bool, str]:
|
|||||||
|
|
||||||
|
|
||||||
def reset_fan(gpu_index: int = 0) -> tuple[bool, str]:
|
def reset_fan(gpu_index: int = 0) -> tuple[bool, str]:
|
||||||
"""Restore automatic fan control.
|
"""Restore automatic fan control for all fans.
|
||||||
|
|
||||||
Tries nvidia-smi --fan=default first (most reliable), then falls back to
|
Tries nvidia-smi --fan=default first (most reliable, resets all fans on
|
||||||
NVML nvmlDeviceSetDefaultFanSpeed_v2.
|
the device), then falls back to NVML nvmlDeviceSetDefaultFanSpeed_v2
|
||||||
|
per fan index.
|
||||||
"""
|
"""
|
||||||
if not _NVML_AVAILABLE:
|
if not _NVML_AVAILABLE:
|
||||||
return False, "NVML not available"
|
return False, "NVML not available"
|
||||||
@@ -102,7 +200,9 @@ def reset_fan(gpu_index: int = 0) -> tuple[bool, str]:
|
|||||||
try:
|
try:
|
||||||
ret = subprocess.run(
|
ret = subprocess.run(
|
||||||
["nvidia-smi", "-i", str(gpu_index), "-fan", "default"],
|
["nvidia-smi", "-i", str(gpu_index), "-fan", "default"],
|
||||||
capture_output=True, text=True, timeout=10,
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
)
|
)
|
||||||
if ret.returncode == 0:
|
if ret.returncode == 0:
|
||||||
return True, "OK"
|
return True, "OK"
|
||||||
@@ -114,28 +214,34 @@ def reset_fan(gpu_index: int = 0) -> tuple[bool, str]:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.debug("nvidia-smi -fan default error: %s", exc)
|
log.debug("nvidia-smi -fan default error: %s", exc)
|
||||||
|
|
||||||
# Fallback: use NVML to reset to default fan speed
|
# Fallback: use NVML to reset every fan to default speed
|
||||||
try:
|
try:
|
||||||
handle = _get_handle(gpu_index)
|
handle = _get_handle(gpu_index)
|
||||||
pynvml.nvmlDeviceSetDefaultFanSpeed_v2(handle, _FAN_INDEX)
|
for i in range(_num_fans(handle)):
|
||||||
|
try:
|
||||||
|
pynvml.nvmlDeviceSetDefaultFanSpeed_v2(handle, i)
|
||||||
|
except pynvml.NVMLError as exc:
|
||||||
|
log.debug("reset_fan: fan %d: %s", i, exc)
|
||||||
return True, "OK"
|
return True, "OK"
|
||||||
except pynvml.NVMLError as exc:
|
except pynvml.NVMLError as exc:
|
||||||
return False, f"Failed to reset fan: {exc}"
|
return False, f"Failed to reset fan: {exc}"
|
||||||
|
|
||||||
|
|
||||||
def get_temp(gpu_index: int = 0) -> Optional[float]:
|
def get_temp(gpu_index: int = 0) -> float | None:
|
||||||
"""Read current GPU temperature in °C."""
|
"""Read current GPU temperature in °C."""
|
||||||
if not _NVML_AVAILABLE:
|
if not _NVML_AVAILABLE:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
handle = _get_handle(gpu_index)
|
handle = _get_handle(gpu_index)
|
||||||
return float(pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU))
|
return float(
|
||||||
|
pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU)
|
||||||
|
)
|
||||||
except pynvml.NVMLError as exc:
|
except pynvml.NVMLError as exc:
|
||||||
log.debug("get_temp: %s", exc)
|
log.debug("get_temp: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def interpolate_fan_speed(curve: List[dict], temp_c: float) -> Optional[int]:
|
def interpolate_fan_speed(curve: list[dict], temp_c: float) -> int | None:
|
||||||
"""Interpolate target fan speed from a curve at a given temperature.
|
"""Interpolate target fan speed from a curve at a given temperature.
|
||||||
|
|
||||||
curve: list of {temp_c: int, fan_pct: int} sorted by temp_c
|
curve: list of {temp_c: int, fan_pct: int} sorted by temp_c
|
||||||
@@ -144,7 +250,10 @@ def interpolate_fan_speed(curve: List[dict], temp_c: float) -> Optional[int]:
|
|||||||
if not curve or len(curve) < 2:
|
if not curve or len(curve) < 2:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
temp = float(temp_c)
|
temp = float(temp_c)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
# Find the two surrounding points
|
# Find the two surrounding points
|
||||||
for i in range(len(curve) - 1):
|
for i in range(len(curve) - 1):
|
||||||
@@ -157,7 +266,10 @@ def interpolate_fan_speed(curve: List[dict], temp_c: float) -> Optional[int]:
|
|||||||
if t0 <= temp <= t1:
|
if t0 <= temp <= t1:
|
||||||
fraction = (temp - t0) / (t1 - t0)
|
fraction = (temp - t0) / (t1 - t0)
|
||||||
result = f0 + fraction * (f1 - f0)
|
result = f0 + fraction * (f1 - f0)
|
||||||
|
try:
|
||||||
return max(0, min(100, int(round(result))))
|
return max(0, min(100, int(round(result))))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
# Outside range: clamp to first or last point
|
# Outside range: clamp to first or last point
|
||||||
if temp <= curve[0]["temp_c"]:
|
if temp <= curve[0]["temp_c"]:
|
||||||
@@ -165,7 +277,7 @@ def interpolate_fan_speed(curve: List[dict], temp_c: float) -> Optional[int]:
|
|||||||
return max(0, min(100, curve[-1]["fan_pct"]))
|
return max(0, min(100, curve[-1]["fan_pct"]))
|
||||||
|
|
||||||
|
|
||||||
def validate_curve(curve: List[dict]) -> tuple[bool, str]:
|
def validate_curve(curve: list[dict]) -> tuple[bool, str]:
|
||||||
"""Validate a fan curve.
|
"""Validate a fan curve.
|
||||||
|
|
||||||
Returns (True, "OK") or (False, error_message).
|
Returns (True, "OK") or (False, error_message).
|
||||||
|
|||||||
+38
-20
@@ -1,12 +1,17 @@
|
|||||||
"""GPU discovery and initialization."""
|
"""GPU discovery and initialization."""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import ctypes
|
import ctypes
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from ..nvapi.bootstrap import query_interface
|
from ..nvapi.bootstrap import query_interface
|
||||||
from ..nvapi.constants import FUNC
|
from ..nvapi.constants import FUNC
|
||||||
from ..nvapi.types import GpuInfo
|
from ..nvapi.types import GpuInfo
|
||||||
|
|
||||||
|
log = logging.getLogger("nvcurve.hal.gpu")
|
||||||
|
|
||||||
|
|
||||||
def init_nvapi() -> None:
|
def init_nvapi() -> None:
|
||||||
"""Initialize NvAPI. Must be called before any GPU operations."""
|
"""Initialize NvAPI. Must be called before any GPU operations."""
|
||||||
@@ -19,7 +24,10 @@ def enumerate_gpus() -> tuple[ctypes.Array, int]:
|
|||||||
"""Return (gpu_handles_array, count). Exits if no GPUs found."""
|
"""Return (gpu_handles_array, count). Exits if no GPUs found."""
|
||||||
gpus = (ctypes.c_void_p * 64)()
|
gpus = (ctypes.c_void_p * 64)()
|
||||||
ngpu = ctypes.c_int32()
|
ngpu = ctypes.c_int32()
|
||||||
query_interface(FUNC["EnumPhysicalGPUs"])(ctypes.byref(gpus), ctypes.byref(ngpu))
|
enum_fn = query_interface(FUNC["EnumPhysicalGPUs"])
|
||||||
|
if enum_fn is None:
|
||||||
|
raise RuntimeError("NvAPI function EnumPhysicalGPUs not available")
|
||||||
|
enum_fn(ctypes.byref(gpus), ctypes.byref(ngpu))
|
||||||
if ngpu.value == 0:
|
if ngpu.value == 0:
|
||||||
print("No NVIDIA GPUs found")
|
print("No NVIDIA GPUs found")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -29,7 +37,10 @@ def enumerate_gpus() -> tuple[ctypes.Array, int]:
|
|||||||
def get_gpu_name(gpu) -> str:
|
def get_gpu_name(gpu) -> str:
|
||||||
"""Return the full name string for a GPU handle."""
|
"""Return the full name string for a GPU handle."""
|
||||||
name_buf = ctypes.create_string_buffer(256)
|
name_buf = ctypes.create_string_buffer(256)
|
||||||
query_interface(FUNC["GetFullName"])(gpu, name_buf)
|
fn = query_interface(FUNC["GetFullName"])
|
||||||
|
if fn is None:
|
||||||
|
raise RuntimeError("NvAPI function GetFullName not available")
|
||||||
|
fn(gpu, name_buf)
|
||||||
return name_buf.value.decode(errors="replace")
|
return name_buf.value.decode(errors="replace")
|
||||||
|
|
||||||
|
|
||||||
@@ -40,38 +51,45 @@ def discover_gpus() -> list[GpuInfo]:
|
|||||||
infos = []
|
infos = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pynvml
|
import pynvml as _pynvml
|
||||||
pynvml.nvmlInit()
|
|
||||||
has_nvml = True
|
_pynvml.nvmlInit()
|
||||||
except Exception:
|
except Exception:
|
||||||
has_nvml = False
|
_pynvml = None
|
||||||
|
# Aliased as Any so attribute access is not flagged when the import failed.
|
||||||
|
pynvml: Any = _pynvml
|
||||||
|
|
||||||
for i in range(count):
|
for i in range(count):
|
||||||
name = get_gpu_name(gpus[i])
|
name = get_gpu_name(gpus[i])
|
||||||
uuid = None
|
uuid = None
|
||||||
pci_bus_id = None
|
pci_bus_id = None
|
||||||
if has_nvml:
|
if pynvml is not None:
|
||||||
try:
|
try:
|
||||||
handle = pynvml.nvmlDeviceGetHandleByIndex(i)
|
handle = pynvml.nvmlDeviceGetHandleByIndex(i)
|
||||||
uuid = pynvml.nvmlDeviceGetUUID(handle)
|
raw_uuid = pynvml.nvmlDeviceGetUUID(handle)
|
||||||
# NVML might return bytes
|
# NVML might return bytes
|
||||||
if isinstance(uuid, bytes):
|
if isinstance(raw_uuid, bytes):
|
||||||
uuid = uuid.decode('utf-8', errors='ignore')
|
uuid = raw_uuid.decode("utf-8", errors="ignore")
|
||||||
|
elif raw_uuid is not None:
|
||||||
|
uuid = str(raw_uuid)
|
||||||
pci_info = pynvml.nvmlDeviceGetPciInfo(handle)
|
pci_info = pynvml.nvmlDeviceGetPciInfo(handle)
|
||||||
# Parse something like "00000000:01:00.0" -> bus is 1
|
# Parse something like "00000000:01:00.0" -> bus is 1.
|
||||||
if isinstance(pci_info.bus, bytes):
|
# PCI bus numbers are hex by convention (pynvml's field is an
|
||||||
pci_bus_id = int(pci_info.bus.decode('utf-8', errors='ignore'), 16)
|
# int; the str/bytes branches are defensive).
|
||||||
|
bus = pci_info.bus
|
||||||
|
if isinstance(bus, bytes):
|
||||||
|
pci_bus_id = int(bus.decode("utf-8", errors="ignore"), 16)
|
||||||
|
elif isinstance(bus, str):
|
||||||
|
pci_bus_id = int(bus, 16)
|
||||||
else:
|
else:
|
||||||
pci_bus_id = pci_info.bus
|
pci_bus_id = int(bus)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
log.debug("NVML query for GPU %d failed: %s", i, exc)
|
||||||
infos.append(GpuInfo(name=name, index=i, uuid=uuid, pci_bus_id=pci_bus_id))
|
infos.append(GpuInfo(name=name, index=i, uuid=uuid, pci_bus_id=pci_bus_id))
|
||||||
|
|
||||||
if has_nvml:
|
if pynvml is not None:
|
||||||
try:
|
with contextlib.suppress(Exception):
|
||||||
pynvml.nvmlShutdown()
|
pynvml.nvmlShutdown()
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return infos
|
return infos
|
||||||
|
|
||||||
|
|||||||
+64
-27
@@ -11,21 +11,26 @@ that are explicitly specified, leaving others unchanged on hardware.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import ctypes
|
import ctypes
|
||||||
import subprocess
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
import subprocess
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pynvml
|
import pynvml as _pynvml_import
|
||||||
|
|
||||||
_NVML_AVAILABLE = True
|
_NVML_AVAILABLE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
_pynvml_import = None
|
||||||
_NVML_AVAILABLE = False
|
_NVML_AVAILABLE = False
|
||||||
|
|
||||||
|
# Aliased as Any so attribute access is not flagged when the import failed.
|
||||||
|
pynvml: Any = _pynvml_import
|
||||||
|
|
||||||
log = logging.getLogger("nvcurve.hal.limits")
|
log = logging.getLogger("nvcurve.hal.limits")
|
||||||
|
|
||||||
# ── NVML library / handle helpers ─────────────────────────────────────────────
|
# ── NVML library / handle helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
_nvml_lib: Optional[ctypes.CDLL] = None
|
_nvml_lib: ctypes.CDLL | None = None
|
||||||
|
|
||||||
|
|
||||||
def _nvml_cdll() -> ctypes.CDLL:
|
def _nvml_cdll() -> ctypes.CDLL:
|
||||||
@@ -39,7 +44,7 @@ def _nvml_cdll() -> ctypes.CDLL:
|
|||||||
lib = getattr(mod, "_lib", None) or getattr(mod, "_nvmlLib", None)
|
lib = getattr(mod, "_lib", None) or getattr(mod, "_nvmlLib", None)
|
||||||
if lib is not None:
|
if lib is not None:
|
||||||
_nvml_lib = lib
|
_nvml_lib = lib
|
||||||
return _nvml_lib
|
return lib
|
||||||
_nvml_lib = ctypes.CDLL("libnvidia-ml.so.1")
|
_nvml_lib = ctypes.CDLL("libnvidia-ml.so.1")
|
||||||
return _nvml_lib
|
return _nvml_lib
|
||||||
|
|
||||||
@@ -53,9 +58,10 @@ def _get_handle(gpu_index: int):
|
|||||||
|
|
||||||
# ── Power limit ───────────────────────────────────────────────────────────────
|
# ── Power limit ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def get_power_limit(gpu_index: int = 0) -> dict:
|
def get_power_limit(gpu_index: int = 0) -> dict:
|
||||||
"""Return dict with power_limit_w, default_power_limit_w, min_power_limit_w, max_power_limit_w."""
|
"""Return dict with power_limit_w, default_power_limit_w, min_power_limit_w, max_power_limit_w."""
|
||||||
out = {
|
out: dict[str, int | None] = {
|
||||||
"power_limit_w": None,
|
"power_limit_w": None,
|
||||||
"default_power_limit_w": None,
|
"default_power_limit_w": None,
|
||||||
"min_power_limit_w": None,
|
"min_power_limit_w": None,
|
||||||
@@ -71,8 +77,8 @@ def get_power_limit(gpu_index: int = 0) -> dict:
|
|||||||
try:
|
try:
|
||||||
default = pynvml.nvmlDeviceGetPowerManagementDefaultLimit(handle)
|
default = pynvml.nvmlDeviceGetPowerManagementDefaultLimit(handle)
|
||||||
out["default_power_limit_w"] = default // 1000
|
out["default_power_limit_w"] = default // 1000
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
log.debug("nvmlDeviceGetPowerManagementDefaultLimit: %s", exc)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.warning("get_power_limit: %s", exc)
|
log.warning("get_power_limit: %s", exc)
|
||||||
return out
|
return out
|
||||||
@@ -89,7 +95,8 @@ def set_power_limit(limit_w: int, gpu_index: int = 0) -> tuple[bool, str]:
|
|||||||
|
|
||||||
ret = subprocess.run(
|
ret = subprocess.run(
|
||||||
["nvidia-smi", "-i", str(gpu_index), "-pl", str(limit_w)],
|
["nvidia-smi", "-i", str(gpu_index), "-pl", str(limit_w)],
|
||||||
capture_output=True, text=True,
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
)
|
)
|
||||||
if ret.returncode == 0:
|
if ret.returncode == 0:
|
||||||
return True, "OK"
|
return True, "OK"
|
||||||
@@ -111,6 +118,7 @@ def set_power_limit(limit_w: int, gpu_index: int = 0) -> tuple[bool, str]:
|
|||||||
# pynvml (nvidia-ml-py ≥ 12) exposes c_nvmlClockOffset_t and nvmlClockOffset_v1
|
# pynvml (nvidia-ml-py ≥ 12) exposes c_nvmlClockOffset_t and nvmlClockOffset_v1
|
||||||
# as ctypes objects; we use them when available and fall back to our own definition.
|
# as ctypes objects; we use them when available and fall back to our own definition.
|
||||||
|
|
||||||
|
|
||||||
class _ClockOffset(ctypes.Structure):
|
class _ClockOffset(ctypes.Structure):
|
||||||
_fields_ = [
|
_fields_ = [
|
||||||
("version", ctypes.c_uint),
|
("version", ctypes.c_uint),
|
||||||
@@ -119,6 +127,7 @@ class _ClockOffset(ctypes.Structure):
|
|||||||
("clockOffsetMHz", ctypes.c_int),
|
("clockOffsetMHz", ctypes.c_int),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
_CLOCK_OFFSET_VER = (1 << 24) | ctypes.sizeof(_ClockOffset) # = 0x01000010 (16 bytes)
|
_CLOCK_OFFSET_VER = (1 << 24) | ctypes.sizeof(_ClockOffset) # = 0x01000010 (16 bytes)
|
||||||
|
|
||||||
# NVML clock-type constants (same values as pynvml).
|
# NVML clock-type constants (same values as pynvml).
|
||||||
@@ -126,7 +135,9 @@ _NVML_CLOCK_GRAPHICS = 0
|
|||||||
_NVML_CLOCK_MEM = 2
|
_NVML_CLOCK_MEM = 2
|
||||||
|
|
||||||
|
|
||||||
def _make_clock_offset(clock_type: int, pstate: int = 0, offset_mhz: int = 0) -> ctypes.Structure:
|
def _make_clock_offset(
|
||||||
|
clock_type: int, pstate: int = 0, offset_mhz: int = 0
|
||||||
|
) -> ctypes.Structure:
|
||||||
"""Return a populated nvmlClockOffset_t struct, using pynvml's type when available."""
|
"""Return a populated nvmlClockOffset_t struct, using pynvml's type when available."""
|
||||||
if hasattr(pynvml, "c_nvmlClockOffset_t") and hasattr(pynvml, "nvmlClockOffset_v1"):
|
if hasattr(pynvml, "c_nvmlClockOffset_t") and hasattr(pynvml, "nvmlClockOffset_v1"):
|
||||||
info = pynvml.c_nvmlClockOffset_t()
|
info = pynvml.c_nvmlClockOffset_t()
|
||||||
@@ -158,7 +169,7 @@ def get_clock_offsets(gpu_index: int = 0) -> dict:
|
|||||||
Keys: gpc_offset_mhz, mem_offset_mhz (both int or None on failure).
|
Keys: gpc_offset_mhz, mem_offset_mhz (both int or None on failure).
|
||||||
Calls nvmlDeviceGetClockOffsets once per clock domain (GRAPHICS, MEM).
|
Calls nvmlDeviceGetClockOffsets once per clock domain (GRAPHICS, MEM).
|
||||||
"""
|
"""
|
||||||
out = {"gpc_offset_mhz": None, "mem_offset_mhz": None}
|
out: dict[str, int | None] = {"gpc_offset_mhz": None, "mem_offset_mhz": None}
|
||||||
if not _NVML_AVAILABLE:
|
if not _NVML_AVAILABLE:
|
||||||
return out
|
return out
|
||||||
try:
|
try:
|
||||||
@@ -167,11 +178,15 @@ def get_clock_offsets(gpu_index: int = 0) -> dict:
|
|||||||
# Try pynvml wrapper first (nvidia-ml-py ≥ 12 exposes it correctly).
|
# Try pynvml wrapper first (nvidia-ml-py ≥ 12 exposes it correctly).
|
||||||
# Fall back to ctypes-direct if pynvml doesn't have it.
|
# Fall back to ctypes-direct if pynvml doesn't have it.
|
||||||
_pynvml_get = getattr(pynvml, "nvmlDeviceGetClockOffsets", None)
|
_pynvml_get = getattr(pynvml, "nvmlDeviceGetClockOffsets", None)
|
||||||
fn_get = _try_nvml_fn("nvmlDeviceGetClockOffsets") if _pynvml_get is None else None
|
fn_get = (
|
||||||
|
_try_nvml_fn("nvmlDeviceGetClockOffsets") if _pynvml_get is None else None
|
||||||
|
)
|
||||||
|
|
||||||
used_new_api = False
|
used_new_api = False
|
||||||
for clock_type, key in ((_NVML_CLOCK_GRAPHICS, "gpc_offset_mhz"),
|
for clock_type, key in (
|
||||||
(_NVML_CLOCK_MEM, "mem_offset_mhz")):
|
(_NVML_CLOCK_GRAPHICS, "gpc_offset_mhz"),
|
||||||
|
(_NVML_CLOCK_MEM, "mem_offset_mhz"),
|
||||||
|
):
|
||||||
info = _make_clock_offset(clock_type, pstate=0)
|
info = _make_clock_offset(clock_type, pstate=0)
|
||||||
try:
|
try:
|
||||||
if _pynvml_get is not None:
|
if _pynvml_get is not None:
|
||||||
@@ -184,7 +199,9 @@ def get_clock_offsets(gpu_index: int = 0) -> dict:
|
|||||||
out[key] = int(info.clockOffsetMHz)
|
out[key] = int(info.clockOffsetMHz)
|
||||||
used_new_api = True
|
used_new_api = True
|
||||||
else:
|
else:
|
||||||
log.debug("nvmlDeviceGetClockOffsets(type=%d) returned %d", clock_type, rc)
|
log.debug(
|
||||||
|
"nvmlDeviceGetClockOffsets(type=%d) returned %d", clock_type, rc
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.debug("nvmlDeviceGetClockOffsets(type=%d): %s", clock_type, exc)
|
log.debug("nvmlDeviceGetClockOffsets(type=%d): %s", clock_type, exc)
|
||||||
|
|
||||||
@@ -200,7 +217,9 @@ def get_clock_offsets(gpu_index: int = 0) -> dict:
|
|||||||
if hasattr(pynvml, "nvmlDeviceGetMemClkVfOffset"):
|
if hasattr(pynvml, "nvmlDeviceGetMemClkVfOffset"):
|
||||||
try:
|
try:
|
||||||
res = pynvml.nvmlDeviceGetMemClkVfOffset(handle)
|
res = pynvml.nvmlDeviceGetMemClkVfOffset(handle)
|
||||||
out["mem_offset_mhz"] = int(res[0] if isinstance(res, (list, tuple)) else res)
|
out["mem_offset_mhz"] = int(
|
||||||
|
res[0] if isinstance(res, (list, tuple)) else res
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.debug("nvmlDeviceGetMemClkVfOffset: %s", exc)
|
log.debug("nvmlDeviceGetMemClkVfOffset: %s", exc)
|
||||||
|
|
||||||
@@ -210,8 +229,8 @@ def get_clock_offsets(gpu_index: int = 0) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def set_clock_offsets(
|
def set_clock_offsets(
|
||||||
gpc_offset_mhz: Optional[int] = None,
|
gpc_offset_mhz: int | None = None,
|
||||||
mem_offset_mhz: Optional[int] = None,
|
mem_offset_mhz: int | None = None,
|
||||||
gpu_index: int = 0,
|
gpu_index: int = 0,
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
"""Set clock offsets (MHz) for the specified domains only.
|
"""Set clock offsets (MHz) for the specified domains only.
|
||||||
@@ -235,20 +254,35 @@ def set_clock_offsets(
|
|||||||
domains.append((_NVML_CLOCK_MEM, mem_offset_mhz))
|
domains.append((_NVML_CLOCK_MEM, mem_offset_mhz))
|
||||||
|
|
||||||
_pynvml_set = getattr(pynvml, "nvmlDeviceSetClockOffsets", None)
|
_pynvml_set = getattr(pynvml, "nvmlDeviceSetClockOffsets", None)
|
||||||
fn_set = _try_nvml_fn("nvmlDeviceSetClockOffsets") if _pynvml_set is None else None
|
fn_set = (
|
||||||
|
_try_nvml_fn("nvmlDeviceSetClockOffsets") if _pynvml_set is None else None
|
||||||
|
)
|
||||||
|
|
||||||
if _pynvml_set is not None or fn_set is not None:
|
if _pynvml_set is not None or fn_set is not None:
|
||||||
all_ok = True
|
all_ok = True
|
||||||
for clock_type, offset in domains:
|
for clock_type, offset in domains:
|
||||||
info = _make_clock_offset(clock_type, pstate=0, offset_mhz=offset)
|
info = _make_clock_offset(clock_type, pstate=0, offset_mhz=offset)
|
||||||
try:
|
try:
|
||||||
rc = _pynvml_set(handle, ctypes.byref(info)) if _pynvml_set else fn_set(handle, ctypes.byref(info))
|
if _pynvml_set is not None:
|
||||||
|
rc = _pynvml_set(handle, ctypes.byref(info))
|
||||||
|
elif fn_set is not None:
|
||||||
|
rc = fn_set(handle, ctypes.byref(info))
|
||||||
|
else:
|
||||||
|
break
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
log.debug("nvmlDeviceSetClockOffsets(type=%d) returned %d — trying fallback", clock_type, rc)
|
log.debug(
|
||||||
|
"nvmlDeviceSetClockOffsets(type=%d) returned %d — trying fallback",
|
||||||
|
clock_type,
|
||||||
|
rc,
|
||||||
|
)
|
||||||
all_ok = False
|
all_ok = False
|
||||||
break
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.debug("nvmlDeviceSetClockOffsets(type=%d): %s — trying fallback", clock_type, exc)
|
log.debug(
|
||||||
|
"nvmlDeviceSetClockOffsets(type=%d): %s — trying fallback",
|
||||||
|
clock_type,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
all_ok = False
|
all_ok = False
|
||||||
break
|
break
|
||||||
if all_ok:
|
if all_ok:
|
||||||
@@ -257,12 +291,16 @@ def set_clock_offsets(
|
|||||||
|
|
||||||
# Deprecated per-domain fallback (works on Blackwell/driver 590.x).
|
# Deprecated per-domain fallback (works on Blackwell/driver 590.x).
|
||||||
errs = []
|
errs = []
|
||||||
if gpc_offset_mhz is not None and hasattr(pynvml, "nvmlDeviceSetGpcClkVfOffset"):
|
if gpc_offset_mhz is not None and hasattr(
|
||||||
|
pynvml, "nvmlDeviceSetGpcClkVfOffset"
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
pynvml.nvmlDeviceSetGpcClkVfOffset(handle, gpc_offset_mhz)
|
pynvml.nvmlDeviceSetGpcClkVfOffset(handle, gpc_offset_mhz)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
errs.append(f"GPC: {exc}")
|
errs.append(f"GPC: {exc}")
|
||||||
if mem_offset_mhz is not None and hasattr(pynvml, "nvmlDeviceSetMemClkVfOffset"):
|
if mem_offset_mhz is not None and hasattr(
|
||||||
|
pynvml, "nvmlDeviceSetMemClkVfOffset"
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
pynvml.nvmlDeviceSetMemClkVfOffset(handle, mem_offset_mhz)
|
pynvml.nvmlDeviceSetMemClkVfOffset(handle, mem_offset_mhz)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -278,6 +316,7 @@ def set_clock_offsets(
|
|||||||
|
|
||||||
# ── Range queries ─────────────────────────────────────────────────────────────
|
# ── Range queries ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def get_mem_offset_range(gpu_index: int = 0) -> dict:
|
def get_mem_offset_range(gpu_index: int = 0) -> dict:
|
||||||
"""Return the min/max allowed memory clock offset (MHz).
|
"""Return the min/max allowed memory clock offset (MHz).
|
||||||
|
|
||||||
@@ -285,7 +324,7 @@ def get_mem_offset_range(gpu_index: int = 0) -> dict:
|
|||||||
Uses nvmlDeviceGetMemClkMinMaxVfOffset; falls back to observed RTX values.
|
Uses nvmlDeviceGetMemClkMinMaxVfOffset; falls back to observed RTX values.
|
||||||
"""
|
"""
|
||||||
# Observed RTX 5090 defaults (NvAPI GetClockBoostRanges says -1000/+3000).
|
# Observed RTX 5090 defaults (NvAPI GetClockBoostRanges says -1000/+3000).
|
||||||
out = {"min_mem_offset_mhz": -2000, "max_mem_offset_mhz": 3000}
|
out: dict[str, int] = {"min_mem_offset_mhz": -2000, "max_mem_offset_mhz": 3000}
|
||||||
if not _NVML_AVAILABLE:
|
if not _NVML_AVAILABLE:
|
||||||
return out
|
return out
|
||||||
try:
|
try:
|
||||||
@@ -317,5 +356,3 @@ def get_mem_offset_range(gpu_index: int = 0) -> dict:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.debug("get_mem_offset_range: %s", exc)
|
log.debug("get_mem_offset_range: %s", exc)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -162,6 +162,20 @@ def _nvml_read(gpu_index: int) -> dict:
|
|||||||
with contextlib.suppress(_pynvml.NVMLError):
|
with contextlib.suppress(_pynvml.NVMLError):
|
||||||
out["fan_pct"] = float(_pynvml.nvmlDeviceGetFanSpeed(handle))
|
out["fan_pct"] = float(_pynvml.nvmlDeviceGetFanSpeed(handle))
|
||||||
|
|
||||||
|
# Per-fan speeds via the v2 API (fan_pct above stays fan 0 for legacy clients).
|
||||||
|
try:
|
||||||
|
num_fans = int(_pynvml.nvmlDeviceGetNumFans(handle))
|
||||||
|
fan_list: list[float | None] = []
|
||||||
|
for i in range(num_fans):
|
||||||
|
try:
|
||||||
|
fan_list.append(float(_pynvml.nvmlDeviceGetFanSpeed_v2(handle, i)))
|
||||||
|
except _pynvml.NVMLError:
|
||||||
|
fan_list.append(None)
|
||||||
|
if fan_list:
|
||||||
|
out["fans"] = fan_list
|
||||||
|
except _pynvml.NVMLError:
|
||||||
|
pass
|
||||||
|
|
||||||
with contextlib.suppress(_pynvml.NVMLError):
|
with contextlib.suppress(_pynvml.NVMLError):
|
||||||
out["throttle_reasons"] = int(
|
out["throttle_reasons"] = int(
|
||||||
_pynvml.nvmlDeviceGetCurrentClocksThrottleReasons(handle)
|
_pynvml.nvmlDeviceGetCurrentClocksThrottleReasons(handle)
|
||||||
|
|||||||
+54
-14
@@ -2,18 +2,20 @@
|
|||||||
|
|
||||||
import ctypes
|
import ctypes
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import struct
|
import struct
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from ..nvapi.bootstrap import nvcall_raw
|
from ..nvapi.bootstrap import nvcall_raw
|
||||||
from ..nvapi.constants import FUNC, CT_SIZE, CT_BASE, CT_STRIDE, CT_DELTA_OFF, CT_POINTS
|
from ..nvapi.constants import CT_BASE, CT_DELTA_OFF, CT_SIZE, CT_STRIDE, FUNC
|
||||||
from ..nvapi.types import SnapshotInfo
|
from ..nvapi.types import SnapshotInfo
|
||||||
from .vfcurve import read_clock_table_raw, get_boost_mask
|
from .vfcurve import get_boost_mask, read_clock_table_raw
|
||||||
|
|
||||||
|
log = logging.getLogger("nvcurve.hal.snapshot")
|
||||||
|
|
||||||
|
|
||||||
def save(gpu, gpu_name: str, snapshot_dir: str, max_snapshots: int = 0) -> Optional[str]:
|
def save(gpu, gpu_name: str, snapshot_dir: str, max_snapshots: int = 0) -> str | None:
|
||||||
"""Save the current ClockBoostTable to disk.
|
"""Save the current ClockBoostTable to disk.
|
||||||
|
|
||||||
Writes both a binary .bin file and a human-readable .json metadata file.
|
Writes both a binary .bin file and a human-readable .json metadata file.
|
||||||
@@ -25,13 +27,21 @@ def save(gpu, gpu_name: str, snapshot_dir: str, max_snapshots: int = 0) -> Optio
|
|||||||
print(f"Failed to read ClockBoostTable: {err}")
|
print(f"Failed to read ClockBoostTable: {err}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
os.makedirs(snapshot_dir, exist_ok=True)
|
os.makedirs(snapshot_dir, exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Failed to create snapshot dir {snapshot_dir}: {exc}")
|
||||||
|
return None
|
||||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
bin_path = os.path.join(snapshot_dir, f"clock_boost_table_{ts}.bin")
|
bin_path = os.path.join(snapshot_dir, f"clock_boost_table_{ts}.bin")
|
||||||
meta_path = os.path.join(snapshot_dir, f"clock_boost_table_{ts}.json")
|
meta_path = os.path.join(snapshot_dir, f"clock_boost_table_{ts}.json")
|
||||||
|
|
||||||
|
try:
|
||||||
with open(bin_path, "wb") as f:
|
with open(bin_path, "wb") as f:
|
||||||
f.write(raw)
|
f.write(raw)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Failed to write snapshot {bin_path}: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
offsets = []
|
offsets = []
|
||||||
max_entries = (len(raw) - CT_BASE) // CT_STRIDE
|
max_entries = (len(raw) - CT_BASE) // CT_STRIDE
|
||||||
@@ -48,10 +58,14 @@ def save(gpu, gpu_name: str, snapshot_dir: str, max_snapshots: int = 0) -> Optio
|
|||||||
"offsets_kHz": offsets,
|
"offsets_kHz": offsets,
|
||||||
"nonzero_offsets": sum(1 for o in offsets if o != 0),
|
"nonzero_offsets": sum(1 for o in offsets if o != 0),
|
||||||
}
|
}
|
||||||
|
try:
|
||||||
with open(meta_path, "w") as f:
|
with open(meta_path, "w") as f:
|
||||||
json.dump(meta, f, indent=2)
|
json.dump(meta, f, indent=2)
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Failed to write snapshot metadata {meta_path}: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
print(f"Snapshot saved:")
|
print("Snapshot saved:")
|
||||||
print(f" Binary: {bin_path}")
|
print(f" Binary: {bin_path}")
|
||||||
print(f" Metadata: {meta_path}")
|
print(f" Metadata: {meta_path}")
|
||||||
print(f" Size: {len(raw)} bytes")
|
print(f" Size: {len(raw)} bytes")
|
||||||
@@ -65,20 +79,22 @@ def save(gpu, gpu_name: str, snapshot_dir: str, max_snapshots: int = 0) -> Optio
|
|||||||
|
|
||||||
def _prune_snapshots(snapshot_dir: str, max_snapshots: int) -> None:
|
def _prune_snapshots(snapshot_dir: str, max_snapshots: int) -> None:
|
||||||
"""Delete oldest snapshots (both .bin and .json) to stay within max_snapshots."""
|
"""Delete oldest snapshots (both .bin and .json) to stay within max_snapshots."""
|
||||||
bins = sorted(
|
# Oldest first (lexicographic = chronological for our timestamp format).
|
||||||
f for f in os.listdir(snapshot_dir) if f.endswith(".bin")
|
try:
|
||||||
) # oldest first (lexicographic = chronological for our timestamp format)
|
bins = sorted(f for f in os.listdir(snapshot_dir) if f.endswith(".bin"))
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
excess = len(bins) - max_snapshots
|
excess = len(bins) - max_snapshots
|
||||||
for fname in bins[:excess]:
|
for fname in bins[:excess]:
|
||||||
stem = fname[:-4] # strip .bin
|
stem = fname[:-4] # strip .bin
|
||||||
for ext in (".bin", ".json"):
|
for ext in (".bin", ".json"):
|
||||||
try:
|
try:
|
||||||
os.remove(os.path.join(snapshot_dir, stem + ext))
|
os.remove(os.path.join(snapshot_dir, stem + ext))
|
||||||
except OSError:
|
except OSError as exc:
|
||||||
pass
|
log.debug("Could not remove %s: %s", stem + ext, exc)
|
||||||
|
|
||||||
|
|
||||||
def restore(gpu, snapshot_dir: str, filepath: str = None) -> bool:
|
def restore(gpu, snapshot_dir: str, filepath: str | None = None) -> bool:
|
||||||
"""Restore a ClockBoostTable snapshot from disk.
|
"""Restore a ClockBoostTable snapshot from disk.
|
||||||
|
|
||||||
If no filepath is given, uses the most recent snapshot in snapshot_dir.
|
If no filepath is given, uses the most recent snapshot in snapshot_dir.
|
||||||
@@ -88,10 +104,14 @@ def restore(gpu, snapshot_dir: str, filepath: str = None) -> bool:
|
|||||||
if not os.path.isdir(snapshot_dir):
|
if not os.path.isdir(snapshot_dir):
|
||||||
print(f"No snapshots found in {snapshot_dir}")
|
print(f"No snapshots found in {snapshot_dir}")
|
||||||
return False
|
return False
|
||||||
|
try:
|
||||||
bins = sorted(
|
bins = sorted(
|
||||||
[f for f in os.listdir(snapshot_dir) if f.endswith(".bin")],
|
[f for f in os.listdir(snapshot_dir) if f.endswith(".bin")],
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
|
except OSError:
|
||||||
|
print(f"No snapshots found in {snapshot_dir}")
|
||||||
|
return False
|
||||||
if not bins:
|
if not bins:
|
||||||
print(f"No snapshot .bin files in {snapshot_dir}")
|
print(f"No snapshot .bin files in {snapshot_dir}")
|
||||||
return False
|
return False
|
||||||
@@ -101,8 +121,21 @@ def restore(gpu, snapshot_dir: str, filepath: str = None) -> bool:
|
|||||||
print(f"Snapshot file not found: {filepath}")
|
print(f"Snapshot file not found: {filepath}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Contain the path inside the snapshot directory — callers (in
|
||||||
|
# particular the HTTP API) must not be able to point the restore at
|
||||||
|
# arbitrary files on the filesystem.
|
||||||
|
snap_dir = os.path.realpath(snapshot_dir)
|
||||||
|
resolved = os.path.realpath(filepath)
|
||||||
|
if not resolved.startswith(snap_dir + os.sep):
|
||||||
|
print(f"Snapshot path outside snapshot directory: {filepath}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
with open(filepath, "rb") as f:
|
with open(filepath, "rb") as f:
|
||||||
raw = f.read()
|
raw = f.read()
|
||||||
|
except OSError as exc:
|
||||||
|
print(f"Failed to read snapshot {filepath}: {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
if len(raw) != CT_SIZE:
|
if len(raw) != CT_SIZE:
|
||||||
print(f"Snapshot size mismatch: expected {CT_SIZE}, got {len(raw)}")
|
print(f"Snapshot size mismatch: expected {CT_SIZE}, got {len(raw)}")
|
||||||
@@ -134,8 +167,13 @@ def list_snapshots(snapshot_dir: str) -> list[SnapshotInfo]:
|
|||||||
if not os.path.isdir(snapshot_dir):
|
if not os.path.isdir(snapshot_dir):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
fnames = sorted(os.listdir(snapshot_dir), reverse=True)
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for fname in sorted(os.listdir(snapshot_dir), reverse=True):
|
for fname in fnames:
|
||||||
if not fname.endswith(".json"):
|
if not fname.endswith(".json"):
|
||||||
continue
|
continue
|
||||||
meta_path = os.path.join(snapshot_dir, fname)
|
meta_path = os.path.join(snapshot_dir, fname)
|
||||||
@@ -143,13 +181,15 @@ def list_snapshots(snapshot_dir: str) -> list[SnapshotInfo]:
|
|||||||
with open(meta_path) as f:
|
with open(meta_path) as f:
|
||||||
meta = json.load(f)
|
meta = json.load(f)
|
||||||
bin_path = meta.get("file", meta_path.replace(".json", ".bin"))
|
bin_path = meta.get("file", meta_path.replace(".json", ".bin"))
|
||||||
results.append(SnapshotInfo(
|
results.append(
|
||||||
|
SnapshotInfo(
|
||||||
filepath=bin_path,
|
filepath=bin_path,
|
||||||
timestamp=meta.get("timestamp", ""),
|
timestamp=meta.get("timestamp", ""),
|
||||||
gpu=meta.get("gpu", ""),
|
gpu=meta.get("gpu", ""),
|
||||||
nonzero_offsets=meta.get("nonzero_offsets", 0),
|
nonzero_offsets=meta.get("nonzero_offsets", 0),
|
||||||
size=meta.get("size", 0),
|
size=meta.get("size", 0),
|
||||||
))
|
)
|
||||||
|
)
|
||||||
except (json.JSONDecodeError, KeyError):
|
except (json.JSONDecodeError, KeyError):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class MonitoringSample:
|
|||||||
pcie_link_width: int | None = None # Current PCIe link width (x1..x16)
|
pcie_link_width: int | None = None # Current PCIe link width (x1..x16)
|
||||||
pcie_link_generation: int | None = None # Current PCIe link generation (1..5)
|
pcie_link_generation: int | None = None # Current PCIe link generation (1..5)
|
||||||
mem_temp_c: float | None = None # VRAM temperature (if the GPU exposes it)
|
mem_temp_c: float | None = None # VRAM temperature (if the GPU exposes it)
|
||||||
|
fans: list[float | None] | None = None # Per-fan speed % (index-aligned)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
+73
-22
@@ -21,12 +21,12 @@ def _gpu_stable_key(info) -> str:
|
|||||||
|
|
||||||
def apply_profile(gpu_index: int, name: str, cfg) -> list[str]:
|
def apply_profile(gpu_index: int, name: str, cfg) -> list[str]:
|
||||||
"""Apply a named profile to the given GPU. Returns a list of error strings."""
|
"""Apply a named profile to the given GPU. Returns a list of error strings."""
|
||||||
from .native import load_profile
|
|
||||||
from ..hal.gpu import get_gpu
|
from ..hal.gpu import get_gpu
|
||||||
from ..hal.limits import set_clock_offsets, set_power_limit
|
from ..hal.limits import set_clock_offsets, set_power_limit
|
||||||
from ..hal.vfcurve import write_offsets, reset_offsets
|
|
||||||
from ..hal.snapshot import save as snapshot_save
|
from ..hal.snapshot import save as snapshot_save
|
||||||
|
from ..hal.vfcurve import reset_offsets, write_offsets
|
||||||
from ..safety import validate_write
|
from ..safety import validate_write
|
||||||
|
from .native import load_profile
|
||||||
|
|
||||||
safe_name = "".join(c for c in name if c.isalnum() or c in " _-()").strip()
|
safe_name = "".join(c for c in name if c.isalnum() or c in " _-()").strip()
|
||||||
filepath = os.path.join(cfg.profile_dir, f"{safe_name}.json")
|
filepath = os.path.join(cfg.profile_dir, f"{safe_name}.json")
|
||||||
@@ -48,14 +48,20 @@ def apply_profile(gpu_index: int, name: str, cfg) -> list[str]:
|
|||||||
errs.append(f"Power limit: {msg}")
|
errs.append(f"Power limit: {msg}")
|
||||||
|
|
||||||
if profile.curve_deltas:
|
if profile.curve_deltas:
|
||||||
|
try:
|
||||||
deltas = {int(k): v for k, v in profile.curve_deltas.items()}
|
deltas = {int(k): v for k, v in profile.curve_deltas.items()}
|
||||||
|
except ValueError:
|
||||||
|
errs.append("Curve: invalid point keys in profile")
|
||||||
|
else:
|
||||||
errors = validate_write(deltas, cfg.max_delta_khz)
|
errors = validate_write(deltas, cfg.max_delta_khz)
|
||||||
if errors:
|
if errors:
|
||||||
errs.append("Curve: " + "; ".join(errors))
|
errs.append("Curve: " + "; ".join(errors))
|
||||||
else:
|
else:
|
||||||
if cfg.auto_snapshot:
|
if cfg.auto_snapshot:
|
||||||
try:
|
try:
|
||||||
snapshot_save(gpu, gpu_name, cfg.snapshot_dir, cfg.max_snapshots)
|
snapshot_save(
|
||||||
|
gpu, gpu_name, cfg.snapshot_dir, cfg.max_snapshots
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.warning("Auto-snapshot failed: %s", exc)
|
log.warning("Auto-snapshot failed: %s", exc)
|
||||||
ret, desc = write_offsets(gpu, deltas)
|
ret, desc = write_offsets(gpu, deltas)
|
||||||
@@ -69,9 +75,9 @@ def apply_profile(gpu_index: int, name: str, cfg) -> list[str]:
|
|||||||
|
|
||||||
def apply_with_retry(gpu_index: int, name: str, cfg, max_retries: int = 3) -> bool:
|
def apply_with_retry(gpu_index: int, name: str, cfg, max_retries: int = 3) -> bool:
|
||||||
"""Apply a named profile with read-back verification, retrying on mismatch."""
|
"""Apply a named profile with read-back verification, retrying on mismatch."""
|
||||||
from .native import load_profile
|
|
||||||
from ..hal.gpu import get_gpu
|
from ..hal.gpu import get_gpu
|
||||||
from ..hal.vfcurve import read_clock_offsets
|
from ..hal.vfcurve import read_clock_offsets
|
||||||
|
from .native import load_profile
|
||||||
|
|
||||||
safe_name = "".join(c for c in name if c.isalnum() or c in " _-()").strip()
|
safe_name = "".join(c for c in name if c.isalnum() or c in " _-()").strip()
|
||||||
filepath = os.path.join(cfg.profile_dir, f"{safe_name}.json")
|
filepath = os.path.join(cfg.profile_dir, f"{safe_name}.json")
|
||||||
@@ -82,27 +88,46 @@ def apply_with_retry(gpu_index: int, name: str, cfg, max_retries: int = 3) -> bo
|
|||||||
log.warning("Auto-load profile %r not found — skipping GPU %d", name, gpu_index)
|
log.warning("Auto-load profile %r not found — skipping GPU %d", name, gpu_index)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
expected: dict[int, int] = (
|
expected: dict[int, int] = (
|
||||||
{int(k): v for k, v in profile.curve_deltas.items()}
|
{int(k): v for k, v in profile.curve_deltas.items()}
|
||||||
if profile.curve_deltas else {}
|
if profile.curve_deltas
|
||||||
|
else {}
|
||||||
)
|
)
|
||||||
|
except ValueError:
|
||||||
|
log.warning(
|
||||||
|
"Profile %r has invalid curve point keys — skipping GPU %d",
|
||||||
|
name,
|
||||||
|
gpu_index,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
errs = apply_profile(gpu_index, name, cfg)
|
errs = apply_profile(gpu_index, name, cfg)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.warning("Auto-load attempt %d/%d exception: %s", attempt + 1, max_retries, exc)
|
log.warning(
|
||||||
|
"Auto-load attempt %d/%d exception: %s", attempt + 1, max_retries, exc
|
||||||
|
)
|
||||||
errs = [str(exc)]
|
errs = [str(exc)]
|
||||||
|
|
||||||
if errs:
|
if errs:
|
||||||
log.warning("Auto-load attempt %d/%d errors: %s",
|
log.warning(
|
||||||
attempt + 1, max_retries, "; ".join(errs))
|
"Auto-load attempt %d/%d errors: %s",
|
||||||
|
attempt + 1,
|
||||||
|
max_retries,
|
||||||
|
"; ".join(errs),
|
||||||
|
)
|
||||||
elif expected:
|
elif expected:
|
||||||
gpu, _ = get_gpu(index=gpu_index)
|
gpu, _ = get_gpu(index=gpu_index)
|
||||||
offsets, err = read_clock_offsets(gpu)
|
offsets, err = 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"
|
||||||
@@ -110,14 +135,28 @@ def apply_with_retry(gpu_index: int, name: str, cfg, max_retries: int = 3) -> bo
|
|||||||
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 True
|
return True
|
||||||
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 True
|
return True
|
||||||
|
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
@@ -125,8 +164,12 @@ def apply_with_retry(gpu_index: int, name: str, cfg, max_retries: int = 3) -> bo
|
|||||||
log.info("Retrying auto-load in %ds…", delay)
|
log.info("Retrying auto-load in %ds…", delay)
|
||||||
time.sleep(delay)
|
time.sleep(delay)
|
||||||
|
|
||||||
log.warning("Auto-load profile %r failed after %d attempts on GPU %d",
|
log.warning(
|
||||||
name, max_retries, gpu_index)
|
"Auto-load profile %r failed after %d attempts on GPU %d",
|
||||||
|
name,
|
||||||
|
max_retries,
|
||||||
|
gpu_index,
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -153,13 +196,19 @@ def run_autoload() -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
from ..config import 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"):
|
"max_delta_khz",
|
||||||
|
"auto_snapshot",
|
||||||
|
"max_snapshots",
|
||||||
|
"snapshot_dir",
|
||||||
|
"profile_dir",
|
||||||
|
):
|
||||||
if key in cfg_data:
|
if key in cfg_data:
|
||||||
setattr(cfg, key, cfg_data[key])
|
setattr(cfg, key, cfg_data[key])
|
||||||
|
|
||||||
from ..hal.gpu import init_nvapi, discover_gpus
|
from ..hal.gpu import discover_gpus, init_nvapi
|
||||||
from ..hal.monitoring import init_nvml, shutdown_nvml
|
from ..hal.monitoring import init_nvml, shutdown_nvml
|
||||||
|
|
||||||
# Retry NvAPI init — the driver may not be fully ready at early boot.
|
# Retry NvAPI init — the driver may not be fully ready at early boot.
|
||||||
@@ -189,7 +238,9 @@ def run_autoload() -> None:
|
|||||||
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)", profile_name, gpu_idx, gpu_key)
|
log.info(
|
||||||
|
"Auto-loading profile %r on GPU %d (%s)", profile_name, gpu_idx, gpu_key
|
||||||
|
)
|
||||||
apply_with_retry(gpu_idx, profile_name, cfg)
|
apply_with_retry(gpu_idx, profile_name, cfg)
|
||||||
|
|
||||||
shutdown_nvml()
|
shutdown_nvml()
|
||||||
+27
-13
@@ -1,39 +1,54 @@
|
|||||||
"""Native profile storage and schema."""
|
"""Native profile storage and schema."""
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import glob
|
import glob
|
||||||
from dataclasses import dataclass, asdict
|
import json
|
||||||
from typing import Dict, Optional, List
|
import logging
|
||||||
|
import os
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
|
||||||
|
log = logging.getLogger("nvcurve.profiles.native")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ProfileData:
|
class ProfileData:
|
||||||
name: str
|
name: str
|
||||||
gpu_name: str
|
gpu_name: str
|
||||||
curve_deltas: Dict[str, int] # { "index": delta_khz }
|
curve_deltas: dict[str, int] # { "index": delta_khz }
|
||||||
mem_offset_mhz: Optional[int] = None
|
mem_offset_mhz: int | None = None
|
||||||
power_limit_w: Optional[int] = None
|
power_limit_w: int | None = None
|
||||||
fan_curve: Optional[List[Dict[str, int]]] = None
|
fan_curve: list[dict[str, int]] | None = None
|
||||||
|
# Fan indices controlled by fan_curve (0-based); None = all fans.
|
||||||
|
fan_targets: list[int] | None = None
|
||||||
|
|
||||||
|
|
||||||
def save_profile(profile_dir: str, data: ProfileData) -> str:
|
def save_profile(profile_dir: str, data: ProfileData) -> str:
|
||||||
"""Save profile to JSON, sanitising the filename."""
|
"""Save profile to JSON, sanitising the filename."""
|
||||||
|
try:
|
||||||
os.makedirs(profile_dir, exist_ok=True)
|
os.makedirs(profile_dir, exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError(f"Cannot create profile dir {profile_dir}: {exc}") from exc
|
||||||
safe_name = "".join(c for c in data.name if c.isalnum() or c in " _-()").strip()
|
safe_name = "".join(c for c in data.name if c.isalnum() or c in " _-()").strip()
|
||||||
if not safe_name:
|
if not safe_name:
|
||||||
safe_name = "Unnamed"
|
safe_name = "Unnamed"
|
||||||
|
|
||||||
filepath = os.path.join(profile_dir, f"{safe_name}.json")
|
filepath = os.path.join(profile_dir, f"{safe_name}.json")
|
||||||
|
try:
|
||||||
with open(filepath, "w", encoding="utf-8") as f:
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
json.dump(asdict(data), f, indent=2)
|
json.dump(asdict(data), f, indent=2)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError(f"Cannot write profile {filepath}: {exc}") from exc
|
||||||
return filepath
|
return filepath
|
||||||
|
|
||||||
|
|
||||||
def load_profile(filepath: str) -> ProfileData:
|
def load_profile(filepath: str) -> ProfileData:
|
||||||
"""Load profile from JSON."""
|
"""Load profile from JSON."""
|
||||||
with open(filepath, "r", encoding="utf-8") as f:
|
try:
|
||||||
|
with open(filepath, encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
raise RuntimeError(f"Cannot read profile {filepath}: {exc}") from exc
|
||||||
# Migrate old field names.
|
# Migrate old field names.
|
||||||
if "vram_p0_offset_mhz" in data and "mem_offset_mhz" not in data:
|
if "vram_p0_offset_mhz" in data and "mem_offset_mhz" not in data:
|
||||||
data["mem_offset_mhz"] = data.pop("vram_p0_offset_mhz")
|
data["mem_offset_mhz"] = data.pop("vram_p0_offset_mhz")
|
||||||
@@ -43,7 +58,7 @@ def load_profile(filepath: str) -> ProfileData:
|
|||||||
return ProfileData(**data)
|
return ProfileData(**data)
|
||||||
|
|
||||||
|
|
||||||
def list_profiles(profile_dir: str) -> List[ProfileData]:
|
def list_profiles(profile_dir: str) -> list[ProfileData]:
|
||||||
"""Return a list of all safely readable profiles."""
|
"""Return a list of all safely readable profiles."""
|
||||||
if not os.path.exists(profile_dir):
|
if not os.path.exists(profile_dir):
|
||||||
return []
|
return []
|
||||||
@@ -51,9 +66,8 @@ def list_profiles(profile_dir: str) -> List[ProfileData]:
|
|||||||
for fp in glob.glob(os.path.join(profile_dir, "*.json")):
|
for fp in glob.glob(os.path.join(profile_dir, "*.json")):
|
||||||
try:
|
try:
|
||||||
profiles.append(load_profile(fp))
|
profiles.append(load_profile(fp))
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
# log warning ideally, but swallowing for robustness
|
log.debug("Skipping unreadable profile %s: %s", fp, exc)
|
||||||
pass
|
|
||||||
# Sort alphabetically by name
|
# Sort alphabetically by name
|
||||||
profiles.sort(key=lambda p: p.name.lower())
|
profiles.sort(key=lambda p: p.name.lower())
|
||||||
return profiles
|
return profiles
|
||||||
|
|||||||
+282
-70
@@ -11,7 +11,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from contextlib import asynccontextmanager, suppress
|
from contextlib import asynccontextmanager, suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Protocol
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -20,10 +20,11 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from . import auth
|
from . import auth
|
||||||
from .config import Config, default_config
|
from .config import Config, default_config, tls_enabled
|
||||||
from .hal.dashboard import get_dashboard_info
|
from .hal.dashboard import get_dashboard_info
|
||||||
from .hal.fans import (
|
from .hal.fans import (
|
||||||
get_fan_info,
|
get_fan_info,
|
||||||
|
get_num_fans,
|
||||||
get_temp,
|
get_temp,
|
||||||
interpolate_fan_speed,
|
interpolate_fan_speed,
|
||||||
reset_fan,
|
reset_fan,
|
||||||
@@ -90,8 +91,8 @@ def _open_browser_as_user(url: str) -> None:
|
|||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
log.debug("runuser xdg-open failed, falling back to webbrowser: %s", exc)
|
||||||
import webbrowser
|
import webbrowser
|
||||||
|
|
||||||
webbrowser.open(url)
|
webbrowser.open(url)
|
||||||
@@ -149,6 +150,7 @@ def _sample_dict(s) -> dict:
|
|||||||
"temp_c": s.temp_c,
|
"temp_c": s.temp_c,
|
||||||
"power_w": s.power_w,
|
"power_w": s.power_w,
|
||||||
"fan_pct": s.fan_pct,
|
"fan_pct": s.fan_pct,
|
||||||
|
"fans": s.fans,
|
||||||
"pstate": s.pstate,
|
"pstate": s.pstate,
|
||||||
"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,
|
||||||
@@ -219,7 +221,8 @@ async def _monitor_poller(gpu_index: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def _fan_poller(gpu_index: int) -> None:
|
async def _fan_poller(gpu_index: int) -> None:
|
||||||
"""Continuously read GPU temp, interpolate fan speed from active curve, and apply."""
|
"""Continuously read GPU temp, interpolate fan speed from active curve, and apply it to the target fans."""
|
||||||
|
last_write_error: str | None = None
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
g_state = _state["gpus"].get(gpu_index)
|
g_state = _state["gpus"].get(gpu_index)
|
||||||
@@ -229,7 +232,25 @@ async def _fan_poller(gpu_index: int) -> None:
|
|||||||
curve = g_state["fan_curve"]
|
curve = g_state["fan_curve"]
|
||||||
target = interpolate_fan_speed(curve, temp)
|
target = interpolate_fan_speed(curve, temp)
|
||||||
if target is not None:
|
if target is not None:
|
||||||
await _run(set_fan_speed, gpu_index, target)
|
ok, msg = await _run(
|
||||||
|
set_fan_speed,
|
||||||
|
gpu_index,
|
||||||
|
target,
|
||||||
|
g_state.get("fan_targets"),
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
# Log once per distinct failure so a stuck target
|
||||||
|
# list doesn't spam a warning every 2 s tick.
|
||||||
|
if msg != last_write_error:
|
||||||
|
log.warning(
|
||||||
|
"Fan write failed for GPU %d (targets=%s): %s",
|
||||||
|
gpu_index,
|
||||||
|
g_state.get("fan_targets"),
|
||||||
|
msg,
|
||||||
|
)
|
||||||
|
last_write_error = msg
|
||||||
|
else:
|
||||||
|
last_write_error = None
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
return
|
return
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -237,6 +258,79 @@ async def _fan_poller(gpu_index: int) -> None:
|
|||||||
await asyncio.sleep(2.0)
|
await asyncio.sleep(2.0)
|
||||||
|
|
||||||
|
|
||||||
|
async def _activate_fan_curve(
|
||||||
|
gpu_index: int, curve: list, fans: list[int] | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Set the active fan curve, (re)start the poller, and persist it.
|
||||||
|
|
||||||
|
fans=None targets all fans on the device; a list targets the given
|
||||||
|
fan indices. Persistence (config.json) is what makes the curve survive
|
||||||
|
server restarts: fan control is volatile, so the driver reverts to
|
||||||
|
automatic mode on reboot and the saved curve is re-applied at the next
|
||||||
|
server start.
|
||||||
|
"""
|
||||||
|
g_state = _get_gpu_state(gpu_index)
|
||||||
|
|
||||||
|
# Validate explicit fan targets against the hardware. Stale indices
|
||||||
|
# (e.g. a profile saved on a 2-fan GPU applied to a 1-fan GPU, or a
|
||||||
|
# persisted entry restored after a hardware change) would otherwise
|
||||||
|
# make the poller fail silently on every tick.
|
||||||
|
if fans is not None:
|
||||||
|
num_fans = await _run(get_num_fans, gpu_index)
|
||||||
|
if not fans or (num_fans > 0 and any(f < 0 or f >= num_fans for f in fans)):
|
||||||
|
log.warning(
|
||||||
|
"Fan targets %s invalid for GPU %d (%d fan(s)); falling back to all fans",
|
||||||
|
fans,
|
||||||
|
gpu_index,
|
||||||
|
num_fans,
|
||||||
|
)
|
||||||
|
fans = None
|
||||||
|
|
||||||
|
# Stop existing poller if running
|
||||||
|
if g_state.get("fan_poller_task"):
|
||||||
|
g_state["fan_poller_task"].cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await g_state["fan_poller_task"]
|
||||||
|
|
||||||
|
g_state["fan_curve"] = curve
|
||||||
|
g_state["fan_curve_active"] = True
|
||||||
|
g_state["fan_targets"] = fans
|
||||||
|
g_state["fan_poller_task"] = asyncio.create_task(_fan_poller(gpu_index))
|
||||||
|
|
||||||
|
cfg: Config = _state["config"]
|
||||||
|
cfg.fan_curves[_gpu_stable_key(gpu_index)] = {"curve": curve, "fans": fans}
|
||||||
|
_persist_fan_curves(cfg.fan_curves)
|
||||||
|
|
||||||
|
|
||||||
|
async def _deactivate_fan_curve(gpu_index: int, reset_hardware: bool = True) -> None:
|
||||||
|
"""Clear the active fan curve, stop the poller, and clear its persistence.
|
||||||
|
|
||||||
|
When reset_hardware is True the GPU is returned to automatic fan control.
|
||||||
|
"""
|
||||||
|
g_state = _get_gpu_state(gpu_index)
|
||||||
|
|
||||||
|
if g_state.get("fan_poller_task"):
|
||||||
|
g_state["fan_poller_task"].cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await g_state["fan_poller_task"]
|
||||||
|
g_state["fan_poller_task"] = None
|
||||||
|
|
||||||
|
g_state["fan_curve"] = None
|
||||||
|
g_state["fan_curve_active"] = False
|
||||||
|
g_state["fan_targets"] = None
|
||||||
|
|
||||||
|
if reset_hardware:
|
||||||
|
ok, msg = await _run(reset_fan, gpu_index)
|
||||||
|
if not ok:
|
||||||
|
log.warning("Fan reset warning: %s", msg)
|
||||||
|
|
||||||
|
cfg: Config = _state["config"]
|
||||||
|
key = _gpu_stable_key(gpu_index)
|
||||||
|
if key in cfg.fan_curves:
|
||||||
|
del cfg.fan_curves[key]
|
||||||
|
_persist_fan_curves(cfg.fan_curves)
|
||||||
|
|
||||||
|
|
||||||
# ── Lifespan ──────────────────────────────────────────────────────────────────
|
# ── Lifespan ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -326,6 +420,43 @@ async def lifespan(app: FastAPI):
|
|||||||
)
|
)
|
||||||
# ──────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ── Restore persisted fan curves ──────────────────────────────────────────
|
||||||
|
# Fan control is volatile: the driver reverts to automatic mode on reboot,
|
||||||
|
# so a curve applied via the UI is persisted in config.json and re-applied
|
||||||
|
# here at startup. Runs after the auto-load profile path so the user's
|
||||||
|
# explicit fan curve setting takes precedence.
|
||||||
|
for gpu_index, g_state in _state["gpus"].items():
|
||||||
|
if g_state["gpu"] is None:
|
||||||
|
continue
|
||||||
|
if g_state.get("fan_curve_active"):
|
||||||
|
continue # already activated by the auto-load profile path
|
||||||
|
key = _gpu_stable_key(gpu_index)
|
||||||
|
entry = cfg.fan_curves.get(key)
|
||||||
|
if not entry:
|
||||||
|
continue
|
||||||
|
# Migrate the legacy format (bare curve list) to the current
|
||||||
|
# {"curve": ..., "fans": ...} shape; legacy entries targeted all fans.
|
||||||
|
if isinstance(entry, list):
|
||||||
|
entry = {"curve": entry, "fans": None}
|
||||||
|
curve = entry.get("curve") if isinstance(entry, dict) else None
|
||||||
|
fans = entry.get("fans") if isinstance(entry, dict) else None
|
||||||
|
if not curve:
|
||||||
|
continue
|
||||||
|
ok, msg = validate_curve(curve)
|
||||||
|
if not ok:
|
||||||
|
log.warning("Skipping persisted fan curve for GPU %d: %s", gpu_index, msg)
|
||||||
|
continue
|
||||||
|
log.info("Restoring persisted fan curve on GPU %d (%s)", gpu_index, key)
|
||||||
|
try:
|
||||||
|
await _activate_fan_curve(gpu_index, curve, fans)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning(
|
||||||
|
"Failed to restore persisted fan curve on GPU %d: %s",
|
||||||
|
gpu_index,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
yield # server is running
|
yield # server is running
|
||||||
|
|
||||||
for task in poller_tasks:
|
for task in poller_tasks:
|
||||||
@@ -421,12 +552,10 @@ app.add_middleware(AuthMiddleware)
|
|||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class GlobalOffsetRequest(BaseModel):
|
class GlobalOffsetRequest(BaseModel):
|
||||||
delta_khz: int
|
delta_khz: int
|
||||||
max_delta_khz: int | None = None # per-request safety limit override
|
|
||||||
|
|
||||||
|
|
||||||
class VerifyRequest(BaseModel):
|
class VerifyRequest(BaseModel):
|
||||||
@@ -462,10 +591,14 @@ class FanCurvePoint(BaseModel):
|
|||||||
|
|
||||||
class FanCurveRequest(BaseModel):
|
class FanCurveRequest(BaseModel):
|
||||||
curve: list[FanCurvePoint]
|
curve: list[FanCurvePoint]
|
||||||
|
# Fan indices to control (0-based); None = all fans on the device.
|
||||||
|
fans: list[int] | None = None
|
||||||
|
|
||||||
|
|
||||||
class FanSpeedRequest(BaseModel):
|
class FanSpeedRequest(BaseModel):
|
||||||
fan_pct: int
|
fan_pct: int
|
||||||
|
# Specific fan index to set (0-based); None = all fans on the device.
|
||||||
|
fan: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
@@ -519,7 +652,7 @@ async def api_auth_login(req: LoginRequest, request: Request):
|
|||||||
if not users:
|
if not users:
|
||||||
raise HTTPException(status_code=404, detail="Authentication is not enabled")
|
raise HTTPException(status_code=404, detail="Authentication is not enabled")
|
||||||
|
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
client_ip = _client_ip(request, cfg.trusted_proxies)
|
||||||
if auth.is_locked_out(client_ip):
|
if auth.is_locked_out(client_ip):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=429, detail="Too many failed attempts. Try again later."
|
status_code=429, detail="Too many failed attempts. Try again later."
|
||||||
@@ -542,6 +675,7 @@ async def api_auth_login(req: LoginRequest, request: Request):
|
|||||||
max_age=auth.SESSION_TTL_S,
|
max_age=auth.SESSION_TTL_S,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
|
secure=tls_enabled(cfg),
|
||||||
path="/",
|
path="/",
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
@@ -575,6 +709,36 @@ def _require_gpu(gpu_index: int = 0):
|
|||||||
return gpu, g_state
|
return gpu, g_state
|
||||||
|
|
||||||
|
|
||||||
|
class _ClientIpSource(Protocol):
|
||||||
|
"""Structural type for the request objects _client_ip accepts.
|
||||||
|
|
||||||
|
Both Starlette's Request and WebSocket expose these; tests may pass
|
||||||
|
lightweight duck types.
|
||||||
|
"""
|
||||||
|
|
||||||
|
client: Any
|
||||||
|
headers: Any
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(request: _ClientIpSource, trusted_proxies: list[str]) -> str:
|
||||||
|
"""Best-effort client IP for the login lockout.
|
||||||
|
|
||||||
|
When the direct peer is a configured trusted proxy (e.g. a TLS
|
||||||
|
reverse proxy), use the rightmost X-Forwarded-For entry that is not
|
||||||
|
itself a trusted proxy. Otherwise use the direct peer address —
|
||||||
|
X-Forwarded-For is spoofable, so it is only honoured for peers the
|
||||||
|
operator explicitly listed in ``trusted_proxies``.
|
||||||
|
"""
|
||||||
|
peer = request.client.host if request.client else "unknown"
|
||||||
|
if not trusted_proxies or peer not in trusted_proxies:
|
||||||
|
return peer
|
||||||
|
hops = [h.strip() for h in request.headers.get("x-forwarded-for", "").split(",")]
|
||||||
|
for hop in reversed(hops):
|
||||||
|
if hop and hop not in trusted_proxies:
|
||||||
|
return hop
|
||||||
|
return peer
|
||||||
|
|
||||||
|
|
||||||
# ── REST endpoints ────────────────────────────────────────────────────────────
|
# ── REST endpoints ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -745,6 +909,11 @@ def _persist_auto_load_profiles(profiles: dict[str, str]) -> None:
|
|||||||
_persist_config_field("auto_load_profiles", profiles if profiles else None)
|
_persist_config_field("auto_load_profiles", profiles if profiles else None)
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_fan_curves(fan_curves: dict) -> None:
|
||||||
|
"""Persist the per-GPU active fan curves dict to config.json."""
|
||||||
|
_persist_config_field("fan_curves", fan_curves if fan_curves else None)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/profiles")
|
@app.get("/api/profiles")
|
||||||
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."""
|
||||||
@@ -787,6 +956,9 @@ async def api_profile_save(req: ProfileSaveRequest, gpu_index: int = 0):
|
|||||||
mem_offset_mhz=mem_offset_mhz,
|
mem_offset_mhz=mem_offset_mhz,
|
||||||
power_limit_w=power_limit_w,
|
power_limit_w=power_limit_w,
|
||||||
fan_curve=g_state.get("fan_curve") if g_state.get("fan_curve_active") else None,
|
fan_curve=g_state.get("fan_curve") if g_state.get("fan_curve_active") else None,
|
||||||
|
fan_targets=g_state.get("fan_targets")
|
||||||
|
if g_state.get("fan_curve_active")
|
||||||
|
else None,
|
||||||
)
|
)
|
||||||
filepath = await _run(save_profile, cfg.profile_dir, data)
|
filepath = await _run(save_profile, cfg.profile_dir, data)
|
||||||
g_state["active_profile"] = req.name
|
g_state["active_profile"] = req.name
|
||||||
@@ -931,31 +1103,16 @@ async def _apply_profile(name: str, gpu_index: int = 0) -> list[str]:
|
|||||||
|
|
||||||
await _update_offsets_and_broadcast(gpu_index)
|
await _update_offsets_and_broadcast(gpu_index)
|
||||||
|
|
||||||
# Apply fan curve if present in profile
|
# Apply fan curve if present in profile (persists it so it survives restarts);
|
||||||
|
# otherwise deactivate any active fan curve (and clear its persistence).
|
||||||
if profile.fan_curve:
|
if profile.fan_curve:
|
||||||
ok, msg = validate_curve(profile.fan_curve)
|
ok, msg = validate_curve(profile.fan_curve)
|
||||||
if not ok:
|
if not ok:
|
||||||
errs.append(f"Fan curve: {msg}")
|
errs.append(f"Fan curve: {msg}")
|
||||||
else:
|
else:
|
||||||
# Stop existing fan poller if running
|
await _activate_fan_curve(gpu_index, profile.fan_curve, profile.fan_targets)
|
||||||
if g_state.get("fan_poller_task"):
|
|
||||||
g_state["fan_poller_task"].cancel()
|
|
||||||
with suppress(asyncio.CancelledError):
|
|
||||||
await g_state["fan_poller_task"]
|
|
||||||
|
|
||||||
g_state["fan_curve"] = profile.fan_curve
|
|
||||||
g_state["fan_curve_active"] = True
|
|
||||||
g_state["fan_poller_task"] = asyncio.create_task(_fan_poller(gpu_index))
|
|
||||||
elif g_state.get("fan_curve_active"):
|
elif g_state.get("fan_curve_active"):
|
||||||
# Profile has no fan curve, deactivate any active fan curve
|
await _deactivate_fan_curve(gpu_index, reset_hardware=True)
|
||||||
if g_state.get("fan_poller_task"):
|
|
||||||
g_state["fan_poller_task"].cancel()
|
|
||||||
with suppress(asyncio.CancelledError):
|
|
||||||
await g_state["fan_poller_task"]
|
|
||||||
g_state["fan_poller_task"] = None
|
|
||||||
g_state["fan_curve"] = None
|
|
||||||
g_state["fan_curve_active"] = False
|
|
||||||
await _run(reset_fan, gpu_index)
|
|
||||||
|
|
||||||
if not errs:
|
if not errs:
|
||||||
g_state["active_profile"] = name
|
g_state["active_profile"] = name
|
||||||
@@ -1155,7 +1312,7 @@ async def api_limits_reset(gpu_index: int = 0):
|
|||||||
|
|
||||||
@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: per-fan %, curve, and whether curve control is active."""
|
||||||
_get_gpu_state(gpu_index)
|
_get_gpu_state(gpu_index)
|
||||||
g_state = _state["gpus"][gpu_index]
|
g_state = _state["gpus"][gpu_index]
|
||||||
info = await _run(get_fan_info, gpu_index)
|
info = await _run(get_fan_info, gpu_index)
|
||||||
@@ -1165,12 +1322,16 @@ async def api_fans(gpu_index: int = 0):
|
|||||||
"fan_mode": "curve" if curve_active else "auto",
|
"fan_mode": "curve" if curve_active else "auto",
|
||||||
"curve": g_state.get("fan_curve"),
|
"curve": g_state.get("fan_curve"),
|
||||||
"curve_active": curve_active,
|
"curve_active": curve_active,
|
||||||
|
"fan_targets": g_state.get("fan_targets"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/fans")
|
@app.post("/api/fans")
|
||||||
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.
|
||||||
|
|
||||||
|
req.fans selects which fans the curve drives (None = all fans).
|
||||||
|
"""
|
||||||
g_state = _get_gpu_state(gpu_index)
|
g_state = _get_gpu_state(gpu_index)
|
||||||
|
|
||||||
curve_data = [{"temp_c": p.temp_c, "fan_pct": p.fan_pct} for p in req.curve]
|
curve_data = [{"temp_c": p.temp_c, "fan_pct": p.fan_pct} for p in req.curve]
|
||||||
@@ -1178,6 +1339,8 @@ async def api_fans_update(req: FanCurveRequest, gpu_index: int = 0):
|
|||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(status_code=400, detail=msg)
|
raise HTTPException(status_code=400, detail=msg)
|
||||||
|
|
||||||
|
fans = sorted(set(req.fans)) if req.fans is not None else None
|
||||||
|
|
||||||
# Test that fan control is available on this GPU, probing with the
|
# Test that fan control is available on this GPU, probing with the
|
||||||
# target for the *current* temperature so the fan is never briefly
|
# target for the *current* temperature so the fan is never briefly
|
||||||
# set to an inappropriate speed.
|
# set to an inappropriate speed.
|
||||||
@@ -1190,21 +1353,14 @@ async def api_fans_update(req: FanCurveRequest, gpu_index: int = 0):
|
|||||||
)
|
)
|
||||||
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, fans)
|
||||||
if not fan_ok:
|
if not fan_ok:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Fan control not available: {fan_msg}"
|
status_code=500, detail=f"Fan control not available: {fan_msg}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Stop existing poller if running
|
# Activate the curve (starts the poller) and persist it so it survives restarts.
|
||||||
if g_state.get("fan_poller_task"):
|
await _activate_fan_curve(gpu_index, curve_data, fans)
|
||||||
g_state["fan_poller_task"].cancel()
|
|
||||||
with suppress(asyncio.CancelledError):
|
|
||||||
await g_state["fan_poller_task"]
|
|
||||||
|
|
||||||
g_state["fan_curve"] = curve_data
|
|
||||||
g_state["fan_curve_active"] = True
|
|
||||||
g_state["fan_poller_task"] = asyncio.create_task(_fan_poller(gpu_index))
|
|
||||||
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@@ -1212,31 +1368,25 @@ async def api_fans_update(req: FanCurveRequest, gpu_index: int = 0):
|
|||||||
@app.post("/api/fans/reset")
|
@app.post("/api/fans/reset")
|
||||||
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."""
|
||||||
g_state = _get_gpu_state(gpu_index)
|
_get_gpu_state(gpu_index)
|
||||||
|
|
||||||
# Stop poller
|
# Stop the poller, clear state, restore automatic fan control, and clear
|
||||||
if g_state.get("fan_poller_task"):
|
# the persisted curve so it is not re-applied on the next server start.
|
||||||
g_state["fan_poller_task"].cancel()
|
await _deactivate_fan_curve(gpu_index, reset_hardware=True)
|
||||||
with suppress(asyncio.CancelledError):
|
|
||||||
await g_state["fan_poller_task"]
|
|
||||||
g_state["fan_poller_task"] = None
|
|
||||||
|
|
||||||
g_state["fan_curve"] = None
|
|
||||||
g_state["fan_curve_active"] = False
|
|
||||||
|
|
||||||
ok, msg = await _run(reset_fan, gpu_index)
|
|
||||||
if not ok:
|
|
||||||
log.warning("Fan reset warning: %s", msg)
|
|
||||||
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/fans/speed")
|
@app.post("/api/fans/speed")
|
||||||
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 to an exact percentage (bypasses curve)."""
|
"""One-shot set fan(s) to an exact percentage (bypasses curve).
|
||||||
|
|
||||||
|
req.fan selects a single fan index; None sets all fans.
|
||||||
|
"""
|
||||||
_get_gpu_state(gpu_index)
|
_get_gpu_state(gpu_index)
|
||||||
pct = max(0, min(100, req.fan_pct))
|
pct = max(0, min(100, req.fan_pct))
|
||||||
ok, msg = await _run(set_fan_speed, gpu_index, pct)
|
fans = [req.fan] if req.fan is not None else None
|
||||||
|
ok, msg = await _run(set_fan_speed, gpu_index, pct, fans)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(status_code=500, detail=msg)
|
raise HTTPException(status_code=500, detail=msg)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
@@ -1286,10 +1436,10 @@ 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 = (
|
# The safety cap is always the server-side config value — clients cannot
|
||||||
req.max_delta_khz if req.max_delta_khz is not None else cfg.max_delta_khz
|
# raise it per request (shared systems must not let one user override the
|
||||||
)
|
# hardware safety limit). Raise it in /etc/nvcurve/config.json if needed.
|
||||||
errors = validate_write(req.deltas, effective_limit)
|
errors = validate_write(req.deltas, cfg.max_delta_khz)
|
||||||
if errors:
|
if errors:
|
||||||
raise HTTPException(status_code=400, detail={"errors": errors})
|
raise HTTPException(status_code=400, detail={"errors": errors})
|
||||||
|
|
||||||
@@ -1340,10 +1490,8 @@ 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 = (
|
# Safety cap is the server-side config value only (see api_curve_write).
|
||||||
req.max_delta_khz if req.max_delta_khz is not None else cfg.max_delta_khz
|
errors = validate_write(all_deltas, cfg.max_delta_khz)
|
||||||
)
|
|
||||||
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})
|
||||||
|
|
||||||
@@ -1484,10 +1632,21 @@ async def api_curve_verify(req: VerifyRequest, gpu_index: int = 0):
|
|||||||
|
|
||||||
@app.post("/api/shutdown")
|
@app.post("/api/shutdown")
|
||||||
async def api_shutdown():
|
async def api_shutdown():
|
||||||
"""Gracefully shut down the server process."""
|
"""Gracefully shut down the server process.
|
||||||
|
|
||||||
|
Disabled when ``allow_api_shutdown`` is false in the config — on shared
|
||||||
|
systems stop the service via systemd instead.
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
|
|
||||||
|
cfg: Config = _state["config"]
|
||||||
|
if not cfg.allow_api_shutdown:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="API shutdown is disabled (allow_api_shutdown: false)",
|
||||||
|
)
|
||||||
|
|
||||||
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}
|
||||||
@@ -1638,7 +1797,10 @@ def _resolve_dist_dir() -> str:
|
|||||||
the project-root layout used during local development.
|
the project-root layout used during local development.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from importlib.resources import files as _resource_files
|
# Project requires Python >= 3.12, so the 3.7-compat finding is a false positive.
|
||||||
|
from importlib.resources import ( # nosemgrep: python.lang.compatibility.python37.python37-compatibility-importlib2
|
||||||
|
files as _resource_files,
|
||||||
|
)
|
||||||
|
|
||||||
candidate = _resource_files("nvcurve") / "frontend" / "dist"
|
candidate = _resource_files("nvcurve") / "frontend" / "dist"
|
||||||
if candidate.is_dir():
|
if candidate.is_dir():
|
||||||
@@ -1671,7 +1833,15 @@ async def serve_spa(catchall: str):
|
|||||||
if not os.path.isdir(_dist_dir):
|
if not os.path.isdir(_dist_dir):
|
||||||
return {"error": "Frontend not built. Run pnpm build in frontend/."}
|
return {"error": "Frontend not built. Run pnpm build in frontend/."}
|
||||||
|
|
||||||
path = os.path.join(_dist_dir, catchall)
|
# Contain the resolved path inside the dist directory. The raw URL path
|
||||||
|
# can carry encoded ".." segments (e.g. /%2e%2e/etc/passwd) that would
|
||||||
|
# otherwise escape the dist dir via os.path.join — an unauthenticated
|
||||||
|
# arbitrary-file-read since the server runs as root.
|
||||||
|
base = os.path.realpath(_dist_dir)
|
||||||
|
path = os.path.realpath(os.path.join(_dist_dir, catchall))
|
||||||
|
if path != base and not path.startswith(base + os.sep):
|
||||||
|
raise HTTPException(status_code=404, detail="Not Found")
|
||||||
|
|
||||||
if os.path.isfile(path) and catchall:
|
if os.path.isfile(path) and catchall:
|
||||||
return FileResponse(path)
|
return FileResponse(path)
|
||||||
|
|
||||||
@@ -1697,8 +1867,14 @@ def run(
|
|||||||
gpu_index: int = 0,
|
gpu_index: int = 0,
|
||||||
config: Config = default_config,
|
config: Config = default_config,
|
||||||
open_browser: bool = False,
|
open_browser: bool = False,
|
||||||
|
ssl_certfile: str | None = None,
|
||||||
|
ssl_keyfile: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Start the uvicorn server. Blocking."""
|
"""Start the uvicorn server. Blocking.
|
||||||
|
|
||||||
|
When both ssl_certfile and ssl_keyfile are given (either here or in the
|
||||||
|
config), the server serves HTTPS and the session cookie is Secure.
|
||||||
|
"""
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
@@ -1706,6 +1882,34 @@ def run(
|
|||||||
|
|
||||||
_state["config"] = config
|
_state["config"] = config
|
||||||
|
|
||||||
|
# CLI flags take precedence over config values.
|
||||||
|
certfile = ssl_certfile or config.ssl_certfile
|
||||||
|
keyfile = ssl_keyfile or config.ssl_keyfile
|
||||||
|
if certfile:
|
||||||
|
config.ssl_certfile = certfile
|
||||||
|
if keyfile:
|
||||||
|
config.ssl_keyfile = keyfile
|
||||||
|
tls = bool(certfile and keyfile)
|
||||||
|
|
||||||
|
# Fail fast on a bad TLS configuration — otherwise uvicorn dies at
|
||||||
|
# startup and (in daemon mode) the error is only visible in the server
|
||||||
|
# log while `serve status` reports "not running".
|
||||||
|
tls = False
|
||||||
|
if certfile and keyfile:
|
||||||
|
missing = [
|
||||||
|
f"{label} ({path})"
|
||||||
|
for label, path in (("certificate", certfile), ("key", keyfile))
|
||||||
|
if not os.path.isfile(path)
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
print(f"Error: TLS file(s) not found: {', '.join(missing)}")
|
||||||
|
print(
|
||||||
|
"Fix the path (nvcurve service configure --ssl-certfile/--ssl-keyfile) "
|
||||||
|
"or disable TLS (--no-ssl)."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
tls = True
|
||||||
|
|
||||||
# Suppress noisy websockets keepalive ping-timeout tracebacks — these are
|
# Suppress noisy websockets keepalive ping-timeout tracebacks — these are
|
||||||
# normal disconnection events (browser tab closed, network hiccup) and
|
# normal disconnection events (browser tab closed, network hiccup) and
|
||||||
# logging them at ERROR level creates false alarm noise.
|
# logging them at ERROR level creates false alarm noise.
|
||||||
@@ -1723,7 +1927,7 @@ def run(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
url = f"http://{host}:{port}"
|
url = f"{'https' if tls else 'http'}://{host}:{port}"
|
||||||
|
|
||||||
# Print banner *before* uvicorn starts so it appears above uvicorn's own output.
|
# Print banner *before* uvicorn starts so it appears above uvicorn's own output.
|
||||||
# GPU name is populated by the lifespan; we omit it here since the server
|
# GPU name is populated by the lifespan; we omit it here since the server
|
||||||
@@ -1738,4 +1942,12 @@ def run(
|
|||||||
if open_browser and not _DEV_PORT:
|
if open_browser and not _DEV_PORT:
|
||||||
threading.Timer(1.2, lambda: _open_browser_as_user(url)).start()
|
threading.Timer(1.2, lambda: _open_browser_as_user(url)).start()
|
||||||
|
|
||||||
uvicorn.run(app, host=host, port=port, log_level="warning", access_log=False)
|
uvicorn.run(
|
||||||
|
app,
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
log_level="warning",
|
||||||
|
access_log=False,
|
||||||
|
ssl_certfile=certfile if tls else None,
|
||||||
|
ssl_keyfile=keyfile if tls else None,
|
||||||
|
)
|
||||||
@@ -19,9 +19,20 @@ dependencies = [
|
|||||||
[project.scripts]
|
[project.scripts]
|
||||||
nvcurve = "nvcurve.cli:main"
|
nvcurve = "nvcurve.cli:main"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"hatchling", # enables local `hatch build` and resolves hatch_build.py imports
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.hatch.build.hooks.custom]
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["nvcurve"]
|
packages = ["nvcurve"]
|
||||||
|
|
||||||
|
# The custom build hook (hatch_build.py) compiles the React frontend when
|
||||||
|
# frontend/dist is missing or stale, so `uv tool install git+<repo-url>` works
|
||||||
|
# as a single command. It runs for both wheel and sdist builds.
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel.force-include]
|
[tool.hatch.build.targets.wheel.force-include]
|
||||||
"frontend/dist" = "nvcurve/frontend/dist"
|
"frontend/dist" = "nvcurve/frontend/dist"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"""Security regression tests for nvcurve.
|
||||||
|
|
||||||
|
Standalone (no pytest required):
|
||||||
|
|
||||||
|
python tests/test_security.py
|
||||||
|
|
||||||
|
Also works under pytest if available. Covers the security-critical logic:
|
||||||
|
SPA path containment, snapshot restore containment, login lockout client-IP
|
||||||
|
derivation, TLS scheme detection, daemon socket hardening, and the
|
||||||
|
server-enforced safety cap.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import builtins
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from nvcurve import (
|
||||||
|
daemon, # noqa: E402
|
||||||
|
server, # noqa: E402
|
||||||
|
)
|
||||||
|
from nvcurve.cli import _discover_server_url # noqa: E402
|
||||||
|
from nvcurve.config import ( # noqa: E402
|
||||||
|
Config,
|
||||||
|
normalize_trusted_proxies,
|
||||||
|
tls_enabled,
|
||||||
|
)
|
||||||
|
from nvcurve.hal.snapshot import restore as snapshot_restore # noqa: E402
|
||||||
|
|
||||||
|
PASS = 0
|
||||||
|
FAIL = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(name: str, cond: bool) -> None:
|
||||||
|
global PASS, FAIL
|
||||||
|
if cond:
|
||||||
|
PASS += 1
|
||||||
|
print(f" PASS {name}")
|
||||||
|
else:
|
||||||
|
FAIL += 1
|
||||||
|
print(f" FAIL {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def _encoded_traversal(target: str = "/etc/hostname") -> str:
|
||||||
|
"""Build an encoded '..'-based traversal path deep enough to escape any dist dir."""
|
||||||
|
dist = os.path.abspath(server._dist_dir)
|
||||||
|
depth = len(dist.rstrip("/").split("/"))
|
||||||
|
enc = lambda s: s.replace("/", "%2f") # noqa: E731
|
||||||
|
return "/" + enc("../*" + str(depth + 2) + target)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spa_path_containment() -> None:
|
||||||
|
"""The SPA catch-all must not serve files outside the dist directory."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
client = TestClient(server.app)
|
||||||
|
r = client.get(_encoded_traversal())
|
||||||
|
check("SPA traversal -> 404", r.status_code == 404)
|
||||||
|
r = client.get(_encoded_traversal("/etc/passwd"))
|
||||||
|
check("SPA traversal /etc/passwd -> 404", r.status_code == 404)
|
||||||
|
# Normal static files must still be served.
|
||||||
|
r = client.get("/index.html")
|
||||||
|
check("SPA /index.html -> 200", r.status_code == 200)
|
||||||
|
r = client.get("/api/nonexistent")
|
||||||
|
check("unknown /api/ path -> 404", r.status_code == 404)
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_restore_containment() -> None:
|
||||||
|
"""Snapshot restore must only read files inside the snapshot dir."""
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
snap_dir = os.path.join(tmp, "snaps")
|
||||||
|
os.makedirs(snap_dir)
|
||||||
|
outside = os.path.join(tmp, "evil.bin")
|
||||||
|
with open(outside, "wb") as f:
|
||||||
|
f.write(b"\x00" * 9248)
|
||||||
|
|
||||||
|
check(
|
||||||
|
"restore(outside file) rejected",
|
||||||
|
snapshot_restore(None, snap_dir, outside) is False,
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"restore(nonexistent) rejected",
|
||||||
|
snapshot_restore(None, snap_dir, "/etc/hostname") is False,
|
||||||
|
)
|
||||||
|
link = os.path.join(snap_dir, "link.bin")
|
||||||
|
os.symlink(outside, link)
|
||||||
|
check(
|
||||||
|
"restore(symlink escape) rejected",
|
||||||
|
snapshot_restore(None, snap_dir, link) is False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_safety_cap_not_client_overridable() -> None:
|
||||||
|
"""The API must not accept a per-request safety cap override."""
|
||||||
|
check(
|
||||||
|
"WriteRequest has no max_delta_khz field",
|
||||||
|
"max_delta_khz" not in server.WriteRequest.model_fields,
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"GlobalOffsetRequest has no max_delta_khz field",
|
||||||
|
"max_delta_khz" not in server.GlobalOffsetRequest.model_fields,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_ip_derivation() -> None:
|
||||||
|
"""X-Forwarded-For is only honoured for configured trusted proxies."""
|
||||||
|
|
||||||
|
class FakeReq:
|
||||||
|
def __init__(self, peer: str, headers: dict):
|
||||||
|
self.client = type("C", (), {"host": peer})()
|
||||||
|
self.headers = headers
|
||||||
|
|
||||||
|
check(
|
||||||
|
"trusted proxy -> XFF used",
|
||||||
|
server._client_ip(
|
||||||
|
FakeReq("127.0.0.1", {"x-forwarded-for": "9.9.9.9"}), ["127.0.0.1"]
|
||||||
|
)
|
||||||
|
== "9.9.9.9",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"untrusted peer -> XFF ignored",
|
||||||
|
server._client_ip(
|
||||||
|
FakeReq("8.8.8.8", {"x-forwarded-for": "9.9.9.9"}), ["127.0.0.1"]
|
||||||
|
)
|
||||||
|
== "8.8.8.8",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"rightmost untrusted hop",
|
||||||
|
server._client_ip(
|
||||||
|
FakeReq("127.0.0.1", {"x-forwarded-for": "127.0.0.1, 9.9.9.9"}),
|
||||||
|
["127.0.0.1"],
|
||||||
|
)
|
||||||
|
== "9.9.9.9",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"no XFF -> peer",
|
||||||
|
server._client_ip(FakeReq("127.0.0.1", {}), ["127.0.0.1"]) == "127.0.0.1",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"all-trusted chain -> peer",
|
||||||
|
server._client_ip(
|
||||||
|
FakeReq("127.0.0.1", {"x-forwarded-for": "10.0.0.1, 10.0.0.2"}),
|
||||||
|
["127.0.0.1", "10.0.0.1", "10.0.0.2"],
|
||||||
|
)
|
||||||
|
== "127.0.0.1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trusted_proxies_normalization() -> None:
|
||||||
|
"""String values must be normalized to lists (no substring matching)."""
|
||||||
|
check("list passthrough", normalize_trusted_proxies(["1.2.3.4"]) == ["1.2.3.4"])
|
||||||
|
check(
|
||||||
|
"comma string split",
|
||||||
|
normalize_trusted_proxies("1.2.3.4, 5.6.7.8") == ["1.2.3.4", "5.6.7.8"],
|
||||||
|
)
|
||||||
|
check("None -> []", normalize_trusted_proxies(None) == [])
|
||||||
|
check("junk -> []", normalize_trusted_proxies(42) == [])
|
||||||
|
# The original bug: substring membership. After normalization, "127.0.0.1"
|
||||||
|
# must NOT be trusted when only "127.0.0.10" is listed.
|
||||||
|
trusted = normalize_trusted_proxies(["127.0.0.10"])
|
||||||
|
check("no substring trust", "127.0.0.1" not in trusted)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tls_scheme_detection() -> None:
|
||||||
|
"""_discover_server_url must pick https when TLS is configured."""
|
||||||
|
from nvcurve import cli as cli_mod
|
||||||
|
|
||||||
|
# Hermetic: hide any live server's runtime info file and any existing
|
||||||
|
# persistent config so the defaults level of the priority chain is
|
||||||
|
# exercised.
|
||||||
|
real_info_file = cli_mod._SERVER_INFO_FILE
|
||||||
|
real_persistent_cfg = cli_mod._PERSISTENT_CONFIG_FILE
|
||||||
|
hidden = tempfile.mkdtemp()
|
||||||
|
cli_mod._SERVER_INFO_FILE = os.path.join(hidden, "nvcurve.json")
|
||||||
|
cli_mod._PERSISTENT_CONFIG_FILE = os.path.join(hidden, "config.json")
|
||||||
|
try:
|
||||||
|
cfg = Config(
|
||||||
|
host="10.0.0.5", port=9000, ssl_certfile="/x/c.pem", ssl_keyfile="/x/k.pem"
|
||||||
|
)
|
||||||
|
check("tls_enabled true", tls_enabled(cfg) is True)
|
||||||
|
check("https url", _discover_server_url(cfg) == "https://10.0.0.5:9000")
|
||||||
|
cfg2 = Config(host="10.0.0.5", port=9000)
|
||||||
|
check("http url", _discover_server_url(cfg2) == "http://10.0.0.5:9000")
|
||||||
|
finally:
|
||||||
|
cli_mod._SERVER_INFO_FILE = real_info_file
|
||||||
|
cli_mod._PERSISTENT_CONFIG_FILE = real_persistent_cfg
|
||||||
|
|
||||||
|
|
||||||
|
def test_daemon_ignores_caller_host_port() -> None:
|
||||||
|
"""serve_start must bind the configured host/port, never the caller's."""
|
||||||
|
daemon._cfg = Config(host="10.1.1.1", port=9999)
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
class FakePopen:
|
||||||
|
def __init__(self, cmd, **kw):
|
||||||
|
captured["cmd"] = cmd
|
||||||
|
self.pid = 4242
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def terminate(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def wait(self, *a):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
real_open = builtins.open
|
||||||
|
|
||||||
|
def fake_open(path, *a, **kw):
|
||||||
|
if str(path).endswith("nvcurve-server.log"):
|
||||||
|
return io.StringIO()
|
||||||
|
return real_open(path, *a, **kw)
|
||||||
|
|
||||||
|
orig_popen = daemon.subprocess.Popen
|
||||||
|
daemon.subprocess.Popen = FakePopen
|
||||||
|
builtins.open = fake_open
|
||||||
|
try:
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
resp = loop.run_until_complete(
|
||||||
|
# "0.0.0.0" is a test payload proving the daemon ignores caller
|
||||||
|
# host/port — no socket is bound here.
|
||||||
|
daemon._dispatch({"cmd": "serve_start", "host": "0.0.0.0", "port": 12345}) # noqa: S104
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
builtins.open = real_open
|
||||||
|
daemon.subprocess.Popen = orig_popen
|
||||||
|
|
||||||
|
cmd = " ".join(captured.get("cmd", []))
|
||||||
|
check("config host/port used", "10.1.1.1" in cmd and "9999" in cmd)
|
||||||
|
check("caller host/port ignored", "0.0.0.0" not in cmd and "12345" not in cmd) # noqa: S104
|
||||||
|
check(
|
||||||
|
"response reports configured values",
|
||||||
|
resp.get("ok") is True
|
||||||
|
and resp.get("host") == "10.1.1.1"
|
||||||
|
and resp.get("port") == 9999,
|
||||||
|
)
|
||||||
|
check("response includes tls flag", "tls" in resp)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
tests = [
|
||||||
|
test_spa_path_containment,
|
||||||
|
test_snapshot_restore_containment,
|
||||||
|
test_safety_cap_not_client_overridable,
|
||||||
|
test_client_ip_derivation,
|
||||||
|
test_trusted_proxies_normalization,
|
||||||
|
test_tls_scheme_detection,
|
||||||
|
test_daemon_ignores_caller_host_port,
|
||||||
|
]
|
||||||
|
for t in tests:
|
||||||
|
print(f"== {t.__name__} ==")
|
||||||
|
t()
|
||||||
|
print(f"\n{PASS} passed, {FAIL} failed")
|
||||||
|
return 1 if FAIL else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -154,6 +154,22 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hatchling"
|
||||||
|
version = "1.32.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pathspec" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "tomlkit" },
|
||||||
|
{ name = "trove-classifiers" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/69/08/33331757185504aae48b8d9bd78cec03a76e3aecfb52e549d05a2347c0dd/hatchling-1.32.0.tar.gz", hash = "sha256:0bdbde4a52b06c37e3eca395f85a762bf0ef06fe374fd8ae429dc6be10230f5f", size = 57783, upload-time = "2026-08-11T05:03:44.114Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/84/1798b6d85ecde0e31546004efd25c5de1b1f49250644a60cce460e12593a/hatchling-1.32.0-py3-none-any.whl", hash = "sha256:0e17c9c3b9aa7c625acc8d0f5b622f107d5049af9ecf5ada4de1aada5be7cdbc", size = 78435, upload-time = "2026-08-11T05:03:42.644Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httpcore"
|
name = "httpcore"
|
||||||
version = "1.0.9"
|
version = "1.0.9"
|
||||||
@@ -233,6 +249,11 @@ dependencies = [
|
|||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "hatchling" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "bcrypt", specifier = ">=4.0" },
|
{ name = "bcrypt", specifier = ">=4.0" },
|
||||||
@@ -243,6 +264,9 @@ requires-dist = [
|
|||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [{ name = "hatchling" }]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nvidia-ml-py"
|
name = "nvidia-ml-py"
|
||||||
version = "13.590.48"
|
version = "13.590.48"
|
||||||
@@ -252,6 +276,33 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/fd/72/fb2af0d259a651affdce65fd6a495f0e07a685a0136baf585c5065204ee7/nvidia_ml_py-13.590.48-py3-none-any.whl", hash = "sha256:fd43d30ee9cd0b7940f5f9f9220b68d42722975e3992b6c21d14144c48760e43", size = 50680, upload-time = "2026-01-22T01:14:55.281Z" },
|
{ url = "https://files.pythonhosted.org/packages/fd/72/fb2af0d259a651affdce65fd6a495f0e07a685a0136baf585c5065204ee7/nvidia_ml_py-13.590.48-py3-none-any.whl", hash = "sha256:fd43d30ee9cd0b7940f5f9f9220b68d42722975e3992b6c21d14144c48760e43", size = 50680, upload-time = "2026-01-22T01:14:55.281Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pathspec"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.12.5"
|
version = "2.12.5"
|
||||||
@@ -406,6 +457,24 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tomlkit"
|
||||||
|
version = "0.15.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97", size = 180129, upload-time = "2026-07-17T01:48:04.562Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "trove-classifiers"
|
||||||
|
version = "2026.6.1.19"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c2/e3/7ca82ee24c82d344584abd5b8637b3bd056f2900226e8d82fc22f1184b92/trove_classifiers-2026.6.1.19.tar.gz", hash = "sha256:c5132b4b61a829d11cfbd2d72e97f20a45ed6edb95e45c5efdeb5e00836b2745", size = 17059, upload-time = "2026-06-01T19:41:34.649Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl", hash = "sha256:ab4c4ec93cc4a4e7815fa759906e05e6bb3f2fbd92ea0f897288c6a43efd15b3", size = 14211, upload-time = "2026-06-01T19:41:33.434Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.15.0"
|
version = "4.15.0"
|
||||||
|
|||||||
Reference in new issue
Block a user