Compare commits

..
Author SHA1 Message Date
Pakobbix fa944c9576 Update README.md 2026-09-02 15:44:49 +00:00
ARIA 9006c22fde Added new screenshots to show the available tabs/tools 2026-09-02 17:42:19 +02:00
ARIA b9d40f007d Added new screenshots to show the available tabs/tools 2026-09-02 17:41:58 +02:00
Pakobbix 418894a77b Update README.md 2026-09-02 15:34:43 +00:00
Pakobbix 06db52e8ac Merge pull request 'fix: fan settings persistence, profile indicator, and point removal' (#6) from fix/fan-settings into main
Reviewed-on: #6
2026-09-02 15:26:15 +00:00
ARIA d9260007d1 fix: fan settings persistence, profile indicator, and point removal
Address three fan-settings issues:

1. Profile view: show a fan icon next to a profile's name when it has a
   saved fan curve, so it's clear which profiles carry custom fans.

2. Fan curve persistence: the active fan curve was in-memory only and lost
   on every server restart. It is now persisted per-GPU in
   /etc/nvcurve/config.json (fan_curves) and re-applied at server startup,
   so an applied curve survives restarts. All fan-curve state changes route
   through _activate_fan_curve/_deactivate_fan_curve helpers that keep the
   persisted state in sync (apply, reset, and profile apply).

3. Point removal: the fan-curve remove button was nearly invisible. The
   chart remove control is now always faintly visible with an X glyph, the
   table remove button is larger with a tooltip, and a hint line explains
   how to add/remove points.

Also includes a formatting pass over the two edited frontend files.
2026-09-02 17:25:09 +02:00
ARIA 9cf2009d2c removed old markdown files 2026-09-02 16:58:31 +02:00
Pakobbix 0320cea895 Merge pull request 'feat: add Dashboard tab with live GPU overview' (#5) from feat/dashboard-tab into main
Reviewed-on: #5
2026-09-02 14:57:54 +00:00
12 changed files with 406 additions and 481 deletions

No files matched your search

-57
View File
@@ -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.
+15 -2
View File
@@ -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.
> [!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. \
> **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.
![NVCurve curve editor](.github/screenshots/curve-editor.png)
<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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

-271
View File
@@ -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
+179 -65
View File
@@ -1,10 +1,10 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Check, X, RotateCcw, Plus } from 'lucide-react';
import { api } from '../../api/client';
import { useCurveStore } from '../../store/curveStore';
import type { FanPoint, FanState } from '../../types';
import { toast } from 'sonner';
import { ConfirmDialog } from '../common/ConfirmDialog';
import { useState, useEffect, useRef, useCallback } from "react";
import { Check, X, RotateCcw, Plus } from "lucide-react";
import { api } from "../../api/client";
import { useCurveStore } from "../../store/curveStore";
import type { FanPoint, FanState } from "../../types";
import { toast } from "sonner";
import { ConfirmDialog } from "../common/ConfirmDialog";
function defaultCurve(): FanPoint[] {
return [
@@ -35,7 +35,9 @@ function fanToY(f: 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) {
@@ -63,13 +65,15 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
setPending(null);
}
} catch {
toast.error('Failed to load fan state');
toast.error("Failed to load fan state");
} finally {
setLoading(false);
}
}
useEffect(() => { fetchFans(); }, [selectedGpuIndex]);
useEffect(() => {
fetchFans();
}, [selectedGpuIndex]);
const activeCurve = pending ?? fanState?.curve ?? defaultCurve();
const hasPending = pending !== null;
@@ -87,7 +91,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
setConfirmApply(false);
await fetchFans();
onChanged?.();
toast.success('Fan curve applied');
toast.success("Fan curve applied");
} catch (e: any) {
setError(e.message ?? String(e));
setConfirmApply(false);
@@ -105,7 +109,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
setConfirmReset(false);
await fetchFans();
onChanged?.();
toast.success('Fan control reset to automatic');
toast.success("Fan control reset to automatic");
} catch (e: any) {
setError(e.message ?? String(e));
setConfirmReset(false);
@@ -128,10 +132,12 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
const fan = Math.max(FAN_MIN, Math.min(FAN_MAX, yToFan(y)));
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;
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);
}
@@ -146,7 +152,8 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
setDragIdx(idx);
}, []);
const handlePointerMove = useCallback((e: React.PointerEvent<SVGSVGElement>) => {
const handlePointerMove = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
if (dragIdx === null) return;
e.preventDefault();
const svg = svgRef.current;
@@ -164,22 +171,27 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
const updated = [...curve];
updated[dragIdx] = { temp_c: temp, fan_pct: fan };
setPending(updated);
}, [dragIdx, pending, fanState]);
},
[dragIdx, pending, fanState],
);
const handlePointerUp = useCallback(() => {
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(() => {
if (dragIdx === null) return;
window.addEventListener('pointerup', handlePointerUp);
return () => window.removeEventListener('pointerup', handlePointerUp);
window.addEventListener("pointerup", handlePointerUp);
return () => window.removeEventListener("pointerup", handlePointerUp);
}, [dragIdx, handlePointerUp]);
// Build polyline path from curve points
const curvePath = activeCurve && activeCurve.length >= 2
? activeCurve.map((p) => `${tempToX(p.temp_c)},${fanToY(p.fan_pct)}`).join(' ')
const curvePath =
activeCurve && activeCurve.length >= 2
? activeCurve
.map((p) => `${tempToX(p.temp_c)},${fanToY(p.fan_pct)}`)
.join(" ")
: null;
// Grid lines
@@ -202,7 +214,9 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
{/* Header */}
<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 && (
<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,7 +246,10 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
Apply
</button>
<button
onClick={() => { setPending(null); setError(null); }}
onClick={() => {
setPending(null);
setError(null);
}}
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"
>
@@ -254,7 +271,12 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
{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">
<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>
)}
@@ -263,7 +285,8 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
{isDefaults && (
<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">
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>
</div>
)}
@@ -272,51 +295,95 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
width="100%"
viewBox={`0 0 ${CHART_W} ${CHART_H}`}
className="max-w-full cursor-crosshair select-none"
style={{ touchAction: 'none' }}
style={{ touchAction: "none" }}
onClick={handleCanvasClick}
onPointerMove={handlePointerMove}
>
{/* 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 %) */}
{fanTicks.map(f => (
{fanTicks.map((f) => (
<g key={`fy-${f}`}>
<line
x1={PAD.left} y1={fanToY(f)}
x2={PAD.left + PLOT_W} y2={fanToY(f)}
stroke="#27272a" strokeWidth="0.5"
x1={PAD.left}
y1={fanToY(f)}
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}%
</text>
</g>
))}
{/* Grid lines - vertical (temp) */}
{tempTicks.map(t => (
{tempTicks.map((t) => (
<g key={`tx-${t}`}>
<line
x1={tempToX(t)} y1={PAD.top}
x2={tempToX(t)} y2={PAD.top + PLOT_H}
stroke="#27272a" strokeWidth="0.5"
x1={tempToX(t)}
y1={PAD.top}
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}°
</text>
</g>
))}
{/* Axis labels */}
<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>
<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 */}
{curvePath && (
<polyline
points={curvePath}
fill="none"
stroke={hasPending ? '#22d3ee' : '#fb923c'}
stroke={hasPending ? "#22d3ee" : "#fb923c"}
strokeWidth="2"
strokeLinejoin="round"
strokeLinecap="round"
@@ -328,7 +395,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
{curvePath && activeCurve && activeCurve.length >= 2 && (
<polygon
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"
/>
)}
@@ -340,40 +407,62 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
cx={tempToX(p.temp_c)}
cy={fanToY(p.fan_pct)}
r="6"
fill={hasPending ? '#22d3ee' : '#fb923c'}
fill={hasPending ? "#22d3ee" : "#fb923c"}
stroke="#09090b"
strokeWidth="2"
className="cursor-grab active:cursor-grabbing"
style={{ touchAction: 'none' }}
style={{ touchAction: "none" }}
onPointerDown={(e) => {
e.stopPropagation();
handlePointerDown(i);
}}
/>
{/* Delete button on hover */}
{/* Remove button: faintly visible, brightens on hover */}
{activeCurve && activeCurve.length > 2 && (
<circle
cx={tempToX(p.temp_c) + 8}
cy={fanToY(p.fan_pct) - 8}
r="7"
fill="#27272a"
stroke="#3f3f46"
strokeWidth="1"
className="cursor-pointer opacity-0 hover:opacity-100 transition-opacity"
<g
className="cursor-pointer group/remove"
onClick={(e) => {
e.stopPropagation();
removePoint(i);
}}
>
<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 */}
<text
x={tempToX(p.temp_c)}
y={fanToY(p.fan_pct) - 12}
textAnchor="middle"
fill={hasPending ? '#22d3ee' : '#fb923c'}
fill={hasPending ? "#22d3ee" : "#fb923c"}
fontSize="8"
fontFamily="monospace"
fontWeight="600"
@@ -386,7 +475,13 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
{/* No-curve hint (only when curve is truly empty) */}
{!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
</text>
)}
@@ -397,7 +492,9 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
{activeCurve && activeCurve.length > 0 && (
<div className="px-4 pb-3">
<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
onClick={() => {
const curve = pending ?? fanState?.curve ?? defaultCurve();
@@ -423,16 +520,29 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
<tbody>
{activeCurve.map((p, i) => (
<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">{p.fan_pct}</td>
<td className="py-1">
{activeCurve.length > 2 && (
<td className="py-1 font-mono text-zinc-300">
{p.temp_c}
</td>
<td className="py-1 font-mono text-zinc-300">
{p.fan_pct}
</td>
<td className="py-1 text-right">
{activeCurve.length > 2 ? (
<button
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>
) : (
<span
className="text-zinc-700"
title="A curve needs at least 2 points"
>
—
</span>
)}
</td>
</tr>
@@ -446,10 +556,14 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
{/* Info */}
<div className="px-4 pb-3 text-[10px] text-zinc-600">
{curveActive
? 'Fan curve is active. Server adjusts fan speed based on GPU temperature.'
? "Fan curve is active. Server adjusts fan speed based on GPU temperature."
: isDefaults
? '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.'}
? "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."}
<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.
</span>
</div>
</div>
+106 -44
View File
@@ -1,16 +1,27 @@
import { useState, useEffect, useRef } from 'react';
import { Save, Trash2, Check, ChevronRight, Pencil, Star } from 'lucide-react';
import { api } from '../../api/client';
import type { ProfileData } from '../../types';
import { toast } from 'sonner';
import { useCurveStore } from '../../store/curveStore';
import { useState, useEffect, useRef } from "react";
import {
Save,
Trash2,
Check,
ChevronRight,
Pencil,
Star,
Fan,
} from "lucide-react";
import { api } from "../../api/client";
import type { ProfileData } from "../../types";
import { toast } from "sonner";
import { useCurveStore } from "../../store/curveStore";
interface ProfilePanelProps {
activeProfile: string | null;
onProfileApplied: (name: string | null) => void;
}
export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelProps) {
export function ProfilePanel({
activeProfile,
onProfileApplied,
}: ProfilePanelProps) {
const { selectedGpuIndex, gpuInfo } = useCurveStore();
const [profiles, setProfiles] = useState<ProfileData[]>([]);
const [loading, setLoading] = useState(true);
@@ -18,7 +29,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
// Save form
const [isSaveOpen, setIsSaveOpen] = useState(false);
const [newName, setNewName] = useState('');
const [newName, setNewName] = useState("");
const [isSaving, setIsSaving] = useState(false);
const saveInputRef = useRef<HTMLInputElement>(null);
@@ -28,7 +39,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
// Inline rename
const [renamingName, setRenamingName] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [renameValue, setRenameValue] = useState("");
const [isRenaming, setIsRenaming] = useState(false);
const renameInputRef = useRef<HTMLInputElement>(null);
@@ -42,7 +53,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
onProfileApplied(data.active);
setAutoLoadProfile(data.auto_load);
} catch {
toast.error('Failed to load profiles');
toast.error("Failed to load profiles");
} finally {
setLoading(false);
}
@@ -53,17 +64,21 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
await api.setAutoLoadProfile(name, selectedGpuIndex);
setAutoLoadProfile(name);
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) {
toast.error('Failed to update default profile: ' + (e.message || String(e)));
toast.error(
"Failed to update default profile: " + (e.message || String(e)),
);
}
}
useEffect(() => { fetchProfiles(); }, [selectedGpuIndex]);
useEffect(() => {
fetchProfiles();
}, [selectedGpuIndex]);
useEffect(() => {
if (isSaveOpen) saveInputRef.current?.focus();
else setNewName('');
else setNewName("");
}, [isSaveOpen]);
useEffect(() => {
@@ -80,7 +95,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
setIsSaveOpen(false);
await fetchProfiles();
} catch (e: any) {
toast.error('Failed to save: ' + (e.message || String(e)));
toast.error("Failed to save: " + (e.message || String(e)));
} finally {
setIsSaving(false);
}
@@ -109,7 +124,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
setDeletingName(null);
await fetchProfiles();
} catch (e: any) {
toast.error('Failed to delete: ' + (e.message || String(e)));
toast.error("Failed to delete: " + (e.message || String(e)));
} finally {
setIsDeleting(false);
}
@@ -130,7 +145,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
setRenamingName(null);
await fetchProfiles();
} catch (e: any) {
toast.error('Failed to rename: ' + (e.message || String(e)));
toast.error("Failed to rename: " + (e.message || String(e)));
} finally {
setIsRenaming(false);
}
@@ -162,9 +177,9 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
type="text"
placeholder="Profile name..."
value={newName}
onChange={e => setNewName(e.target.value)}
onChange={(e) => setNewName(e.target.value)}
disabled={isSaving}
onKeyDown={e => e.key === 'Escape' && setIsSaveOpen(false)}
onKeyDown={(e) => e.key === "Escape" && setIsSaveOpen(false)}
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
@@ -201,13 +216,17 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
</p>
) : (
<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 badges = [
pts > 0 ? `${pts} pts` : 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,
].filter(Boolean).join(' · ');
p.mem_offset_mhz != null
? `${p.mem_offset_mhz > 0 ? "+" : ""}${p.mem_offset_mhz} MHz mem`
: null,
]
.filter(Boolean)
.join(" · ");
const isActive = activeProfile === p.name;
const isAutoLoad = autoLoadProfile === p.name;
@@ -218,11 +237,13 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
return (
<div
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 ? (
<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">
<button
onClick={() => handleDeleteConfirm(p.name)}
@@ -240,14 +261,19 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
</div>
</div>
) : 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
ref={renameInputRef}
type="text"
value={renameValue}
onChange={e => setRenameValue(e.target.value)}
onChange={(e) => setRenameValue(e.target.value)}
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"
/>
<button
@@ -268,25 +294,48 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
) : (
<div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2 min-w-0 pr-2">
{isActive
? <Check size={13} className="text-emerald-400 shrink-0" />
: <span className="w-[13px] shrink-0" />
}
{isActive ? (
<Check
size={13}
className="text-emerald-400 shrink-0"
/>
) : (
<span className="w-[13px] shrink-0" />
)}
<div className="min-w-0">
<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>
{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 && (
<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}
</span>
)}
</div>
{badges && <p className="text-xs text-zinc-500">{badges}</p>}
{badges && (
<p className="text-xs text-zinc-500">{badges}</p>
)}
</div>
</div>
@@ -296,21 +345,34 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
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"
>
{isApplying
? <span className="w-3 h-3 border border-zinc-500 border-t-emerald-400 rounded-full animate-spin" />
: <ChevronRight size={13} />
}
{isApplying ? (
<span className="w-3 h-3 border border-zinc-500 border-t-emerald-400 rounded-full animate-spin" />
) : (
<ChevronRight size={13} />
)}
Apply
</button>
<button
onClick={() => handleSetAutoLoad(isAutoLoad ? null : p.name)}
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'}
onClick={() =>
handleSetAutoLoad(isAutoLoad ? null : p.name)
}
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
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"
title="Rename"
>
+3
View File
@@ -2092,6 +2092,9 @@ def main():
elif "auto_load_profile" in data:
# Migrate old single-string format — GPU 0, no UUID known at this point
cfg.auto_load_profiles = {"idx:0": data["auto_load_profile"]}
if "fan_curves" in data:
# Per-GPU active fan curves, restored on server startup.
cfg.fan_curves = dict(data["fan_curves"])
except Exception:
pass
+7
View File
@@ -30,6 +30,13 @@ class Config:
# Value = profile name (str).
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 = list of {"temp_c": int, "fan_pct": int} sorted by temp_c.
fan_curves: dict[str, list] = field(default_factory=dict)
# Module-level default config instance.
default_config = Config()
+96 -42
View File
@@ -237,6 +237,58 @@ async def _fan_poller(gpu_index: int) -> None:
await asyncio.sleep(2.0)
async def _activate_fan_curve(gpu_index: int, curve: list) -> None:
"""Set the active fan curve, (re)start the poller, and persist it.
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)
# 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_poller_task"] = asyncio.create_task(_fan_poller(gpu_index))
cfg: Config = _state["config"]
cfg.fan_curves[_gpu_stable_key(gpu_index)] = curve
_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
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 ──────────────────────────────────────────────────────────────────
@@ -326,6 +378,35 @@ 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)
curve = cfg.fan_curves.get(key)
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)
except Exception as exc:
log.warning(
"Failed to restore persisted fan curve on GPU %d: %s",
gpu_index,
exc,
)
# ──────────────────────────────────────────────────────────────────────────
yield # server is running
for task in poller_tasks:
@@ -745,6 +826,11 @@ def _persist_auto_load_profiles(profiles: dict[str, str]) -> 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")
async def api_profiles(gpu_index: int = 0):
"""List saved native profiles, the active profile name, and the auto-load profile name."""
@@ -931,31 +1017,16 @@ async def _apply_profile(name: str, gpu_index: int = 0) -> list[str]:
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:
ok, msg = validate_curve(profile.fan_curve)
if not ok:
errs.append(f"Fan curve: {msg}")
else:
# Stop existing fan 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"] = profile.fan_curve
g_state["fan_curve_active"] = True
g_state["fan_poller_task"] = asyncio.create_task(_fan_poller(gpu_index))
await _activate_fan_curve(gpu_index, profile.fan_curve)
elif g_state.get("fan_curve_active"):
# Profile has no fan curve, deactivate any active fan curve
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)
await _deactivate_fan_curve(gpu_index, reset_hardware=True)
if not errs:
g_state["active_profile"] = name
@@ -1196,15 +1267,8 @@ async def api_fans_update(req: FanCurveRequest, gpu_index: int = 0):
status_code=500, detail=f"Fan control not available: {fan_msg}"
)
# 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_data
g_state["fan_curve_active"] = True
g_state["fan_poller_task"] = asyncio.create_task(_fan_poller(gpu_index))
# Activate the curve (starts the poller) and persist it so it survives restarts.
await _activate_fan_curve(gpu_index, curve_data)
return {"ok": True}
@@ -1212,21 +1276,11 @@ async def api_fans_update(req: FanCurveRequest, gpu_index: int = 0):
@app.post("/api/fans/reset")
async def api_fans_reset(gpu_index: int = 0):
"""Deactivate fan curve control and restore automatic fan mode."""
g_state = _get_gpu_state(gpu_index)
_get_gpu_state(gpu_index)
# Stop poller
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
ok, msg = await _run(reset_fan, gpu_index)
if not ok:
log.warning("Fan reset warning: %s", msg)
# Stop the poller, clear state, restore automatic fan control, and clear
# the persisted curve so it is not re-applied on the next server start.
await _deactivate_fan_curve(gpu_index, reset_hardware=True)
return {"ok": True}