Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
418894a77b | ||
|
|
06db52e8ac | ||
|
|
d9260007d1 | ||
|
|
9cf2009d2c | ||
|
|
0320cea895 |
No files matched your search
@@ -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.
|
|
||||||
@@ -12,7 +12,10 @@ 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. \
|
||||||
|
> **Dashboard** — The default tab is an Dashboard with additional information (PCIe link speed, VBIOS information, Max Core Clock, Throttle Reason and much much more.) \
|
||||||
|
> Installing the pre-built PyPI package will NOT include these features. You must build from source.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
-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
|
|
||||||
@@ -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";
|
||||||
import { useCurveStore } from '../../store/curveStore';
|
import { useCurveStore } from "../../store/curveStore";
|
||||||
import type { FanPoint, FanState } from '../../types';
|
import type { FanPoint, FanState } from "../../types";
|
||||||
import { toast } from 'sonner';
|
import { toast } from "sonner";
|
||||||
import { ConfirmDialog } from '../common/ConfirmDialog';
|
import { ConfirmDialog } from "../common/ConfirmDialog";
|
||||||
|
|
||||||
function defaultCurve(): FanPoint[] {
|
function defaultCurve(): FanPoint[] {
|
||||||
return [
|
return [
|
||||||
@@ -35,7 +35,9 @@ 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) {
|
||||||
@@ -63,13 +65,15 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
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]);
|
useEffect(() => {
|
||||||
|
fetchFans();
|
||||||
|
}, [selectedGpuIndex]);
|
||||||
|
|
||||||
const activeCurve = pending ?? fanState?.curve ?? defaultCurve();
|
const activeCurve = pending ?? fanState?.curve ?? defaultCurve();
|
||||||
const hasPending = pending !== null;
|
const hasPending = pending !== null;
|
||||||
@@ -87,7 +91,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
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: any) {
|
||||||
setError(e.message ?? String(e));
|
setError(e.message ?? String(e));
|
||||||
setConfirmApply(false);
|
setConfirmApply(false);
|
||||||
@@ -105,7 +109,7 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
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: any) {
|
||||||
setError(e.message ?? String(e));
|
setError(e.message ?? String(e));
|
||||||
setConfirmReset(false);
|
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 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,41 +152,47 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
setDragIdx(idx);
|
setDragIdx(idx);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePointerMove = useCallback((e: React.PointerEvent<SVGSVGElement>) => {
|
const handlePointerMove = useCallback(
|
||||||
if (dragIdx === null) return;
|
(e: React.PointerEvent<SVGSVGElement>) => {
|
||||||
e.preventDefault();
|
if (dragIdx === null) return;
|
||||||
const svg = svgRef.current;
|
e.preventDefault();
|
||||||
if (!svg) return;
|
const svg = svgRef.current;
|
||||||
const rect = svg.getBoundingClientRect();
|
if (!svg) return;
|
||||||
const scaleX = CHART_W / rect.width;
|
const rect = svg.getBoundingClientRect();
|
||||||
const scaleY = CHART_H / rect.height;
|
const scaleX = CHART_W / rect.width;
|
||||||
const x = (e.clientX - rect.left) * scaleX;
|
const scaleY = CHART_H / rect.height;
|
||||||
const y = (e.clientY - rect.top) * scaleY;
|
const x = (e.clientX - rect.left) * scaleX;
|
||||||
|
const y = (e.clientY - rect.top) * scaleY;
|
||||||
|
|
||||||
const temp = Math.max(TEMP_MIN, Math.min(TEMP_MAX, xToTemp(x)));
|
const temp = Math.max(TEMP_MIN, Math.min(TEMP_MAX, xToTemp(x)));
|
||||||
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 curve = pending ?? fanState?.curve ?? defaultCurve();
|
const curve = pending ?? fanState?.curve ?? defaultCurve();
|
||||||
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
|
||||||
: null;
|
? activeCurve
|
||||||
|
.map((p) => `${tempToX(p.temp_c)},${fanToY(p.fan_pct)}`)
|
||||||
|
.join(" ")
|
||||||
|
: null;
|
||||||
|
|
||||||
// Grid lines
|
// Grid lines
|
||||||
const tempTicks = [30, 40, 50, 60, 70, 80, 90];
|
const tempTicks = [30, 40, 50, 60, 70, 80, 90];
|
||||||
@@ -202,7 +214,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,7 +246,10 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
Apply
|
Apply
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setPending(null); setError(null); }}
|
onClick={() => {
|
||||||
|
setPending(null);
|
||||||
|
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"
|
||||||
>
|
>
|
||||||
@@ -254,7 +271,12 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
{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 +285,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>
|
||||||
)}
|
)}
|
||||||
@@ -272,51 +295,95 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
|
|||||||
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"
|
||||||
style={{ touchAction: '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 +395,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 +407,62 @@ 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"
|
||||||
style={{ touchAction: '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 +475,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 +492,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 +520,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,10 +556,14 @@ 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. 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.
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
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";
|
||||||
|
import type { ProfileData } from "../../types";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useCurveStore } from "../../store/curveStore";
|
||||||
|
|
||||||
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 +29,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 +39,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 +53,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,17 +64,21 @@ 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: 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(() => {
|
useEffect(() => {
|
||||||
if (isSaveOpen) saveInputRef.current?.focus();
|
if (isSaveOpen) saveInputRef.current?.focus();
|
||||||
else setNewName('');
|
else setNewName("");
|
||||||
}, [isSaveOpen]);
|
}, [isSaveOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -80,7 +95,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
setIsSaveOpen(false);
|
setIsSaveOpen(false);
|
||||||
await fetchProfiles();
|
await fetchProfiles();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error('Failed to save: ' + (e.message || String(e)));
|
toast.error("Failed to save: " + (e.message || String(e)));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -109,7 +124,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
setDeletingName(null);
|
setDeletingName(null);
|
||||||
await fetchProfiles();
|
await fetchProfiles();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error('Failed to delete: ' + (e.message || String(e)));
|
toast.error("Failed to delete: " + (e.message || String(e)));
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
@@ -130,7 +145,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
|
|||||||
setRenamingName(null);
|
setRenamingName(null);
|
||||||
await fetchProfiles();
|
await fetchProfiles();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error('Failed to rename: ' + (e.message || String(e)));
|
toast.error("Failed to rename: " + (e.message || String(e)));
|
||||||
} finally {
|
} finally {
|
||||||
setIsRenaming(false);
|
setIsRenaming(false);
|
||||||
}
|
}
|
||||||
@@ -162,9 +177,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" && 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"
|
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
|
||||||
@@ -201,13 +216,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 +237,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 +261,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 +294,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 +345,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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2092,6 +2092,9 @@ def main():
|
|||||||
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"]}
|
||||||
|
if "fan_curves" in data:
|
||||||
|
# Per-GPU active fan curves, restored on server startup.
|
||||||
|
cfg.fan_curves = dict(data["fan_curves"])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ 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 = 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.
|
# Module-level default config instance.
|
||||||
default_config = Config()
|
default_config = Config()
|
||||||
+96
-42
@@ -237,6 +237,58 @@ 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) -> 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 ──────────────────────────────────────────────────────────────────
|
# ── 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
|
yield # server is running
|
||||||
|
|
||||||
for task in poller_tasks:
|
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)
|
_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."""
|
||||||
@@ -931,31 +1017,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)
|
||||||
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
|
||||||
@@ -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}"
|
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)
|
||||||
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,21 +1276,11 @@ 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}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user