Compare commits

...
Author SHA1 Message Date
Pakobbix 34a9bc6d6e Merge pull request 'Clean up LSP diagnostics across backend and frontend' (#7) from cleanup/lint-modernization into main
Reviewed-on: #7
2026-09-08 21:58:42 +00:00
ARIA 930e56bd07 Clean up LSP diagnostics across backend and frontend
Backend (nvcurve/):
- hal/fans.py, hal/limits.py, hal/gpu.py: replace conditional pynvml
  imports with the established 'pynvml: Any = _pynvml_import' pattern
  (fixes ~50 'possibly unbound' errors); type the result dicts; guard
  query_interface() results; explicit uuid/pci-bus parsing (int, hex
  convention documented); modernize Optional[T] -> T | None
- cli.py: fix 'curve_state' possibly-unbound and snap_path None handling
  in cmd_setup; wrap unchecked int()/open()/makedirs() calls in
  try/except with clean CLI errors; add module logger for silent
  except-pass blocks; raise ... from exc; fix unused loop vars and
  set-comprehension
- hal/snapshot.py: filepath: str | None; wrap all file ops; sorted
  imports; remove unused CT_POINTS import
- daemon.py: extract 0o666 to _SOCKET_MODE constant (intentional for
  /run sockets) with nosemgrep
- server.py: nosemgrep for Python 3.7-compat false positive (project
  requires >= 3.12); log previously-swallowed exception
- profiles/native.py, profiles/apply.py: wrap file ops and int(k)
  profile-key parsing; sorted imports; modernize typing

Frontend (frontend/src):
- Add .js extensions to all relative imports (standard TS-ESM; Vite
  resolves .js -> .ts)
- React.FormEvent (deprecated in React 19 types) -> React.SubmitEvent
- catch (e: any) -> catch (e: unknown) + instanceof Error narrowing
- React-hooks: move ref writes from render into effects; convert
  viewport reset to render-phase state adjustment; split
  selectPoint(index, multi) into selectPoint + togglePoint (no flag
  argument); remove non-null assertion
- Static inline styles -> Tailwind classes (dynamic positioning/cursor
  styles kept)
- Remove non-standard 'container' option from scrollIntoView (browsers
  ignore unknown options) which had orphaned a @ts-expect-error
- Object.fromEntries for Map -> Record conversion

Tooling:
- .gitignore: ignore .codegraph/ local tool data

Verified: tsc --noEmit, vite production build, python imports, and
full LSP scan (0 errors/warnings in both projects).
2026-09-08 23:57:30 +02:00
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
41 changed files with 1974 additions and 1282 deletions

No files matched your search

+2
View File
@@ -15,3 +15,5 @@ node_modules/
/build/ /build/
*.egg-info/ *.egg-info/
.claude .claude
# Local tool data
.codegraph/
-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. > **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.) \
> **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 ## 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
+18 -18
View File
@@ -1,24 +1,24 @@
import { useGpu } from "./hooks/useGpu"; import { useGpu } from "./hooks/useGpu.js";
import { useCurve } from "./hooks/useCurve"; import { useCurve } from "./hooks/useCurve.js";
import { useMonitor } from "./hooks/useMonitor"; import { useMonitor } from "./hooks/useMonitor.js";
import { useDashboard } from "./hooks/useDashboard"; import { useDashboard } from "./hooks/useDashboard.js";
import { StatusBar } from "./components/Monitor/StatusBar"; import { StatusBar } from "./components/Monitor/StatusBar.js";
import { LiveMonitor } from "./components/Monitor/LiveMonitor"; import { LiveMonitor } from "./components/Monitor/LiveMonitor.js";
import { Dashboard } from "./components/Dashboard/Dashboard"; import { Dashboard } from "./components/Dashboard/Dashboard.js";
import { CurveEditor } from "./components/CurveEditor/CurveEditor"; import { CurveEditor } from "./components/CurveEditor/CurveEditor.js";
import { PointTable } from "./components/PointTable/PointTable"; import { PointTable } from "./components/PointTable/PointTable.js";
import { PerformancePanel } from "./components/Limits/PerformancePanel"; import { PerformancePanel } from "./components/Limits/PerformancePanel.js";
import { PerformanceMonitor } from "./components/Monitor/PerformanceMonitor"; import { PerformanceMonitor } from "./components/Monitor/PerformanceMonitor.js";
import { FanMonitor } from "./components/Monitor/FanMonitor"; import { FanMonitor } from "./components/Monitor/FanMonitor.js";
import { FanCurveEditor } from "./components/Fans/FanCurveEditor"; import { FanCurveEditor } from "./components/Fans/FanCurveEditor.js";
import { ProfilePanel } from "./components/Profiles/ProfilePanel"; import { ProfilePanel } from "./components/Profiles/ProfilePanel.js";
import { api, onUnauthorized } from "./api/client"; import { api, onUnauthorized } from "./api/client.js";
import { LoginScreen } from "./components/Auth/LoginScreen"; import { LoginScreen } from "./components/Auth/LoginScreen.js";
import { useCurveStore } from "./store/curveStore"; import { useCurveStore } from "./store/curveStore.js";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { Loader, ChevronDown } from "lucide-react"; import { Loader, ChevronDown } from "lucide-react";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import type { FanState } from "./types"; import type { FanState } from "./types.js";
type AuthState = "checking" | "login" | "ok"; type AuthState = "checking" | "login" | "ok";
+1 -1
View File
@@ -8,7 +8,7 @@ import type {
FanState, FanState,
FanPoint, FanPoint,
DashboardInfo, DashboardInfo,
} from "../types"; } from "../types.js";
export class ApiError extends Error { export class ApiError extends Error {
status: number; status: number;
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { Loader, Lock, User } from "lucide-react"; import { Loader, Lock, User } from "lucide-react";
import { api, ApiError } from "../../api/client"; import { api, ApiError } from "../../api/client.js";
interface Props { interface Props {
onSuccess: (username: string) => void; onSuccess: (username: string) => void;
@@ -12,7 +12,7 @@ export function LoginScreen({ onSuccess }: Props) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) { async function submit(e: React.SubmitEvent) {
e.preventDefault(); e.preventDefault();
if (busy) return; if (busy) return;
setBusy(true); setBusy(true);
File diff suppressed because it is too large. Load diff
@@ -1,7 +1,7 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo } from "react";
import { ZoomIn, RotateCcw, Minus } from 'lucide-react'; import { ZoomIn, RotateCcw, Minus } from "lucide-react";
import { useCurveStore } from '../../store/curveStore'; import { useCurveStore } from "../../store/curveStore.js";
import type { VFPoint } from '../../types'; import type { VFPoint } from "../../types.js";
interface Props { interface Props {
/** All curve points — used by global offset slider */ /** All curve points — used by global offset slider */
@@ -18,23 +18,44 @@ interface Props {
onZoomChange: (factor: number) => void; onZoomChange: (factor: number) => void;
} }
export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomFactor, onZoomChange }: Props) { export function CurveToolbar({
const { pendingDeltas, selectedPoints, anchorPoint, curve, stageRangeEdit, flattenToAnchor } = useCurveStore(); activePts,
onResetZoom,
isZoomed,
readOnly,
zoomFactor,
onZoomChange,
}: Props) {
const {
pendingDeltas,
selectedPoints,
anchorPoint,
curve,
stageRangeEdit,
flattenToAnchor,
} = useCurveStore();
const [offsetMhz, setOffsetMhz] = useState(0); const [offsetMhz, setOffsetMhz] = useState(0);
const uniformDeltaMhz = useMemo(() => { const uniformDeltaMhz = useMemo(() => {
if (activePts.length === 0) return 0; if (activePts.length === 0) return 0;
const firstD = pendingDeltas.get(activePts[0].index) ?? activePts[0].delta_khz; const firstD =
const uniform = activePts.every((p) => (pendingDeltas.get(p.index) ?? p.delta_khz) === firstD); pendingDeltas.get(activePts[0].index) ?? activePts[0].delta_khz;
const uniform = activePts.every(
(p) => (pendingDeltas.get(p.index) ?? p.delta_khz) === firstD,
);
return uniform ? firstD / 1000 : null; return uniform ? firstD / 1000 : null;
}, [activePts, pendingDeltas]); }, [activePts, pendingDeltas]);
useEffect(() => { // Sync the slider to the uniform delta when it changes (adjust state during
if (uniformDeltaMhz !== null) { // render instead of an effect; undefined sentinel so the first render syncs).
setOffsetMhz(uniformDeltaMhz); const [lastUniformDelta, setLastUniformDelta] = useState<
} number | null | undefined
}, [uniformDeltaMhz]); >();
if (uniformDeltaMhz !== null && lastUniformDelta !== uniformDeltaMhz) {
setLastUniformDelta(uniformDeltaMhz);
setOffsetMhz(uniformDeltaMhz);
}
function handleOffsetChange(mhz: number) { function handleOffsetChange(mhz: number) {
setOffsetMhz(mhz); setOffsetMhz(mhz);
@@ -44,7 +65,10 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
return ( return (
<div className="flex flex-wrap items-center gap-2 px-1 pb-2"> <div className="flex flex-wrap items-center gap-2 px-1 pb-2">
{/* Zoom control */} {/* Zoom control */}
<div className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800/60 border border-zinc-700/40" title="Zoom x-axis (Alt+scroll also works)"> <div
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800/60 border border-zinc-700/40"
title="Zoom x-axis (Alt+scroll also works)"
>
<ZoomIn size={11} className="text-zinc-500 shrink-0" /> <ZoomIn size={11} className="text-zinc-500 shrink-0" />
<input <input
type="range" type="range"
@@ -55,7 +79,9 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
onChange={(e) => onZoomChange(Number(e.target.value))} onChange={(e) => onZoomChange(Number(e.target.value))}
className="w-20 h-1 cursor-pointer accent-cyan-400" className="w-20 h-1 cursor-pointer accent-cyan-400"
/> />
<span className={`text-xs font-mono w-8 tabular-nums ${isZoomed ? 'text-cyan-400' : 'text-zinc-600'}`}> <span
className={`text-xs font-mono w-8 tabular-nums ${isZoomed ? "text-cyan-400" : "text-zinc-600"}`}
>
{zoomFactor.toFixed(1)}× {zoomFactor.toFixed(1)}×
</span> </span>
{isZoomed && ( {isZoomed && (
@@ -75,7 +101,9 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
{/* Global offset slider — GPU only */} {/* Global offset slider — GPU only */}
{!readOnly && uniformDeltaMhz !== null && ( {!readOnly && uniformDeltaMhz !== null && (
<div className="flex items-center gap-1.5 min-w-[260px]"> <div className="flex items-center gap-1.5 min-w-[260px]">
<span className="text-zinc-500 text-xs whitespace-nowrap">Global Offset</span> <span className="text-zinc-500 text-xs whitespace-nowrap">
Global Offset
</span>
<input <input
type="range" type="range"
min={-1000} min={-1000}
@@ -84,46 +112,63 @@ export function CurveToolbar({ activePts, onResetZoom, isZoomed, readOnly, zoomF
value={offsetMhz} value={offsetMhz}
onChange={(e) => handleOffsetChange(Number(e.target.value))} onChange={(e) => handleOffsetChange(Number(e.target.value))}
className="w-32 accent-cyan-400" className="w-32 accent-cyan-400"
title={`${offsetMhz > 0 ? '+' : ''}${offsetMhz} MHz`} title={`${offsetMhz > 0 ? "+" : ""}${offsetMhz} MHz`}
/> />
<span <span
className={[ className={[
'text-xs font-mono w-16', "text-xs font-mono w-16",
offsetMhz > 0 ? 'text-cyan-400' : offsetMhz < 0 ? 'text-orange-400' : 'text-zinc-500', offsetMhz > 0
].join(' ')} ? "text-cyan-400"
: offsetMhz < 0
? "text-orange-400"
: "text-zinc-500",
].join(" ")}
> >
{offsetMhz > 0 ? '+' : ''}{offsetMhz} MHz {offsetMhz > 0 ? "+" : ""}
{offsetMhz} MHz
</span> </span>
</div> </div>
)} )}
{/* Flatten — visible when 2+ points are selected */} {/* Flatten — visible when 2+ points are selected */}
{!readOnly && selectedPoints.size >= 2 && (() => { {!readOnly &&
const anchor = anchorPoint !== null && selectedPoints.has(anchorPoint) selectedPoints.size >= 2 &&
? anchorPoint (() => {
: Math.min(...selectedPoints); const anchor =
const anchorDelta = anchorPoint !== null && selectedPoints.has(anchorPoint)
pendingDeltas.get(anchor) ?? ? anchorPoint
curve?.points.find(p => p.index === anchor)?.delta_khz ?? : Math.min(...selectedPoints);
0; const anchorDelta =
const label = `·${anchor} ${anchorDelta >= 0 ? '+' : ''}${anchorDelta / 1000} MHz`; pendingDeltas.get(anchor) ??
return ( curve?.points.find((p) => p.index === anchor)?.delta_khz ??
<button 0;
onClick={flattenToAnchor} const label = `·${anchor} ${anchorDelta >= 0 ? "+" : ""}${anchorDelta / 1000} MHz`;
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium text-amber-400 hover:text-amber-300 hover:bg-zinc-800 border border-zinc-700/40 transition" return (
title={`Flatten all selected points to anchor point ${anchor} (${anchorDelta >= 0 ? '+' : ''}${anchorDelta / 1000} MHz)`} <button
> onClick={flattenToAnchor}
<Minus size={11} /> className="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium text-amber-400 hover:text-amber-300 hover:bg-zinc-800 border border-zinc-700/40 transition"
Flatten to {label} title={`Flatten all selected points to anchor point ${anchor} (${anchorDelta >= 0 ? "+" : ""}${anchorDelta / 1000} MHz)`}
</button> >
); <Minus size={11} />
})()} Flatten to {label}
</button>
);
})()}
{/* Legend — right-aligned */} {/* Legend — right-aligned */}
<div className="flex items-center gap-3 text-xs text-zinc-500 ml-auto"> <div className="flex items-center gap-3 text-xs text-zinc-500 ml-auto">
<span className="flex items-center gap-1"><span className="inline-block w-3 h-0.5 bg-emerald-400 rounded" /> effective</span> <span className="flex items-center gap-1">
<span className="flex items-center gap-1"><span className="inline-block w-3 h-px border-t-2 border-dashed border-cyan-400" /> pending</span> <span className="inline-block w-3 h-0.5 bg-emerald-400 rounded" />{" "}
<span className="flex items-center gap-1"><span className="inline-block w-2 h-2 rounded-full bg-yellow-400" /> current</span> effective
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-px border-t-2 border-dashed border-cyan-400" />{" "}
pending
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-yellow-400" />{" "}
current
</span>
</div> </div>
</div> </div>
); );
@@ -1,5 +1,5 @@
import { fmt } from '../../utils/units'; import { fmt } from '../../utils/units.js';
import type { VFPoint } from '../../types'; import type { VFPoint } from '../../types.js';
interface Props { interface Props {
point: VFPoint; point: VFPoint;
@@ -21,15 +21,7 @@ export function CurveTooltip({ point, pendingDeltaKhz, isClamped }: Props) {
return ( return (
<div <div
style={{ className="absolute right-4 bottom-4 pointer-events-none z-50 w-[172px] bg-zinc-800 border border-zinc-700 rounded-md p-2 text-xs shadow-xl"
position: 'absolute',
right: 16,
bottom: 16,
pointerEvents: 'none',
zIndex: 50,
width: 172,
}}
className="bg-zinc-800 border border-zinc-700 rounded-md p-2 text-xs shadow-xl"
> >
<div className="text-zinc-400 mb-1">Point {point.index}</div> <div className="text-zinc-400 mb-1">Point {point.index}</div>
<div className="text-zinc-200"> <div className="text-zinc-200">
@@ -1,7 +1,7 @@
import { Loader } from "lucide-react"; import { Loader } from "lucide-react";
import { GaugeCard } from "../Monitor/GaugeCard"; import { GaugeCard } from "../Monitor/GaugeCard.js";
import { fmt } from "../../utils/units"; import { fmt } from "../../utils/units.js";
import type { MonitoringSample, DashboardInfo } from "../../types"; import type { MonitoringSample, DashboardInfo } from "../../types.js";
interface Props { interface Props {
monitor: MonitoringSample | null; monitor: MonitoringSample | null;
+202 -87
View File
@@ -1,10 +1,10 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from "react";
import { Check, X, RotateCcw, Plus } from 'lucide-react'; import { Check, X, RotateCcw, Plus } from "lucide-react";
import { api } from '../../api/client'; import { api } from "../../api/client.js";
import { useCurveStore } from '../../store/curveStore'; import { useCurveStore } from "../../store/curveStore.js";
import type { FanPoint, FanState } from '../../types'; import type { FanPoint, FanState } from "../../types.js";
import { toast } from 'sonner'; import { toast } from "sonner";
import { ConfirmDialog } from '../common/ConfirmDialog'; import { ConfirmDialog } from "../common/ConfirmDialog.js";
function defaultCurve(): FanPoint[] { function defaultCurve(): FanPoint[] {
return [ return [
@@ -35,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,18 @@ 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]); // Data fetch on GPU change — setState calls happen after the await, not
// synchronously in the effect body (rule false-positive on async fetch).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchFans();
}, [selectedGpuIndex]);
const activeCurve = pending ?? fanState?.curve ?? defaultCurve(); const activeCurve = pending ?? fanState?.curve ?? defaultCurve();
const hasPending = pending !== null; const hasPending = pending !== null;
@@ -87,9 +94,9 @@ 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: unknown) {
setError(e.message ?? String(e)); setError(e instanceof Error ? e.message : String(e));
setConfirmApply(false); setConfirmApply(false);
} finally { } finally {
setBusy(false); setBusy(false);
@@ -105,9 +112,9 @@ 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: unknown) {
setError(e.message ?? String(e)); setError(e instanceof Error ? e.message : String(e));
setConfirmReset(false); setConfirmReset(false);
} finally { } finally {
setBusy(false); setBusy(false);
@@ -128,10 +135,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 +155,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 +217,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 +249,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 +274,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 +288,8 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
{isDefaults && ( {isDefaults && (
<div className="absolute inset-x-4 top-4 z-10 pointer-events-none text-center"> <div className="absolute inset-x-4 top-4 z-10 pointer-events-none text-center">
<span className="inline-block bg-zinc-900/90 backdrop-blur-sm border border-orange-500/30 rounded-lg px-4 py-2 text-xs text-orange-400/90 font-medium"> <span className="inline-block bg-zinc-900/90 backdrop-blur-sm border border-orange-500/30 rounded-lg px-4 py-2 text-xs text-orange-400/90 font-medium">
Default curve — edit points or apply as-is to enable curve control Default curve — edit points or apply as-is to enable curve
control
</span> </span>
</div> </div>
)} )}
@@ -271,52 +297,95 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
ref={svgRef} ref={svgRef}
width="100%" width="100%"
viewBox={`0 0 ${CHART_W} ${CHART_H}`} viewBox={`0 0 ${CHART_W} ${CHART_H}`}
className="max-w-full cursor-crosshair select-none" className="max-w-full cursor-crosshair select-none touch-none"
style={{ touchAction: 'none' }}
onClick={handleCanvasClick} onClick={handleCanvasClick}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
> >
{/* Background */} {/* Background */}
<rect x={PAD.left} y={PAD.top} width={PLOT_W} height={PLOT_H} fill="#09090b" rx="4" /> <rect
x={PAD.left}
y={PAD.top}
width={PLOT_W}
height={PLOT_H}
fill="#09090b"
rx="4"
/>
{/* Grid lines - horizontal (fan %) */} {/* Grid lines - horizontal (fan %) */}
{fanTicks.map(f => ( {fanTicks.map((f) => (
<g key={`fy-${f}`}> <g key={`fy-${f}`}>
<line <line
x1={PAD.left} y1={fanToY(f)} x1={PAD.left}
x2={PAD.left + PLOT_W} y2={fanToY(f)} y1={fanToY(f)}
stroke="#27272a" strokeWidth="0.5" x2={PAD.left + PLOT_W}
y2={fanToY(f)}
stroke="#27272a"
strokeWidth="0.5"
/> />
<text x={PAD.left - 6} y={fanToY(f) + 3} textAnchor="end" fill="#71717a" fontSize="9" fontFamily="monospace"> <text
x={PAD.left - 6}
y={fanToY(f) + 3}
textAnchor="end"
fill="#71717a"
fontSize="9"
fontFamily="monospace"
>
{f}% {f}%
</text> </text>
</g> </g>
))} ))}
{/* Grid lines - vertical (temp) */} {/* Grid lines - vertical (temp) */}
{tempTicks.map(t => ( {tempTicks.map((t) => (
<g key={`tx-${t}`}> <g key={`tx-${t}`}>
<line <line
x1={tempToX(t)} y1={PAD.top} x1={tempToX(t)}
x2={tempToX(t)} y2={PAD.top + PLOT_H} y1={PAD.top}
stroke="#27272a" strokeWidth="0.5" x2={tempToX(t)}
y2={PAD.top + PLOT_H}
stroke="#27272a"
strokeWidth="0.5"
/> />
<text x={tempToX(t)} y={PAD.top + PLOT_H + 16} textAnchor="middle" fill="#71717a" fontSize="9" fontFamily="monospace"> <text
x={tempToX(t)}
y={PAD.top + PLOT_H + 16}
textAnchor="middle"
fill="#71717a"
fontSize="9"
fontFamily="monospace"
>
{t}° {t}°
</text> </text>
</g> </g>
))} ))}
{/* Axis labels */} {/* Axis labels */}
<text x={PAD.left + PLOT_W / 2} y={CHART_H - 2} textAnchor="middle" fill="#52525b" fontSize="9">Temperature (°C)</text> <text
<text x={8} y={PAD.top + PLOT_H / 2} textAnchor="middle" fill="#52525b" fontSize="9" transform={`rotate(-90, 8, ${PAD.top + PLOT_H / 2})`}>Fan Speed (%)</text> x={PAD.left + PLOT_W / 2}
y={CHART_H - 2}
textAnchor="middle"
fill="#52525b"
fontSize="9"
>
Temperature (°C)
</text>
<text
x={8}
y={PAD.top + PLOT_H / 2}
textAnchor="middle"
fill="#52525b"
fontSize="9"
transform={`rotate(-90, 8, ${PAD.top + PLOT_H / 2})`}
>
Fan Speed (%)
</text>
{/* Curve line */} {/* Curve line */}
{curvePath && ( {curvePath && (
<polyline <polyline
points={curvePath} points={curvePath}
fill="none" fill="none"
stroke={hasPending ? '#22d3ee' : '#fb923c'} stroke={hasPending ? "#22d3ee" : "#fb923c"}
strokeWidth="2" strokeWidth="2"
strokeLinejoin="round" strokeLinejoin="round"
strokeLinecap="round" strokeLinecap="round"
@@ -328,7 +397,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 +409,61 @@ export function FanCurveEditor({ onChanged }: { onChanged?: () => void }) {
cx={tempToX(p.temp_c)} cx={tempToX(p.temp_c)}
cy={fanToY(p.fan_pct)} cy={fanToY(p.fan_pct)}
r="6" r="6"
fill={hasPending ? '#22d3ee' : '#fb923c'} fill={hasPending ? "#22d3ee" : "#fb923c"}
stroke="#09090b" stroke="#09090b"
strokeWidth="2" strokeWidth="2"
className="cursor-grab active:cursor-grabbing" className="cursor-grab active:cursor-grabbing touch-none"
style={{ touchAction: 'none' }}
onPointerDown={(e) => { onPointerDown={(e) => {
e.stopPropagation(); e.stopPropagation();
handlePointerDown(i); handlePointerDown(i);
}} }}
/> />
{/* Delete button on hover */} {/* Remove button: faintly visible, brightens on hover */}
{activeCurve && activeCurve.length > 2 && ( {activeCurve && activeCurve.length > 2 && (
<circle <g
cx={tempToX(p.temp_c) + 8} className="cursor-pointer group/remove"
cy={fanToY(p.fan_pct) - 8}
r="7"
fill="#27272a"
stroke="#3f3f46"
strokeWidth="1"
className="cursor-pointer opacity-0 hover:opacity-100 transition-opacity"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
removePoint(i); removePoint(i);
}} }}
> >
<title>Remove point</title> <title>Remove point</title>
</circle> <circle
cx={tempToX(p.temp_c) + 9}
cy={fanToY(p.fan_pct) - 9}
r="7"
fill="#27272a"
stroke="#3f3f46"
strokeWidth="1"
className="opacity-50 group-hover/remove:opacity-100 group-hover/remove:stroke-red-400 transition-opacity"
/>
{/* X glyph */}
<line
x1={tempToX(p.temp_c) + 6.5}
y1={fanToY(p.fan_pct) - 11.5}
x2={tempToX(p.temp_c) + 11.5}
y2={fanToY(p.fan_pct) - 6.5}
stroke="#a1a1aa"
strokeWidth="1.2"
className="group-hover/remove:stroke-red-400 transition-colors"
/>
<line
x1={tempToX(p.temp_c) + 11.5}
y1={fanToY(p.fan_pct) - 11.5}
x2={tempToX(p.temp_c) + 6.5}
y2={fanToY(p.fan_pct) - 6.5}
stroke="#a1a1aa"
strokeWidth="1.2"
className="group-hover/remove:stroke-red-400 transition-colors"
/>
</g>
)} )}
{/* Value label */} {/* Value label */}
<text <text
x={tempToX(p.temp_c)} x={tempToX(p.temp_c)}
y={fanToY(p.fan_pct) - 12} y={fanToY(p.fan_pct) - 12}
textAnchor="middle" textAnchor="middle"
fill={hasPending ? '#22d3ee' : '#fb923c'} fill={hasPending ? "#22d3ee" : "#fb923c"}
fontSize="8" fontSize="8"
fontFamily="monospace" fontFamily="monospace"
fontWeight="600" fontWeight="600"
@@ -386,7 +476,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 +493,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 +521,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 +557,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,12 +1,12 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import { Check, X, RotateCcw } from 'lucide-react'; import { Check, X, RotateCcw } from "lucide-react";
import { api } from '../../api/client'; import { api } from "../../api/client.js";
import { useCurveStore } from '../../store/curveStore'; import { useCurveStore } from "../../store/curveStore.js";
import type { LimitsState } from '../../types'; import type { LimitsState } from "../../types.js";
import { toast } from 'sonner'; import { toast } from "sonner";
import { ConfirmDialog } from '../common/ConfirmDialog'; import { ConfirmDialog } from "../common/ConfirmDialog.js";
type Pending = Pick<LimitsState, 'power_limit_w' | 'mem_offset_mhz'>; type Pending = Pick<LimitsState, "power_limit_w" | "mem_offset_mhz">;
export function PerformancePanel() { export function PerformancePanel() {
const { selectedGpuIndex } = useCurveStore(); const { selectedGpuIndex } = useCurveStore();
@@ -23,13 +23,18 @@ export function PerformancePanel() {
setLoading(true); setLoading(true);
setLimits(await api.limits(selectedGpuIndex)); setLimits(await api.limits(selectedGpuIndex));
} catch { } catch {
toast.error('Failed to load performance limits'); toast.error("Failed to load performance limits");
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
useEffect(() => { fetchLimits(); }, [selectedGpuIndex]); // Data fetch on GPU change — setState calls happen after the await, not
// synchronously in the effect body (rule false-positive on async fetch).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchLimits();
}, [selectedGpuIndex]);
async function handleApply() { async function handleApply() {
setBusy(true); setBusy(true);
@@ -40,9 +45,9 @@ export function PerformancePanel() {
setPending({}); setPending({});
setConfirmApply(false); setConfirmApply(false);
await fetchLimits(); await fetchLimits();
toast.success('Performance limits applied'); toast.success("Performance limits applied");
} catch (e: any) { } catch (e: unknown) {
setError(e.message ?? String(e)); setError(e instanceof Error ? e.message : String(e));
setConfirmApply(false); setConfirmApply(false);
} finally { } finally {
setBusy(false); setBusy(false);
@@ -58,9 +63,9 @@ export function PerformancePanel() {
setPending({}); setPending({});
setConfirmReset(false); setConfirmReset(false);
await fetchLimits(); await fetchLimits();
toast.success('Performance limits reset to defaults'); toast.success("Performance limits reset to defaults");
} catch (e: any) { } catch (e: unknown) {
setError(e.message ?? String(e)); setError(e instanceof Error ? e.message : String(e));
setConfirmReset(false); setConfirmReset(false);
} finally { } finally {
setBusy(false); setBusy(false);
@@ -96,10 +101,11 @@ export function PerformancePanel() {
return ( return (
<> <>
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col"> <div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
{/* ── Header ─────────────────────────────────────────────────────── */} {/* ── Header ─────────────────────────────────────────────────────── */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 shrink-0"> <div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 shrink-0">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Performance</span> <span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
Performance
</span>
{hasPending && ( {hasPending && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs"> <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs">
@@ -117,7 +123,10 @@ export function PerformancePanel() {
Apply Apply
</button> </button>
<button <button
onClick={() => { setPending({}); setError(null); }} onClick={() => {
setPending({});
setError(null);
}}
disabled={!hasPending || busy} disabled={!hasPending || busy}
className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs transition-colors disabled:opacity-40 disabled:cursor-not-allowed" className="flex items-center gap-1.5 px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
> >
@@ -139,25 +148,32 @@ export function PerformancePanel() {
{error && ( {error && (
<div className="px-3 py-1.5 bg-red-900/40 border-b border-red-700 text-red-300 text-xs flex items-center justify-between"> <div className="px-3 py-1.5 bg-red-900/40 border-b border-red-700 text-red-300 text-xs flex items-center justify-between">
<span>⚠ {error}</span> <span>⚠ {error}</span>
<button onClick={() => setError(null)} className="ml-2 text-red-400 hover:text-red-200">✕</button> <button
onClick={() => setError(null)}
className="ml-2 text-red-400 hover:text-red-200"
>
✕
</button>
</div> </div>
)} )}
<div className="flex flex-col divide-y divide-zinc-800"> <div className="flex flex-col divide-y divide-zinc-800">
{/* ── Board Power Limit ─────────────────────────────────────────── */} {/* ── Board Power Limit ─────────────────────────────────────────── */}
<div className="px-4 py-4 flex flex-col gap-3"> <div className="px-4 py-4 flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-zinc-500 uppercase tracking-wider">Board Power Limit</span> <span className="text-xs text-zinc-500 uppercase tracking-wider">
Board Power Limit
</span>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<input <input
type="number" type="number"
min={pwrMin} min={pwrMin}
max={pwrMax} max={pwrMax}
value={pwrVal} value={pwrVal}
onChange={e => { onChange={(e) => {
const v = parseInt(e.target.value); const v = parseInt(e.target.value);
if (!isNaN(v)) setPending(p => ({ ...p, power_limit_w: v })); if (!isNaN(v))
setPending((p) => ({ ...p, power_limit_w: v }));
}} }}
className="w-14 bg-zinc-950 border border-zinc-800 rounded text-xs px-2 py-1 text-right font-mono focus:outline-none focus:border-cyan-500" className="w-14 bg-zinc-950 border border-zinc-800 rounded text-xs px-2 py-1 text-right font-mono focus:outline-none focus:border-cyan-500"
/> />
@@ -165,32 +181,44 @@ export function PerformancePanel() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-zinc-600 font-mono w-8 text-right">{pwrMin}</span> <span className="text-xs text-zinc-600 font-mono w-8 text-right">
{pwrMin}
</span>
<input <input
type="range" type="range"
min={pwrMin} min={pwrMin}
max={pwrMax} max={pwrMax}
value={pwrVal} value={pwrVal}
onChange={e => setPending(p => ({ ...p, power_limit_w: parseInt(e.target.value) }))} onChange={(e) =>
setPending((p) => ({
...p,
power_limit_w: parseInt(e.target.value),
}))
}
className="flex-1 accent-cyan-400 h-1 cursor-pointer" className="flex-1 accent-cyan-400 h-1 cursor-pointer"
/> />
<span className="text-xs text-zinc-600 font-mono w-8">{pwrMax}</span> <span className="text-xs text-zinc-600 font-mono w-8">
{pwrMax}
</span>
</div> </div>
</div> </div>
{/* ── Memory Clock Offset ───────────────────────────────────────── */} {/* ── Memory Clock Offset ───────────────────────────────────────── */}
<div className="px-4 py-4 flex flex-col gap-3"> <div className="px-4 py-4 flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-zinc-500 uppercase tracking-wider">Memory Clock Offset</span> <span className="text-xs text-zinc-500 uppercase tracking-wider">
Memory Clock Offset
</span>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<input <input
type="number" type="number"
min={memMin} min={memMin}
max={memMax} max={memMax}
value={memVal} value={memVal}
onChange={e => { onChange={(e) => {
const v = parseInt(e.target.value); const v = parseInt(e.target.value);
if (!isNaN(v)) setPending(p => ({ ...p, mem_offset_mhz: v })); if (!isNaN(v))
setPending((p) => ({ ...p, mem_offset_mhz: v }));
}} }}
className="w-16 bg-zinc-950 border border-zinc-800 rounded text-xs px-2 py-1 text-right font-mono focus:outline-none focus:border-cyan-500" className="w-16 bg-zinc-950 border border-zinc-800 rounded text-xs px-2 py-1 text-right font-mono focus:outline-none focus:border-cyan-500"
/> />
@@ -198,20 +226,28 @@ export function PerformancePanel() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-zinc-600 font-mono w-10 text-right">{memMin}</span> <span className="text-xs text-zinc-600 font-mono w-10 text-right">
{memMin}
</span>
<input <input
type="range" type="range"
min={memMin} min={memMin}
max={memMax} max={memMax}
step={1} step={1}
value={memVal} value={memVal}
onChange={e => setPending(p => ({ ...p, mem_offset_mhz: parseInt(e.target.value) }))} onChange={(e) =>
setPending((p) => ({
...p,
mem_offset_mhz: parseInt(e.target.value),
}))
}
className="flex-1 accent-cyan-400 h-1 cursor-pointer" className="flex-1 accent-cyan-400 h-1 cursor-pointer"
/> />
<span className="text-xs text-zinc-600 font-mono w-10">+{memMax}</span> <span className="text-xs text-zinc-600 font-mono w-10">
+{memMax}
</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
+23 -11
View File
@@ -1,6 +1,6 @@
import { GaugeCard } from './GaugeCard'; import { GaugeCard } from "./GaugeCard.js";
import { fmt } from '../../utils/units'; import { fmt } from "../../utils/units.js";
import type { MonitoringSample, FanPoint } from '../../types'; import type { MonitoringSample, FanPoint } from "../../types.js";
interface Props { interface Props {
monitor: MonitoringSample | null; monitor: MonitoringSample | null;
@@ -16,7 +16,10 @@ function pluck<K extends keyof MonitoringSample>(
return history.map((s) => (s[key] as number | null) ?? 0); return history.map((s) => (s[key] as number | null) ?? 0);
} }
function computeTargetFan(curve: FanPoint[] | null, tempC: number | null): number | null { function computeTargetFan(
curve: FanPoint[] | null,
tempC: number | null,
): number | null {
if (!curve || !tempC || curve.length < 2) return null; if (!curve || !tempC || curve.length < 2) return null;
for (let i = 0; i < curve.length - 1; i++) { for (let i = 0; i < curve.length - 1; i++) {
@@ -36,19 +39,28 @@ function computeTargetFan(curve: FanPoint[] | null, tempC: number | null): numbe
return curve[curve.length - 1].fan_pct; return curve[curve.length - 1].fan_pct;
} }
export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props) { export function FanMonitor({
const fanHistory = pluck(history, 'fan_pct'); monitor,
const tempHistory = pluck(history, 'temp_c'); history,
fanCurve,
fanCurveActive,
}: Props) {
const fanHistory = pluck(history, "fan_pct");
const tempHistory = pluck(history, "temp_c");
const currentTemp = monitor?.temp_c ?? null; const currentTemp = monitor?.temp_c ?? null;
const targetFan = computeTargetFan(fanCurve, currentTemp); const targetFan = computeTargetFan(fanCurve, currentTemp);
const targetFanHistory = history.map((s) => computeTargetFan(fanCurve, s.temp_c) ?? 0); const targetFanHistory = history.map(
(s) => computeTargetFan(fanCurve, s.temp_c) ?? 0,
);
return ( return (
<div className="flex flex-col gap-2 w-full h-full"> <div className="flex flex-col gap-2 w-full h-full">
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full"> <div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full">
<div className="flex items-center justify-between pb-2 border-b border-zinc-800"> <div className="flex items-center justify-between pb-2 border-b border-zinc-800">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Live Monitor</span> <span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
Live Monitor
</span>
{fanCurveActive && ( {fanCurveActive && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-orange-500/15 border border-orange-500/30 text-orange-400 text-[10px] font-semibold"> <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-orange-500/15 border border-orange-500/30 text-orange-400 text-[10px] font-semibold">
Curve Active Curve Active
@@ -73,7 +85,7 @@ export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props
{fanCurveActive && ( {fanCurveActive && (
<GaugeCard <GaugeCard
label="Target Fan" label="Target Fan"
value={targetFan !== null ? `${targetFan}%` : '—'} value={targetFan !== null ? `${targetFan}%` : "—"}
history={targetFanHistory} history={targetFanHistory}
color="#fbbf24" color="#fbbf24"
max={100} max={100}
@@ -82,7 +94,7 @@ export function FanMonitor({ monitor, history, fanCurve, fanCurveActive }: Props
<div className="mt-auto"> <div className="mt-auto">
<GaugeCard <GaugeCard
label="Fan Mode" label="Fan Mode"
value={fanCurveActive ? 'Curve' : 'Auto'} value={fanCurveActive ? "Curve" : "Auto"}
/> />
</div> </div>
</div> </div>
+41 -39
View File
@@ -1,6 +1,6 @@
import { GaugeCard } from './GaugeCard'; import { GaugeCard } from "./GaugeCard.js";
import { fmt } from '../../utils/units'; import { fmt } from "../../utils/units.js";
import type { MonitoringSample } from '../../types'; import type { MonitoringSample } from "../../types.js";
interface Props { interface Props {
monitor: MonitoringSample | null; monitor: MonitoringSample | null;
@@ -19,48 +19,50 @@ export function LiveMonitor({ monitor, history }: Props) {
<div className="flex flex-col gap-2 w-full h-full"> <div className="flex flex-col gap-2 w-full h-full">
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full"> <div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full">
<div className="flex items-center justify-between pb-2 border-b border-zinc-800"> <div className="flex items-center justify-between pb-2 border-b border-zinc-800">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Live Monitor</span> <span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
Live Monitor
</span>
</div> </div>
<div className="flex flex-col gap-2 mt-1"> <div className="flex flex-col gap-2 mt-1">
<GaugeCard
label="Core Clock"
value={fmt.mhz(monitor?.clock_mhz)}
history={pluck(history, 'clock_mhz')}
color="#34d399"
max={3000}
/>
<GaugeCard
label="Voltage"
value={fmt.mv(monitor?.voltage_mv)}
history={pluck(history, 'voltage_mv')}
color="#a78bfa"
max={1100}
/>
<GaugeCard
label="Power Draw"
value={fmt.watts(monitor?.power_w)}
history={pluck(history, 'power_w')}
color="#f472b6"
max={600}
/>
<GaugeCard
label="GPU Util"
value={fmt.pct(monitor?.gpu_util_pct)}
history={pluck(history, 'gpu_util_pct')}
color="#facc15"
max={100}
/>
<div className="mt-auto">
<GaugeCard <GaugeCard
label="P-State" label="Core Clock"
value={monitor?.pstate_label ?? 'Unknown'} value={fmt.mhz(monitor?.clock_mhz)}
history={pluck(history, 'pstate')} history={pluck(history, "clock_mhz")}
color="#a8a29e" color="#34d399"
max={15} max={3000}
/> />
<GaugeCard
label="Voltage"
value={fmt.mv(monitor?.voltage_mv)}
history={pluck(history, "voltage_mv")}
color="#a78bfa"
max={1100}
/>
<GaugeCard
label="Power Draw"
value={fmt.watts(monitor?.power_w)}
history={pluck(history, "power_w")}
color="#f472b6"
max={600}
/>
<GaugeCard
label="GPU Util"
value={fmt.pct(monitor?.gpu_util_pct)}
history={pluck(history, "gpu_util_pct")}
color="#facc15"
max={100}
/>
<div className="mt-auto">
<GaugeCard
label="P-State"
value={monitor?.pstate_label ?? "Unknown"}
history={pluck(history, "pstate")}
color="#a8a29e"
max={15}
/>
</div>
</div> </div>
</div> </div>
</div>
</div> </div>
); );
} }
@@ -1,6 +1,6 @@
import { GaugeCard } from './GaugeCard'; import { GaugeCard } from "./GaugeCard.js";
import { fmt } from '../../utils/units'; import { fmt } from "../../utils/units.js";
import type { MonitoringSample } from '../../types'; import type { MonitoringSample } from "../../types.js";
interface Props { interface Props {
monitor: MonitoringSample | null; monitor: MonitoringSample | null;
@@ -17,35 +17,38 @@ function pluck<K extends keyof MonitoringSample>(
export function PerformanceMonitor({ monitor, history }: Props) { export function PerformanceMonitor({ monitor, history }: Props) {
const memUsed = monitor?.mem_used_mib ?? null; const memUsed = monitor?.mem_used_mib ?? null;
const memTotal = monitor?.mem_total_mib ?? null; const memTotal = monitor?.mem_total_mib ?? null;
const memLabel = memUsed != null && memTotal != null const memLabel =
? `${memUsed.toFixed(0)} / ${memTotal.toFixed(0)} MiB` memUsed != null && memTotal != null
: '—'; ? `${memUsed.toFixed(0)} / ${memTotal.toFixed(0)} MiB`
: "—";
return ( return (
<div className="flex flex-col gap-2 w-full h-full"> <div className="flex flex-col gap-2 w-full h-full">
<div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full"> <div className="bg-zinc-900 rounded-lg p-3 flex flex-col gap-2 h-full">
<div className="flex items-center justify-between pb-2 border-b border-zinc-800"> <div className="flex items-center justify-between pb-2 border-b border-zinc-800">
<span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">Live Monitor</span> <span className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
Live Monitor
</span>
</div> </div>
<div className="flex flex-col gap-2 mt-1"> <div className="flex flex-col gap-2 mt-1">
<GaugeCard <GaugeCard
label="Mem Clock" label="Mem Clock"
value={fmt.mhz(monitor?.mem_clock_mhz)} value={fmt.mhz(monitor?.mem_clock_mhz)}
history={pluck(history, 'mem_clock_mhz')} history={pluck(history, "mem_clock_mhz")}
color="#67e8f9" color="#67e8f9"
max={20000} max={20000}
/> />
<GaugeCard <GaugeCard
label="Power Draw" label="Power Draw"
value={fmt.watts(monitor?.power_w)} value={fmt.watts(monitor?.power_w)}
history={pluck(history, 'power_w')} history={pluck(history, "power_w")}
color="#f472b6" color="#f472b6"
max={600} max={600}
/> />
<GaugeCard <GaugeCard
label="VRAM Used" label="VRAM Used"
value={memLabel} value={memLabel}
history={pluck(history, 'mem_used_mib')} history={pluck(history, "mem_used_mib")}
color="#a78bfa" color="#a78bfa"
max={memTotal ?? 32768} max={memTotal ?? 32768}
/> />
@@ -8,9 +8,9 @@ import {
LogOut, LogOut,
User, User,
} from "lucide-react"; } from "lucide-react";
import type { GpuInfo, MonitoringSample } from "../../types"; import type { GpuInfo, MonitoringSample } from "../../types.js";
import { fmt } from "../../utils/units"; import { fmt } from "../../utils/units.js";
import { useCurveStore } from "../../store/curveStore"; import { useCurveStore } from "../../store/curveStore.js";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
interface Props { interface Props {
+58 -22
View File
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from "react";
import { fmt } from '../../utils/units'; import { fmt } from "../../utils/units.js";
import type { VFPoint } from '../../types'; import type { VFPoint } from "../../types.js";
import { useCurveStore } from '../../store/curveStore'; import { useCurveStore } from "../../store/curveStore.js";
interface Props { interface Props {
point: VFPoint; point: VFPoint;
@@ -14,17 +14,28 @@ interface Props {
onMouseEnter?: () => void; onMouseEnter?: () => void;
} }
export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDeltaKhz, shouldAutoScroll, onMouseDown, onMouseEnter }: Props) { export function PointRow({
point,
isCurrent,
isSelected,
isClamped,
pendingDeltaKhz,
shouldAutoScroll,
onMouseDown,
onMouseEnter,
}: Props) {
const { stageEdit } = useCurveStore(); const { stageEdit } = useCurveStore();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const trRef = useRef<HTMLTableRowElement>(null); const trRef = useRef<HTMLTableRowElement>(null);
useEffect(() => { useEffect(() => {
if (shouldAutoScroll && trRef.current) { if (shouldAutoScroll && trRef.current) {
// @ts-expect-error: the typing seems to not include the valid 'container' option trRef.current.scrollIntoView({
trRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest', container: 'nearest' }); behavior: "smooth",
block: "nearest",
});
} }
}, [shouldAutoScroll]); }, [shouldAutoScroll]);
@@ -36,11 +47,19 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
const displayEffMhz = point.freq_mhz + deltaChange / 1000; const displayEffMhz = point.freq_mhz + deltaChange / 1000;
const deltaColor = hasPending const deltaColor = hasPending
? displayDeltaKhz > 0 ? 'text-cyan-400' : displayDeltaKhz < 0 ? 'text-orange-400' : 'text-zinc-400' ? displayDeltaKhz > 0
: point.delta_khz > 0 ? 'text-emerald-400' : point.delta_khz < 0 ? 'text-red-400' : 'text-zinc-500'; ? "text-cyan-400"
: displayDeltaKhz < 0
? "text-orange-400"
: "text-zinc-400"
: point.delta_khz > 0
? "text-emerald-400"
: point.delta_khz < 0
? "text-red-400"
: "text-zinc-500";
function startEdit() { function startEdit() {
setInputValue((displayDeltaMhz).toFixed(1)); setInputValue(displayDeltaMhz.toFixed(1));
setEditing(true); setEditing(true);
setTimeout(() => { setTimeout(() => {
inputRef.current?.select(); inputRef.current?.select();
@@ -64,9 +83,13 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
<tr <tr
ref={trRef} ref={trRef}
className={[ className={[
'border-b border-zinc-800 text-xs font-mono cursor-pointer', "border-b border-zinc-800 text-xs font-mono cursor-pointer",
isCurrent ? 'bg-yellow-400/10' : isSelected ? 'bg-cyan-500/10' : 'hover:bg-zinc-800/50', isCurrent
].join(' ')} ? "bg-yellow-400/10"
: isSelected
? "bg-cyan-500/10"
: "hover:bg-zinc-800/50",
].join(" ")}
onMouseDown={(e) => { onMouseDown={(e) => {
if (editing) return; if (editing) return;
onMouseDown?.(e); onMouseDown?.(e);
@@ -80,7 +103,13 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
<td className="px-3 py-1 text-zinc-300">{fmt.mv(point.volt_mv, 0)}</td> <td className="px-3 py-1 text-zinc-300">{fmt.mv(point.volt_mv, 0)}</td>
{/* Offset — click to edit inline */} {/* Offset — click to edit inline */}
<td className={`px-3 py-1 ${deltaColor}`} onClick={(e) => { e.stopPropagation(); startEdit(); }}> <td
className={`px-3 py-1 ${deltaColor}`}
onClick={(e) => {
e.stopPropagation();
startEdit();
}}
>
{editing ? ( {editing ? (
<input <input
ref={inputRef} ref={inputRef}
@@ -90,28 +119,35 @@ export function PointRow({ point, isCurrent, isSelected, isClamped, pendingDelta
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
onBlur={commitEdit} onBlur={commitEdit}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); commitEdit(); } if (e.key === "Enter" || e.key === "Tab") {
if (e.key === 'Escape') cancelEdit(); e.preventDefault();
commitEdit();
}
if (e.key === "Escape") cancelEdit();
}} }}
className="w-20 bg-zinc-700 text-cyan-300 rounded px-1 py-0 border border-cyan-500 outline-none text-xs" className="w-20 bg-zinc-700 text-cyan-300 rounded px-1 py-0 border border-cyan-500 outline-none text-xs font-mono"
style={{ fontFamily: 'monospace' }}
/> />
) : ( ) : (
<span title="Click to edit"> <span title="Click to edit">
{hasPending && <span className="text-cyan-500 mr-0.5">✎</span>} {hasPending && <span className="text-cyan-500 mr-0.5">✎</span>}
{displayDeltaKhz > 0 ? '+' : ''}{displayDeltaMhz.toFixed(1)} MHz {displayDeltaKhz > 0 ? "+" : ""}
{displayDeltaMhz.toFixed(1)} MHz
</span> </span>
)} )}
</td> </td>
{/* Eff. Freq */} {/* Eff. Freq */}
<td className={`px-3 py-1 font-semibold ${hasPending ? 'text-cyan-200' : 'text-zinc-100'}`}> <td
className={`px-3 py-1 font-semibold ${hasPending ? "text-cyan-200" : "text-zinc-100"}`}
>
{fmt.mhz(displayEffMhz, 0)} {fmt.mhz(displayEffMhz, 0)}
{isClamped && !hasPending && ( {isClamped && !hasPending && (
<span <span
className="ml-1 text-amber-500 cursor-help" className="ml-1 text-amber-500 cursor-help"
title="Clamped by monotonicity — a lower-voltage point with a higher offset is holding this frequency up" title="Clamped by monotonicity — a lower-voltage point with a higher offset is holding this frequency up"
>⇡</span> >
⇡
</span>
)} )}
</td> </td>
@@ -1,8 +1,11 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from "react";
import { PointRow } from './PointRow'; import { PointRow } from "./PointRow.js";
import type { VFPoint } from '../../types'; import type { VFPoint } from "../../types.js";
import { findCurrentPoint, detectClampedPoints } from '../../utils/curveHelpers'; import {
import { useCurveStore } from '../../store/curveStore'; findCurrentPoint,
detectClampedPoints,
} from "../../utils/curveHelpers.js";
import { useCurveStore } from "../../store/curveStore.js";
interface Props { interface Props {
points: VFPoint[]; points: VFPoint[];
@@ -11,22 +14,32 @@ interface Props {
} }
export function PointTable({ points, currentVoltageMv, readOnly }: Props) { export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
const { pendingDeltas, selectedPoints, selectPoint, selectRange } = useCurveStore(); const {
pendingDeltas,
selectedPoints,
selectPoint,
togglePoint,
selectRange,
} = useCurveStore();
const currentPoint = findCurrentPoint(points, currentVoltageMv); const currentPoint = findCurrentPoint(points, currentVoltageMv);
const clampedPoints = useMemo(() => detectClampedPoints(points), [points]); const clampedPoints = useMemo(() => detectClampedPoints(points), [points]);
const [dragStartIdx, setDragStartIdx] = useState<number | null>(null); const [dragStartIdx, setDragStartIdx] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
function onUp() { setDragStartIdx(null); } function onUp() {
window.addEventListener('mouseup', onUp); setDragStartIdx(null);
return () => window.removeEventListener('mouseup', onUp); }
window.addEventListener("mouseup", onUp);
return () => window.removeEventListener("mouseup", onUp);
}, []); }, []);
return ( return (
<div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col"> <div className="bg-zinc-900 rounded-lg overflow-hidden flex flex-col">
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 shrink-0"> <div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 shrink-0">
<span className="text-xs text-zinc-500 uppercase tracking-wider mr-2">Points</span> <span className="text-xs text-zinc-500 uppercase tracking-wider mr-2">
Points
</span>
{!readOnly && pendingDeltas.size > 0 && ( {!readOnly && pendingDeltas.size > 0 && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs"> <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-cyan-500/15 border border-cyan-500/30 text-cyan-400 text-xs">
{pendingDeltas.size} staged {pendingDeltas.size} staged
@@ -35,13 +48,17 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
{readOnly && ( {readOnly && (
<span className="text-xs text-zinc-600 italic">read-only</span> <span className="text-xs text-zinc-600 italic">read-only</span>
)} )}
<span className="ml-auto text-xs text-zinc-600">{points.length} points</span> <span className="ml-auto text-xs text-zinc-600">
{points.length} points
</span>
{!readOnly && selectedPoints.size === 1 && ( {!readOnly && selectedPoints.size === 1 && (
<div className="flex gap-1 ml-4 border-l border-zinc-800 pl-4"> <div className="flex gap-1 ml-4 border-l border-zinc-800 pl-4">
<button <button
onClick={() => { onClick={() => {
const idx = Array.from(selectedPoints)[0]; const idx = Array.from(selectedPoints)[0];
const beforeIdxs = points.filter(p => p.index <= idx).map(p => p.index); const beforeIdxs = points
.filter((p) => p.index <= idx)
.map((p) => p.index);
selectRange(beforeIdxs); selectRange(beforeIdxs);
}} }}
className="px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors whitespace-nowrap" className="px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors whitespace-nowrap"
@@ -51,7 +68,9 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
<button <button
onClick={() => { onClick={() => {
const idx = Array.from(selectedPoints)[0]; const idx = Array.from(selectedPoints)[0];
const afterIdxs = points.filter(p => p.index >= idx).map(p => p.index); const afterIdxs = points
.filter((p) => p.index >= idx)
.map((p) => p.index);
selectRange(afterIdxs); selectRange(afterIdxs);
}} }}
className="px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors whitespace-nowrap" className="px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-400 hover:bg-zinc-700 transition-colors whitespace-nowrap"
@@ -66,9 +85,15 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
<thead className="sticky top-0 bg-zinc-900 z-10"> <thead className="sticky top-0 bg-zinc-900 z-10">
<tr className="text-xs text-zinc-500 uppercase tracking-wider border-b border-zinc-800"> <tr className="text-xs text-zinc-500 uppercase tracking-wider border-b border-zinc-800">
<th className="px-3 py-2 text-left font-normal bg-zinc-900">#</th> <th className="px-3 py-2 text-left font-normal bg-zinc-900">#</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900">Voltage</th> <th className="px-3 py-2 text-left font-normal bg-zinc-900">
<th className="px-3 py-2 text-left font-normal bg-zinc-900">Offset</th> Voltage
<th className="px-3 py-2 text-left font-normal bg-zinc-900">Eff. Freq</th> </th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900">
Offset
</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900">
Eff. Freq
</th>
<th className="px-3 py-2 text-left font-normal bg-zinc-900" /> <th className="px-3 py-2 text-left font-normal bg-zinc-900" />
</tr> </tr>
</thead> </thead>
@@ -81,34 +106,48 @@ export function PointTable({ points, currentVoltageMv, readOnly }: Props) {
isSelected={selectedPoints.has(p.index)} isSelected={selectedPoints.has(p.index)}
isClamped={clampedPoints.has(p.index)} isClamped={clampedPoints.has(p.index)}
pendingDeltaKhz={pendingDeltas.get(p.index)} pendingDeltaKhz={pendingDeltas.get(p.index)}
shouldAutoScroll={selectedPoints.size === 1 && selectedPoints.has(p.index)} shouldAutoScroll={
onMouseDown={readOnly ? undefined : (e) => { selectedPoints.size === 1 && selectedPoints.has(p.index)
if (e.shiftKey) { }
const currentSelected = Array.from(selectedPoints); onMouseDown={
if (currentSelected.length > 0) { readOnly
const last = Math.max(...currentSelected); ? undefined
const min = Math.min(last, p.index); : (e) => {
const max = Math.max(last, p.index); if (e.shiftKey) {
const toSelect = points.filter(a => a.index >= min && a.index <= max).map(a => a.index); const currentSelected = Array.from(selectedPoints);
selectRange(toSelect); if (currentSelected.length > 0) {
} else { const last = Math.max(...currentSelected);
selectPoint(p.index, false); const min = Math.min(last, p.index);
} const max = Math.max(last, p.index);
} else if (e.ctrlKey || e.metaKey) { const toSelect = points
selectPoint(p.index, true); .filter((a) => a.index >= min && a.index <= max)
} else { .map((a) => a.index);
setDragStartIdx(p.index); selectRange(toSelect);
selectPoint(p.index, false); } else {
} selectPoint(p.index);
}} }
onMouseEnter={readOnly ? undefined : () => { } else if (e.ctrlKey || e.metaKey) {
if (dragStartIdx !== null) { togglePoint(p.index);
const min = Math.min(dragStartIdx, p.index); } else {
const max = Math.max(dragStartIdx, p.index); setDragStartIdx(p.index);
const toSelect = points.filter(a => a.index >= min && a.index <= max).map(a => a.index); selectPoint(p.index);
selectRange(toSelect); }
} }
}} }
onMouseEnter={
readOnly
? undefined
: () => {
if (dragStartIdx !== null) {
const min = Math.min(dragStartIdx, p.index);
const max = Math.max(dragStartIdx, p.index);
const toSelect = points
.filter((a) => a.index >= min && a.index <= max)
.map((a) => a.index);
selectRange(toSelect);
}
}
}
/> />
))} ))}
</tbody> </tbody>
+125 -54
View File
@@ -1,16 +1,31 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from "react";
import { Save, Trash2, Check, ChevronRight, Pencil, Star } from 'lucide-react'; import {
import { api } from '../../api/client'; Save,
import type { ProfileData } from '../../types'; Trash2,
import { toast } from 'sonner'; Check,
import { useCurveStore } from '../../store/curveStore'; ChevronRight,
Pencil,
Star,
Fan,
} from "lucide-react";
import { api } from "../../api/client.js";
import type { ProfileData } from "../../types.js";
import { toast } from "sonner";
import { useCurveStore } from "../../store/curveStore.js";
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
interface ProfilePanelProps { interface ProfilePanelProps {
activeProfile: string | null; activeProfile: string | null;
onProfileApplied: (name: string | null) => void; onProfileApplied: (name: string | null) => void;
} }
export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelProps) { export function ProfilePanel({
activeProfile,
onProfileApplied,
}: ProfilePanelProps) {
const { selectedGpuIndex, gpuInfo } = useCurveStore(); const { selectedGpuIndex, gpuInfo } = useCurveStore();
const [profiles, setProfiles] = useState<ProfileData[]>([]); const [profiles, setProfiles] = useState<ProfileData[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -18,7 +33,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
// Save form // Save form
const [isSaveOpen, setIsSaveOpen] = useState(false); const [isSaveOpen, setIsSaveOpen] = useState(false);
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState("");
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const saveInputRef = useRef<HTMLInputElement>(null); const saveInputRef = useRef<HTMLInputElement>(null);
@@ -28,7 +43,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
// Inline rename // Inline rename
const [renamingName, setRenamingName] = useState<string | null>(null); const [renamingName, setRenamingName] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState(''); const [renameValue, setRenameValue] = useState("");
const [isRenaming, setIsRenaming] = useState(false); const [isRenaming, setIsRenaming] = useState(false);
const renameInputRef = useRef<HTMLInputElement>(null); const renameInputRef = useRef<HTMLInputElement>(null);
@@ -42,7 +57,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
onProfileApplied(data.active); onProfileApplied(data.active);
setAutoLoadProfile(data.auto_load); setAutoLoadProfile(data.auto_load);
} catch { } catch {
toast.error('Failed to load profiles'); toast.error("Failed to load profiles");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -53,34 +68,43 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
await api.setAutoLoadProfile(name, selectedGpuIndex); await api.setAutoLoadProfile(name, selectedGpuIndex);
setAutoLoadProfile(name); setAutoLoadProfile(name);
if (name) toast.success(`"${name}" will load on server start`); if (name) toast.success(`"${name}" will load on server start`);
else toast.success('Auto-load cleared'); else toast.success("Auto-load cleared");
} catch (e: any) { } catch (e: unknown) {
toast.error('Failed to update default profile: ' + (e.message || String(e))); toast.error("Failed to update default profile: " + errMsg(e));
} }
} }
useEffect(() => { fetchProfiles(); }, [selectedGpuIndex]); // Data fetch on GPU change — setState calls happen after the await, not
// synchronously in the effect body (rule false-positive on async fetch).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchProfiles();
}, [selectedGpuIndex]);
useEffect(() => { useEffect(() => {
if (isSaveOpen) saveInputRef.current?.focus(); if (isSaveOpen) saveInputRef.current?.focus();
else setNewName('');
}, [isSaveOpen]); }, [isSaveOpen]);
useEffect(() => { useEffect(() => {
if (renamingName) renameInputRef.current?.focus(); if (renamingName) renameInputRef.current?.focus();
}, [renamingName]); }, [renamingName]);
async function handleSave(e: React.FormEvent) { function closeSaveForm() {
setIsSaveOpen(false);
setNewName("");
}
async function handleSave(e: React.SubmitEvent) {
e.preventDefault(); e.preventDefault();
if (!newName.trim()) return; if (!newName.trim()) return;
try { try {
setIsSaving(true); setIsSaving(true);
await api.saveProfile(newName.trim(), selectedGpuIndex); await api.saveProfile(newName.trim(), selectedGpuIndex);
toast.success(`Profile "${newName.trim()}" saved`); toast.success(`Profile "${newName.trim()}" saved`);
setIsSaveOpen(false); closeSaveForm();
await fetchProfiles(); await fetchProfiles();
} catch (e: any) { } catch (e: unknown) {
toast.error('Failed to save: ' + (e.message || String(e))); toast.error("Failed to save: " + errMsg(e));
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@@ -92,8 +116,8 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
await api.applyProfile(name, selectedGpuIndex); await api.applyProfile(name, selectedGpuIndex);
onProfileApplied(name); onProfileApplied(name);
toast.success(`"${name}" applied`); toast.success(`"${name}" applied`);
} catch (e: any) { } catch (e: unknown) {
toast.error(`Failed to apply "${name}": ` + (e.message || String(e))); toast.error(`Failed to apply "${name}": ` + errMsg(e));
} finally { } finally {
setApplyingName(null); setApplyingName(null);
} }
@@ -108,14 +132,14 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
if (autoLoadProfile === name) setAutoLoadProfile(null); if (autoLoadProfile === name) setAutoLoadProfile(null);
setDeletingName(null); setDeletingName(null);
await fetchProfiles(); await fetchProfiles();
} catch (e: any) { } catch (e: unknown) {
toast.error('Failed to delete: ' + (e.message || String(e))); toast.error("Failed to delete: " + errMsg(e));
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
} }
} }
async function handleRename(e: React.FormEvent, oldName: string) { async function handleRename(e: React.SubmitEvent, oldName: string) {
e.preventDefault(); e.preventDefault();
if (!renameValue.trim() || renameValue.trim() === oldName) { if (!renameValue.trim() || renameValue.trim() === oldName) {
setRenamingName(null); setRenamingName(null);
@@ -129,8 +153,8 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
if (autoLoadProfile === oldName) setAutoLoadProfile(renameValue.trim()); if (autoLoadProfile === oldName) setAutoLoadProfile(renameValue.trim());
setRenamingName(null); setRenamingName(null);
await fetchProfiles(); await fetchProfiles();
} catch (e: any) { } catch (e: unknown) {
toast.error('Failed to rename: ' + (e.message || String(e))); toast.error("Failed to rename: " + errMsg(e));
} finally { } finally {
setIsRenaming(false); setIsRenaming(false);
} }
@@ -162,9 +186,9 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
type="text" type="text"
placeholder="Profile name..." placeholder="Profile name..."
value={newName} value={newName}
onChange={e => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
disabled={isSaving} disabled={isSaving}
onKeyDown={e => e.key === 'Escape' && setIsSaveOpen(false)} onKeyDown={(e) => e.key === "Escape" && closeSaveForm()}
className="flex-1 min-w-0 bg-zinc-950 border border-zinc-700 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 disabled:opacity-50" className="flex-1 min-w-0 bg-zinc-950 border border-zinc-700 rounded px-3 py-1.5 text-sm focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 disabled:opacity-50"
/> />
<button <button
@@ -176,7 +200,7 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
</button> </button>
<button <button
type="button" type="button"
onClick={() => setIsSaveOpen(false)} onClick={closeSaveForm}
className="px-3 py-1.5 text-zinc-400 hover:text-zinc-200 rounded text-sm transition shrink-0" className="px-3 py-1.5 text-zinc-400 hover:text-zinc-200 rounded text-sm transition shrink-0"
> >
Cancel Cancel
@@ -201,13 +225,17 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
</p> </p>
) : ( ) : (
<div className="flex flex-col gap-0.5 px-2"> <div className="flex flex-col gap-0.5 px-2">
{profiles.map(p => { {profiles.map((p) => {
const pts = Object.keys(p.curve_deltas).length; const pts = Object.keys(p.curve_deltas).length;
const badges = [ const badges = [
pts > 0 ? `${pts} pts` : null, pts > 0 ? `${pts} pts` : null,
p.power_limit_w != null ? `${p.power_limit_w}W` : null, p.power_limit_w != null ? `${p.power_limit_w}W` : null,
p.mem_offset_mhz != null ? `${p.mem_offset_mhz > 0 ? '+' : ''}${p.mem_offset_mhz} MHz mem` : null, p.mem_offset_mhz != null
].filter(Boolean).join(' · '); ? `${p.mem_offset_mhz > 0 ? "+" : ""}${p.mem_offset_mhz} MHz mem`
: null,
]
.filter(Boolean)
.join(" · ");
const isActive = activeProfile === p.name; const isActive = activeProfile === p.name;
const isAutoLoad = autoLoadProfile === p.name; const isAutoLoad = autoLoadProfile === p.name;
@@ -218,11 +246,13 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
return ( return (
<div <div
key={p.name} key={p.name}
className={`group rounded-md transition ${isActive ? 'bg-zinc-800/60' : 'hover:bg-zinc-800/50'}`} className={`group rounded-md transition ${isActive ? "bg-zinc-800/60" : "hover:bg-zinc-800/50"}`}
> >
{isConfirmingDelete ? ( {isConfirmingDelete ? (
<div className="flex items-center justify-between px-3 py-2 gap-2"> <div className="flex items-center justify-between px-3 py-2 gap-2">
<span className="text-sm text-zinc-300 truncate min-w-0">Delete "{p.name}"?</span> <span className="text-sm text-zinc-300 truncate min-w-0">
Delete "{p.name}"?
</span>
<div className="flex gap-1.5 shrink-0"> <div className="flex gap-1.5 shrink-0">
<button <button
onClick={() => handleDeleteConfirm(p.name)} onClick={() => handleDeleteConfirm(p.name)}
@@ -240,14 +270,19 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
</div> </div>
</div> </div>
) : isRenaming_ ? ( ) : isRenaming_ ? (
<form onSubmit={e => handleRename(e, p.name)} className="flex items-center gap-2 px-3 py-2"> <form
onSubmit={(e) => handleRename(e, p.name)}
className="flex items-center gap-2 px-3 py-2"
>
<input <input
ref={renameInputRef} ref={renameInputRef}
type="text" type="text"
value={renameValue} value={renameValue}
onChange={e => setRenameValue(e.target.value)} onChange={(e) => setRenameValue(e.target.value)}
disabled={isRenaming} disabled={isRenaming}
onKeyDown={e => e.key === 'Escape' && setRenamingName(null)} onKeyDown={(e) =>
e.key === "Escape" && setRenamingName(null)
}
className="flex-1 min-w-0 bg-zinc-950 border border-zinc-600 rounded px-2 py-1 text-sm focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 disabled:opacity-50" className="flex-1 min-w-0 bg-zinc-950 border border-zinc-600 rounded px-2 py-1 text-sm focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 disabled:opacity-50"
/> />
<button <button
@@ -268,25 +303,48 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
) : ( ) : (
<div className="flex items-center justify-between px-3 py-2"> <div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2 min-w-0 pr-2"> <div className="flex items-center gap-2 min-w-0 pr-2">
{isActive {isActive ? (
? <Check size={13} className="text-emerald-400 shrink-0" /> <Check
: <span className="w-[13px] shrink-0" /> size={13}
} className="text-emerald-400 shrink-0"
/>
) : (
<span className="w-[13px] shrink-0" />
)}
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<p className={`text-sm font-medium truncate ${isActive ? 'text-zinc-100' : 'text-zinc-300'}`}> <p
className={`text-sm font-medium truncate ${isActive ? "text-zinc-100" : "text-zinc-300"}`}
>
{p.name} {p.name}
</p> </p>
{isAutoLoad && ( {isAutoLoad && (
<Star size={11} className="text-sky-400 shrink-0" fill="currentColor" /> <Star
size={11}
className="text-sky-400 shrink-0"
fill="currentColor"
/>
)}
{p.fan_curve && p.fan_curve.length > 0 && (
<span
className="shrink-0 text-cyan-400"
title="Custom fan curve saved"
>
<Fan size={11} />
</span>
)} )}
{gpuInfo && p.gpu_name !== gpuInfo.name && ( {gpuInfo && p.gpu_name !== gpuInfo.name && (
<span className="text-xs text-zinc-600 truncate shrink-0" title={`Saved from ${p.gpu_name}`}> <span
className="text-xs text-zinc-600 truncate shrink-0"
title={`Saved from ${p.gpu_name}`}
>
{p.gpu_name} {p.gpu_name}
</span> </span>
)} )}
</div> </div>
{badges && <p className="text-xs text-zinc-500">{badges}</p>} {badges && (
<p className="text-xs text-zinc-500">{badges}</p>
)}
</div> </div>
</div> </div>
@@ -296,21 +354,34 @@ export function ProfilePanel({ activeProfile, onProfileApplied }: ProfilePanelPr
disabled={isApplying} disabled={isApplying}
className="flex items-center gap-1 px-2 py-1 text-xs rounded text-zinc-400 hover:text-emerald-400 hover:bg-zinc-700 transition disabled:opacity-50 font-medium" className="flex items-center gap-1 px-2 py-1 text-xs rounded text-zinc-400 hover:text-emerald-400 hover:bg-zinc-700 transition disabled:opacity-50 font-medium"
> >
{isApplying {isApplying ? (
? <span className="w-3 h-3 border border-zinc-500 border-t-emerald-400 rounded-full animate-spin" /> <span className="w-3 h-3 border border-zinc-500 border-t-emerald-400 rounded-full animate-spin" />
: <ChevronRight size={13} /> ) : (
} <ChevronRight size={13} />
)}
Apply Apply
</button> </button>
<button <button
onClick={() => handleSetAutoLoad(isAutoLoad ? null : p.name)} onClick={() =>
className={`p-1.5 rounded transition hover:bg-zinc-700 ${isAutoLoad ? 'text-sky-400 hover:text-sky-300' : 'text-zinc-600 hover:text-sky-400'}`} handleSetAutoLoad(isAutoLoad ? null : p.name)
title={isAutoLoad ? 'Clear default profile' : 'Set as default profile'} }
className={`p-1.5 rounded transition hover:bg-zinc-700 ${isAutoLoad ? "text-sky-400 hover:text-sky-300" : "text-zinc-600 hover:text-sky-400"}`}
title={
isAutoLoad
? "Clear default profile"
: "Set as default profile"
}
> >
<Star size={13} fill={isAutoLoad ? 'currentColor' : 'none'} /> <Star
size={13}
fill={isAutoLoad ? "currentColor" : "none"}
/>
</button> </button>
<button <button
onClick={() => { setRenamingName(p.name); setRenameValue(p.name); }} onClick={() => {
setRenamingName(p.name);
setRenameValue(p.name);
}}
className="p-1.5 text-zinc-600 hover:text-zinc-300 hover:bg-zinc-700 rounded transition" className="p-1.5 text-zinc-600 hover:text-zinc-300 hover:bg-zinc-700 rounded transition"
title="Rename" title="Rename"
> >
@@ -10,16 +10,17 @@ interface Props {
export function ConfirmDialog({ export function ConfirmDialog({
message, message,
detail, detail,
confirmLabel = 'Confirm', confirmLabel = "Confirm",
isDestructive = false, isDestructive = false,
onConfirm, onConfirm,
onCancel, onCancel,
}: Props) { }: Props) {
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center" className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
style={{ background: 'rgba(0,0,0,0.7)' }} onMouseDown={(e) => {
onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }} if (e.target === e.currentTarget) onCancel();
}}
> >
<div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 w-96 max-w-[90vw]"> <div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 w-96 max-w-[90vw]">
<h2 className="text-zinc-100 font-semibold text-sm mb-1">{message}</h2> <h2 className="text-zinc-100 font-semibold text-sm mb-1">{message}</h2>
@@ -35,11 +36,11 @@ export function ConfirmDialog({
<button <button
onClick={onConfirm} onClick={onConfirm}
className={[ className={[
'px-3 py-1.5 rounded text-xs font-semibold transition-colors', "px-3 py-1.5 rounded text-xs font-semibold transition-colors",
isDestructive isDestructive
? 'bg-red-600 hover:bg-red-500 text-white' ? "bg-red-600 hover:bg-red-500 text-white"
: 'bg-emerald-600 hover:bg-emerald-500 text-white', : "bg-emerald-600 hover:bg-emerald-500 text-white",
].join(' ')} ].join(" ")}
> >
{confirmLabel} {confirmLabel}
</button> </button>
+9 -7
View File
@@ -1,12 +1,14 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from "react";
import { api } from '../api/client'; import { api } from "../api/client.js";
import { createWsConnection } from '../api/websocket'; import { createWsConnection } from "../api/websocket.js";
import { useCurveStore } from '../store/curveStore'; import { useCurveStore } from "../store/curveStore.js";
import type { CurveState } from '../types'; import type { CurveState } from "../types.js";
export function useCurve() { export function useCurve() {
const { curve, setCurve, selectedGpuIndex } = useCurveStore(); const { curve, setCurve, selectedGpuIndex } = useCurveStore();
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting'); const [wsStatus, setWsStatus] = useState<
"connecting" | "connected" | "disconnected"
>("connecting");
const wsRef = useRef<ReturnType<typeof createWsConnection> | null>(null); const wsRef = useRef<ReturnType<typeof createWsConnection> | null>(null);
useEffect(() => { useEffect(() => {
@@ -15,7 +17,7 @@ export function useCurve() {
// Subscribe to /ws/curve for push updates after writes // Subscribe to /ws/curve for push updates after writes
wsRef.current = createWsConnection<CurveState>( wsRef.current = createWsConnection<CurveState>(
'/ws/curve', "/ws/curve",
(data) => setCurve(data), (data) => setCurve(data),
setWsStatus, setWsStatus,
selectedGpuIndex, selectedGpuIndex,
+9 -9
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api } from "../api/client"; import { api } from "../api/client.js";
import { useCurveStore } from "../store/curveStore"; import { useCurveStore } from "../store/curveStore.js";
import type { DashboardInfo } from "../types"; import type { DashboardInfo } from "../types.js";
interface DashboardState { interface DashboardState {
gpuIndex: number; gpuIndex: number;
@@ -23,17 +23,17 @@ export function useDashboard() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
api (async () => {
.dashboard(selectedGpuIndex) try {
.then((data) => { const data = await api.dashboard(selectedGpuIndex);
if (!cancelled) if (!cancelled)
setState({ gpuIndex: selectedGpuIndex, data, done: true }); setState({ gpuIndex: selectedGpuIndex, data, done: true });
}) } catch (err) {
.catch((err) => {
console.error("Failed to load dashboard info:", err); console.error("Failed to load dashboard info:", err);
if (!cancelled) if (!cancelled)
setState({ gpuIndex: selectedGpuIndex, data: null, done: true }); setState({ gpuIndex: selectedGpuIndex, data: null, done: true });
}); }
})();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
+5 -4
View File
@@ -1,9 +1,10 @@
import { useEffect } from 'react'; import { useEffect } from "react";
import { api } from '../api/client'; import { api } from "../api/client.js";
import { useCurveStore } from '../store/curveStore'; import { useCurveStore } from "../store/curveStore.js";
export function useGpu() { export function useGpu() {
const { gpuInfo, setGpuInfo, setAvailableGpus, selectedGpuIndex } = useCurveStore(); const { gpuInfo, setGpuInfo, setAvailableGpus, selectedGpuIndex } =
useCurveStore();
useEffect(() => { useEffect(() => {
api.gpus().then(setAvailableGpus).catch(console.error); api.gpus().then(setAvailableGpus).catch(console.error);
+10 -7
View File
@@ -1,16 +1,19 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from "react";
import { createWsConnection } from '../api/websocket'; import { createWsConnection } from "../api/websocket.js";
import { useCurveStore } from '../store/curveStore'; import { useCurveStore } from "../store/curveStore.js";
import type { MonitoringSample } from '../types'; import type { MonitoringSample } from "../types.js";
export function useMonitor() { export function useMonitor() {
const { monitor, monitorHistory, pushMonitor, selectedGpuIndex } = useCurveStore(); const { monitor, monitorHistory, pushMonitor, selectedGpuIndex } =
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting'); useCurveStore();
const [wsStatus, setWsStatus] = useState<
"connecting" | "connected" | "disconnected"
>("connecting");
const wsRef = useRef<ReturnType<typeof createWsConnection> | null>(null); const wsRef = useRef<ReturnType<typeof createWsConnection> | null>(null);
useEffect(() => { useEffect(() => {
wsRef.current = createWsConnection<MonitoringSample>( wsRef.current = createWsConnection<MonitoringSample>(
'/ws/monitor', "/ws/monitor",
pushMonitor, pushMonitor,
setWsStatus, setWsStatus,
selectedGpuIndex, selectedGpuIndex,
+95 -45
View File
@@ -1,7 +1,12 @@
import { create } from 'zustand'; import { create } from "zustand";
import type { CurveState, GpuInfo, MonitoringSample, VFPoint } from '../types'; import type {
import { api } from '../api/client'; CurveState,
import { toast } from 'sonner'; GpuInfo,
MonitoringSample,
VFPoint,
} from "../types.js";
import { api } from "../api/client.js";
import { toast } from "sonner";
const HISTORY_SIZE = 120; // ~60s at 2Hz const HISTORY_SIZE = 120; // ~60s at 2Hz
@@ -50,7 +55,9 @@ interface CurveStore {
resetAllDeltas: (onSuccess: () => void) => Promise<void>; resetAllDeltas: (onSuccess: () => void) => Promise<void>;
// Selection actions // Selection actions
selectPoint: (index: number, multi?: boolean) => void; selectPoint: (index: number) => void;
/** Toggle a point in the selection (Shift/Ctrl+click); updates the anchor. */
togglePoint: (index: number) => void;
selectRange: (indices: number[]) => void; selectRange: (indices: number[]) => void;
clearSelection: () => void; clearSelection: () => void;
/** /**
@@ -80,7 +87,16 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
setAvailableGpus: (availableGpus) => set({ availableGpus }), setAvailableGpus: (availableGpus) => set({ availableGpus }),
setSelectedGpuIndex: (selectedGpuIndex) => { setSelectedGpuIndex: (selectedGpuIndex) => {
set({ selectedGpuIndex, curve: null, gpuInfo: null, monitor: null, monitorHistory: [], pendingDeltas: new Map(), selectedPoints: new Set(), anchorPoint: null }); set({
selectedGpuIndex,
curve: null,
gpuInfo: null,
monitor: null,
monitorHistory: [],
pendingDeltas: new Map(),
selectedPoints: new Set(),
anchorPoint: null,
});
}, },
setCurve: (curve) => set({ curve }), setCurve: (curve) => set({ curve }),
setGpuInfo: (gpuInfo) => set({ gpuInfo }), setGpuInfo: (gpuInfo) => set({ gpuInfo }),
@@ -95,7 +111,7 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
stageEdit: (pointIndex, deltaKhz) => stageEdit: (pointIndex, deltaKhz) =>
set((s) => { set((s) => {
const next = new Map(s.pendingDeltas); const next = new Map(s.pendingDeltas);
const point = s.curve?.points.find(p => p.index === pointIndex); const point = s.curve?.points.find((p) => p.index === pointIndex);
if (point && point.delta_khz === deltaKhz) { if (point && point.delta_khz === deltaKhz) {
next.delete(pointIndex); next.delete(pointIndex);
} else { } else {
@@ -108,7 +124,7 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
set((s) => { set((s) => {
const next = new Map(s.pendingDeltas); const next = new Map(s.pendingDeltas);
edits.forEach((deltaKhz, index) => { edits.forEach((deltaKhz, index) => {
const point = s.curve?.points.find(p => p.index === index); const point = s.curve?.points.find((p) => p.index === index);
if (point && point.delta_khz === deltaKhz) { if (point && point.delta_khz === deltaKhz) {
next.delete(index); next.delete(index);
} else { } else {
@@ -128,27 +144,39 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
}), }),
discardEdits: () => discardEdits: () =>
set({ pendingDeltas: new Map(), selectedPoints: new Set(), anchorPoint: null }), set({
pendingDeltas: new Map(),
selectedPoints: new Set(),
anchorPoint: null,
}),
applyEdits: async (onSuccess) => { applyEdits: async (onSuccess) => {
const { pendingDeltas, selectedGpuIndex } = get(); const { pendingDeltas, selectedGpuIndex } = get();
if (pendingDeltas.size === 0) return; if (pendingDeltas.size === 0) return;
// Convert Map to plain record for the API // Convert Map to plain record for the API
const deltas: Record<number, number> = {}; const deltas: Record<number, number> = Object.fromEntries(pendingDeltas);
pendingDeltas.forEach((v, k) => { deltas[k] = v; });
try { try {
const result = await api.writeDeltas(deltas, selectedGpuIndex); const result = await api.writeDeltas(deltas, selectedGpuIndex);
set({ pendingDeltas: new Map(), selectedPoints: new Set(), activeProfile: null }); set({
pendingDeltas: new Map(),
selectedPoints: new Set(),
activeProfile: null,
});
if (result?.freq_warnings?.length) { if (result?.freq_warnings?.length) {
toast.warning('Curve applied — driver clamped some points to 0 MHz (negative freq delta)'); toast.warning(
"Curve applied — driver clamped some points to 0 MHz (negative freq delta)",
);
} else { } else {
toast.success('Curve applied successfully'); toast.success("Curve applied successfully");
} }
onSuccess(); onSuccess();
} catch (e: any) { } catch (e: unknown) {
toast.error('Failed to apply curve: ' + (e.message || String(e))); toast.error(
"Failed to apply curve: " +
(e instanceof Error ? e.message : String(e)),
);
} }
}, },
@@ -156,35 +184,48 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
const { selectedGpuIndex } = get(); const { selectedGpuIndex } = get();
try { try {
await api.resetCurve(selectedGpuIndex); await api.resetCurve(selectedGpuIndex);
set({ pendingDeltas: new Map(), selectedPoints: new Set(), activeProfile: null }); set({
toast.success('Curve reset to hardware defaults'); pendingDeltas: new Map(),
selectedPoints: new Set(),
activeProfile: null,
});
toast.success("Curve reset to hardware defaults");
onSuccess(); onSuccess();
} catch (e: any) { } catch (e: unknown) {
toast.error('Failed to reset curve: ' + (e.message || String(e))); toast.error(
"Failed to reset curve: " +
(e instanceof Error ? e.message : String(e)),
);
} }
}, },
selectPoint: (index, multi = false) => selectPoint: (index) =>
set((s) => {
const next = new Set(s.selectedPoints);
let anchor: number | null = index;
if (next.size === 1 && next.has(index)) {
next.clear();
anchor = null;
} else {
next.clear();
next.add(index);
}
return { selectedPoints: next, anchorPoint: anchor };
}),
togglePoint: (index) =>
set((s) => { set((s) => {
const next = new Set(s.selectedPoints); const next = new Set(s.selectedPoints);
let anchor = s.anchorPoint; let anchor = s.anchorPoint;
if (multi) { if (next.has(index)) {
if (next.has(index)) { next.delete(index);
next.delete(index); if (anchor === index) {
if (anchor === index) anchor = next.size > 0 ? [...next].at(-1)! : null; const last = [...next].at(-1);
} else { anchor = last ?? null;
next.add(index);
anchor = index; // last explicitly added point is the new anchor
} }
} else { } else {
if (next.size === 1 && next.has(index)) { next.add(index);
next.clear(); anchor = index; // last explicitly added point is the new anchor
anchor = null;
} else {
next.clear();
next.add(index);
anchor = index;
}
} }
return { selectedPoints: next, anchorPoint: anchor }; return { selectedPoints: next, anchorPoint: anchor };
}), }),
@@ -193,29 +234,38 @@ export const useCurveStore = create<CurveStore>()((set, get) => ({
// Bulk selects don't change the anchor — preserve it if still in the new selection. // Bulk selects don't change the anchor — preserve it if still in the new selection.
set((s) => { set((s) => {
const next = new Set(indices); const next = new Set(indices);
const anchor = s.anchorPoint !== null && next.has(s.anchorPoint) ? s.anchorPoint : null; const anchor =
s.anchorPoint !== null && next.has(s.anchorPoint)
? s.anchorPoint
: null;
return { selectedPoints: next, anchorPoint: anchor }; return { selectedPoints: next, anchorPoint: anchor };
}), }),
clearSelection: () => clearSelection: () => set({ selectedPoints: new Set(), anchorPoint: null }),
set({ selectedPoints: new Set(), anchorPoint: null }),
flattenToAnchor: () => { flattenToAnchor: () => {
const { selectedPoints, anchorPoint, pendingDeltas, curve, stageMultiEdit } = get(); const {
selectedPoints,
anchorPoint,
pendingDeltas,
curve,
stageMultiEdit,
} = get();
if (selectedPoints.size < 2 || !curve) return; if (selectedPoints.size < 2 || !curve) return;
const anchor = anchorPoint !== null && selectedPoints.has(anchorPoint) const anchor =
? anchorPoint anchorPoint !== null && selectedPoints.has(anchorPoint)
: Math.min(...selectedPoints); ? anchorPoint
: Math.min(...selectedPoints);
const anchorPt = curve.points.find(p => p.index === anchor); const anchorPt = curve.points.find((p) => p.index === anchor);
if (!anchorPt) return; if (!anchorPt) return;
const anchorPendingDelta = pendingDeltas.get(anchor) ?? anchorPt.delta_khz; const anchorPendingDelta = pendingDeltas.get(anchor) ?? anchorPt.delta_khz;
const anchorEffectiveKhz = anchorPt.freq_khz + anchorPendingDelta; const anchorEffectiveKhz = anchorPt.freq_khz + anchorPendingDelta;
const edits = new Map<number, number>(); const edits = new Map<number, number>();
for (const idx of selectedPoints) { for (const idx of selectedPoints) {
const pt = curve.points.find(p => p.index === idx); const pt = curve.points.find((p) => p.index === idx);
if (!pt) continue; if (!pt) continue;
edits.set(idx, anchorEffectiveKhz - pt.freq_khz); edits.set(idx, anchorEffectiveKhz - pt.freq_khz);
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import type { VFPoint } from '../types'; import type { VFPoint } from '../types.js';
/** /**
* Approximate reference frequency (MHz) for a point: effective − delta. * Approximate reference frequency (MHz) for a point: effective − delta.
+127 -58
View File
@@ -31,6 +31,7 @@ First-time / diagnostic commands (bypass server, escalate to root):
import argparse import argparse
import json import json
import logging
import os import os
import struct import struct
import sys import sys
@@ -48,6 +49,8 @@ from .nvapi.constants import (
VFP_STRIDE, VFP_STRIDE,
) )
log = logging.getLogger("nvcurve.cli")
# ── Utilities ───────────────────────────────────────────────────────────────── # ── Utilities ─────────────────────────────────────────────────────────────────
@@ -69,8 +72,8 @@ def parse_range(s: str):
raise argparse.ArgumentTypeError(f"Expected A-B format, got '{s}'") raise argparse.ArgumentTypeError(f"Expected A-B format, got '{s}'")
try: try:
a, b = int(parts[0]), int(parts[1]) a, b = int(parts[0]), int(parts[1])
except ValueError: except ValueError as exc:
raise argparse.ArgumentTypeError(f"Non-integer in range: '{s}'") raise argparse.ArgumentTypeError(f"Non-integer in range: '{s}'") from exc
if a > b: if a > b:
raise argparse.ArgumentTypeError(f"Start > end in range: {a}-{b}") raise argparse.ArgumentTypeError(f"Start > end in range: {a}-{b}")
if a < 0 or b >= CT_POINTS: if a < 0 or b >= CT_POINTS:
@@ -98,7 +101,7 @@ def print_curve(points, offsets, voltage, domains=None, full=False):
current_idx = None current_idx = None
if voltage: if voltage:
for i, (f, v) in enumerate(points): for i, (_f, v) in enumerate(points):
if v > 0 and abs(v - voltage) < 10000: if v > 0 and abs(v - voltage) < 10000:
current_idx = i current_idx = i
break break
@@ -214,7 +217,7 @@ def print_curve(points, offsets, voltage, domains=None, full=False):
if offsets: if offsets:
nonzero = sum(1 for o in offsets if o != 0) nonzero = sum(1 for o in offsets if o != 0)
if nonzero > 0: if nonzero > 0:
vals = set(o for o in offsets if o != 0) vals = {o for o in offsets if o != 0}
if len(vals) == 1: if len(vals) == 1:
print( print(
f"Global offset: {next(iter(vals)) / 1000:+.0f} MHz " f"Global offset: {next(iter(vals)) / 1000:+.0f} MHz "
@@ -316,7 +319,7 @@ def run_diagnostics(gpu, gpu_name, gpu_index: int = 0):
("SetClockBoostTable", FUNC["SetClockBoostTable"], CT_SIZE, 1, True), ("SetClockBoostTable", FUNC["SetClockBoostTable"], CT_SIZE, 1, True),
] ]
for name, fid, size, ver, needs_mask in probes: for name, fid, size, ver, _needs_mask in probes:
ptr = query_interface(fid) ptr = query_interface(fid)
resolved = "resolved" if ptr else "NOT FOUND" resolved = "resolved" if ptr else "NOT FOUND"
print(f" {name:30s} 0x{fid:08X} size=0x{size:04X} ver={ver} {resolved}") print(f" {name:30s} 0x{fid:08X} size=0x{size:04X} ver={ver} {resolved}")
@@ -420,8 +423,8 @@ def _open_browser_as_user(url: str) -> None:
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
return return
except Exception: except Exception as exc:
pass log.debug("runuser xdg-open failed, falling back to webbrowser: %s", exc)
import webbrowser import webbrowser
webbrowser.open(url) webbrowser.open(url)
@@ -445,7 +448,7 @@ def require_root():
] ]
try: try:
# PYTHONDONTWRITEBYTECODE prevents root-owned __pycache__ in site-packages. # PYTHONDONTWRITEBYTECODE prevents root-owned __pycache__ in site-packages.
os.execvp( os.execvp( # noqa: S606 — intentional re-exec via sudo
"sudo", "sudo",
[ [
"sudo", "sudo",
@@ -501,7 +504,7 @@ def _safe_host(host: str, cfg: Config) -> str:
0.0.0.0 (bind-all) is silently remapped to 127.0.0.1 — it's a valid local 0.0.0.0 (bind-all) is silently remapped to 127.0.0.1 — it's a valid local
server address, just not usable as a client connection target. server address, just not usable as a client connection target.
""" """
if host in ("0.0.0.0", "::"): if host in ("0.0.0.0", "::"): # noqa: S104 — comparison only, no binding here
return "127.0.0.1" return "127.0.0.1"
if host not in _ALLOWED_HOSTS: if host not in _ALLOWED_HOSTS:
print( print(
@@ -514,7 +517,7 @@ def _safe_host(host: str, cfg: Config) -> str:
def _log_file() -> str: def _log_file() -> str:
return "/var/log/nvcurve.log" if os.geteuid() == 0 else "/tmp/nvcurve.log" return "/var/log/nvcurve.log" if os.geteuid() == 0 else "/tmp/nvcurve.log" # noqa: S108
def _read_server_info() -> dict | None: def _read_server_info() -> dict | None:
@@ -737,8 +740,14 @@ def cmd_inspect(args):
def cmd_write(args): def cmd_write(args):
delta_khz = int(args.delta * 1000) try:
max_delta_khz = int(args.max_delta * 1000) if args.max_delta is not None else None delta_khz = int(args.delta * 1000)
max_delta_khz = (
int(args.max_delta * 1000) if args.max_delta is not None else None
)
except (TypeError, ValueError, OverflowError) as exc:
print(f"Error: invalid numeric argument: {exc}", file=sys.stderr)
sys.exit(1)
point_deltas = {} point_deltas = {}
if args.reset: if args.reset:
@@ -837,14 +846,15 @@ def cmd_write(args):
print(f"Write OK — {len(point_deltas)} point(s) updated.") print(f"Write OK — {len(point_deltas)} point(s) updated.")
try: try:
curve_state = None
if not args.glob: if not args.glob:
curve_state, _ = read_curve(gpu, gpu_name) curve_state, _ = read_curve(gpu, gpu_name)
if curve_state: if curve_state:
vfp_freqs = [p.freq_khz for p in curve_state.points] vfp_freqs = [p.freq_khz for p in curve_state.points]
for w in check_negative_freq_warnings(point_deltas, vfp_freqs, []): for w in check_negative_freq_warnings(point_deltas, vfp_freqs, []):
print(f"WARNING: {w}") print(f"WARNING: {w}")
except Exception: except Exception as exc:
pass log.debug("Post-write curve check failed: %s", exc)
def cmd_verify(args): def cmd_verify(args):
@@ -855,7 +865,11 @@ def cmd_verify(args):
from .hal.snapshot import save as snapshot_save from .hal.snapshot import save as snapshot_save
from .hal.vfcurve import read_clock_offsets, write_offsets from .hal.vfcurve import read_clock_offsets, write_offsets
delta_khz = int(args.delta * 1000) try:
delta_khz = int(args.delta * 1000)
except (TypeError, ValueError, OverflowError) as exc:
print(f"Error: invalid numeric argument: {exc}", file=sys.stderr)
sys.exit(1)
if args.point is not None: if args.point is not None:
points = [args.point] points = [args.point]
@@ -1016,8 +1030,11 @@ def _profile_config_write(key: str, value) -> None:
data.pop(key, None) data.pop(key, None)
else: else:
data[key] = value data[key] = value
with open(_PERSISTENT_CONFIG_FILE, "w") as f: try:
_json.dump(data, f, indent=2) with open(_PERSISTENT_CONFIG_FILE, "w") as f:
_json.dump(data, f, indent=2)
except OSError as exc:
raise RuntimeError(f"Cannot write {_PERSISTENT_CONFIG_FILE}: {exc}") from exc
def _gpu_stable_key_offline(gpu_index: int) -> str | None: def _gpu_stable_key_offline(gpu_index: int) -> str | None:
@@ -1066,8 +1083,11 @@ def _profile_config_set_default(gpu_index: int, name: str | None) -> None:
profiles[gpu_key] = name profiles[gpu_key] = name
if not profiles: if not profiles:
data.pop("auto_load_profiles", None) data.pop("auto_load_profiles", None)
with open(_PERSISTENT_CONFIG_FILE, "w") as f: try:
_json.dump(data, f, indent=2) with open(_PERSISTENT_CONFIG_FILE, "w") as f:
_json.dump(data, f, indent=2)
except OSError as exc:
raise RuntimeError(f"Cannot write {_PERSISTENT_CONFIG_FILE}: {exc}") from exc
def cmd_profile(args): def cmd_profile(args):
@@ -1094,8 +1114,8 @@ def cmd_profile(args):
profiles.append( profiles.append(
{"name": name, "curve_deltas": p.get("curve_deltas", {})} {"name": name, "curve_deltas": p.get("curve_deltas", {})}
) )
except Exception: except Exception as exc:
pass log.debug("Skipping unreadable profile %s: %s", path, exc)
if not profiles: if not profiles:
print("No profiles found.") print("No profiles found.")
return return
@@ -1117,7 +1137,7 @@ def cmd_profile(args):
require_root() require_root()
try: try:
_profile_config_set_default(gpu_index, None if clearing else args.name) _profile_config_set_default(gpu_index, None if clearing else args.name)
except ValueError as e: except (ValueError, RuntimeError) as e:
print(f"Error: {e}", file=sys.stderr) print(f"Error: {e}", file=sys.stderr)
return return
if clearing: if clearing:
@@ -1205,7 +1225,14 @@ def cmd_profile(args):
errs.append(f"Power limit: {msg}") errs.append(f"Power limit: {msg}")
if profile.curve_deltas: if profile.curve_deltas:
deltas = {int(k): v for k, v in profile.curve_deltas.items()} try:
deltas = {int(k): v for k, v in profile.curve_deltas.items()}
except ValueError:
print(
f"Profile '{args.name}' has invalid curve point keys.",
file=sys.stderr,
)
sys.exit(1)
errors = validate_write(deltas, default_config.max_delta_khz) errors = validate_write(deltas, default_config.max_delta_khz)
if errors: if errors:
errs.append("Curve: " + "; ".join(errors)) errs.append("Curve: " + "; ".join(errors))
@@ -1328,7 +1355,11 @@ def cmd_setup(args):
"""One-shot hardware compatibility check: diag → read → write-verify → restore.""" """One-shot hardware compatibility check: diag → read → write-verify → restore."""
explicit_point = getattr(args, "point", None) explicit_point = getattr(args, "point", None)
verify_delta_mhz = getattr(args, "delta", 5.0) or 5.0 verify_delta_mhz = getattr(args, "delta", 5.0) or 5.0
verify_delta_khz = int(verify_delta_mhz * 1000) try:
verify_delta_khz = int(verify_delta_mhz * 1000)
except (TypeError, ValueError, OverflowError) as exc:
print(f"Error: invalid numeric argument: {exc}", file=sys.stderr)
sys.exit(1)
require_root() require_root()
@@ -1452,13 +1483,20 @@ def cmd_setup(args):
print() print()
print("Step 4/4 Restoring snapshot") print("Step 4/4 Restoring snapshot")
print() print()
ok = snapshot_restore(gpu, default_config.snapshot_dir, snap_path) if snap_path is None:
if ok:
print(" Hardware state restored to baseline.")
else:
print( print(
" WARNING: Restore failed. Run: nvcurve snapshot restore", file=sys.stderr " WARNING: Snapshot save failed — cannot restore baseline.",
file=sys.stderr,
) )
else:
ok = snapshot_restore(gpu, default_config.snapshot_dir, snap_path)
if ok:
print(" Hardware state restored to baseline.")
else:
print(
" WARNING: Restore failed. Run: nvcurve snapshot restore",
file=sys.stderr,
)
print() print()
print(sep) print(sep)
@@ -1509,24 +1547,36 @@ def cmd_service(args):
"WantedBy=multi-user.target\n" "WantedBy=multi-user.target\n"
) )
with open(unit_path, "w") as f: try:
f.write(unit) with open(unit_path, "w") as f:
f.write(unit)
except OSError as exc:
print(f"Failed to write {unit_path}: {exc}", file=sys.stderr)
return
print(f"Unit file written to {unit_path}") print(f"Unit file written to {unit_path}")
# Write persistent config. # Write persistent config.
os.makedirs("/etc/nvcurve", exist_ok=True) try:
os.makedirs("/etc/nvcurve", exist_ok=True)
except OSError as exc:
print(f"Failed to create /etc/nvcurve: {exc}", file=sys.stderr)
return
persistent_cfg: dict = {} persistent_cfg: dict = {}
try: try:
with open(_PERSISTENT_CONFIG_FILE) as f: with open(_PERSISTENT_CONFIG_FILE) as f:
persistent_cfg = json.load(f) persistent_cfg = json.load(f)
except Exception: except Exception as exc:
pass log.debug("Could not read persistent config: %s", exc)
host = getattr(args, "host", "127.0.0.1") host = getattr(args, "host", "127.0.0.1")
port = getattr(args, "port", 8042) port = getattr(args, "port", 8042)
auto_serve = getattr(args, "auto_serve", False) auto_serve = getattr(args, "auto_serve", False)
persistent_cfg.update({"host": host, "port": port, "auto_serve": auto_serve}) persistent_cfg.update({"host": host, "port": port, "auto_serve": auto_serve})
with open(_PERSISTENT_CONFIG_FILE, "w") as f: try:
json.dump(persistent_cfg, f, indent=2) with open(_PERSISTENT_CONFIG_FILE, "w") as f:
json.dump(persistent_cfg, f, indent=2)
except OSError as exc:
print(f"Failed to write {_PERSISTENT_CONFIG_FILE}: {exc}", file=sys.stderr)
return
print(f"Persistent config written to {_PERSISTENT_CONFIG_FILE}") print(f"Persistent config written to {_PERSISTENT_CONFIG_FILE}")
if auto_serve: if auto_serve:
print(f" Web server will auto-start on boot at {host}:{port}") print(f" Web server will auto-start on boot at {host}:{port}")
@@ -1538,12 +1588,8 @@ def cmd_service(args):
try: try:
subprocess.run(["systemctl", "daemon-reload"], check=True) subprocess.run(["systemctl", "daemon-reload"], check=True)
was_active = ( probe = subprocess.run(["systemctl", "is-active", "--quiet", "nvcurve"])
subprocess.run( was_active = probe.returncode == 0
["systemctl", "is-active", "--quiet", "nvcurve"],
).returncode
== 0
)
subprocess.run(["systemctl", "enable", "--now", "nvcurve"], check=True) subprocess.run(["systemctl", "enable", "--now", "nvcurve"], check=True)
print("Service enabled and started.") print("Service enabled and started.")
@@ -1560,7 +1606,7 @@ def cmd_service(args):
print(" systemctl status nvcurve") print(" systemctl status nvcurve")
print(" journalctl -u nvcurve -f") print(" journalctl -u nvcurve -f")
print(" nvcurve service uninstall") print(" nvcurve service uninstall")
except subprocess.CalledProcessError as e: except (subprocess.CalledProcessError, FileNotFoundError) as e:
print(f"systemctl failed: {e}", file=sys.stderr) print(f"systemctl failed: {e}", file=sys.stderr)
elif action == "uninstall": elif action == "uninstall":
@@ -1672,13 +1718,17 @@ def cmd_service(args):
require_root() require_root()
import subprocess import subprocess
os.makedirs("/etc/nvcurve", exist_ok=True) try:
os.makedirs("/etc/nvcurve", exist_ok=True)
except OSError as exc:
print(f"Failed to create /etc/nvcurve: {exc}", file=sys.stderr)
return
pcfg: dict = {} pcfg: dict = {}
try: try:
with open(_PERSISTENT_CONFIG_FILE) as f: with open(_PERSISTENT_CONFIG_FILE) as f:
pcfg = json.load(f) pcfg = json.load(f)
except Exception: except Exception as exc:
pass log.debug("Could not read persistent config: %s", exc)
if hasattr(args, "auto_serve") and args.auto_serve is not None: if hasattr(args, "auto_serve") and args.auto_serve is not None:
pcfg["auto_serve"] = args.auto_serve pcfg["auto_serve"] = args.auto_serve
@@ -1687,8 +1737,12 @@ def cmd_service(args):
if hasattr(args, "port") and args.port is not None: if hasattr(args, "port") and args.port is not None:
pcfg["port"] = args.port pcfg["port"] = args.port
with open(_PERSISTENT_CONFIG_FILE, "w") as f: try:
json.dump(pcfg, f, indent=2) with open(_PERSISTENT_CONFIG_FILE, "w") as f:
json.dump(pcfg, f, indent=2)
except OSError as exc:
print(f"Failed to write {_PERSISTENT_CONFIG_FILE}: {exc}", file=sys.stderr)
return
print(f"Config updated ({_PERSISTENT_CONFIG_FILE}):") print(f"Config updated ({_PERSISTENT_CONFIG_FILE}):")
print(f" auto-serve: {'on' if pcfg.get('auto_serve', False) else 'off'}") print(f" auto-serve: {'on' if pcfg.get('auto_serve', False) else 'off'}")
print(f" host: {pcfg.get('host', '127.0.0.1')}") print(f" host: {pcfg.get('host', '127.0.0.1')}")
@@ -1715,8 +1769,12 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None:
# --direct: skip daemon round-trip (used when the daemon itself spawns us). # --direct: skip daemon round-trip (used when the daemon itself spawns us).
if getattr(args, "direct", False): if getattr(args, "direct", False):
require_root() require_root()
with open(_SERVER_INFO_FILE, "w") as f: try:
json.dump({"pid": os.getpid(), "host": host, "port": port}, f) with open(_SERVER_INFO_FILE, "w") as f:
json.dump({"pid": os.getpid(), "host": host, "port": port}, f)
except OSError as exc:
print(f"Failed to write {_SERVER_INFO_FILE}: {exc}", file=sys.stderr)
return
try: try:
from .server import run as server_run from .server import run as server_run
@@ -1773,8 +1831,12 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None:
cmd += ["--gpu", str(args.gpu_index)] cmd += ["--gpu", str(args.gpu_index)]
log_path = _log_file() log_path = _log_file()
print("Starting nvcurve server in background...") print("Starting nvcurve server in background...")
with open(log_path, "a") as lf: try:
p = subprocess.Popen(cmd, stdout=lf, stderr=lf, start_new_session=True) with open(log_path, "a") as lf:
p = subprocess.Popen(cmd, stdout=lf, stderr=lf, start_new_session=True)
except OSError as exc:
print(f"Failed to open log file {log_path}: {exc}", file=sys.stderr)
return
print(f"Server starting (PID {p.pid}). Logs: {log_path}") print(f"Server starting (PID {p.pid}). Logs: {log_path}")
if open_browser: if open_browser:
time.sleep(1.5) time.sleep(1.5)
@@ -1782,8 +1844,12 @@ def _cmd_serve_start(args, cfg: Config, open_browser: bool = False) -> None:
return return
# Foreground mode — write info file so clients can discover host:port. # Foreground mode — write info file so clients can discover host:port.
with open(_SERVER_INFO_FILE, "w") as f: try:
json.dump({"pid": os.getpid(), "host": host, "port": port}, f) with open(_SERVER_INFO_FILE, "w") as f:
json.dump({"pid": os.getpid(), "host": host, "port": port}, f)
except OSError as exc:
print(f"Failed to write {_SERVER_INFO_FILE}: {exc}", file=sys.stderr)
return
try: try:
from .server import run as server_run from .server import run as server_run
@@ -2092,8 +2158,11 @@ 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"]}
except Exception: if "fan_curves" in data:
pass # Per-GPU active fan curves, restored on server startup.
cfg.fan_curves = dict(data["fan_curves"])
except Exception as exc:
log.debug("Could not load user config: %s", exc)
base_url = args.server or _discover_server_url(cfg) base_url = args.server or _discover_server_url(cfg)
client = NvCurveClient(base=base_url, gpu_index=getattr(args, "gpu_index", 0)) client = NvCurveClient(base=base_url, gpu_index=getattr(args, "gpu_index", 0))
@@ -2145,8 +2214,8 @@ def main():
if os.path.exists(_SERVER_INFO_FILE): if os.path.exists(_SERVER_INFO_FILE):
try: try:
os.remove(_SERVER_INFO_FILE) os.remove(_SERVER_INFO_FILE)
except OSError: except OSError as exc:
pass log.debug("Could not remove %s: %s", _SERVER_INFO_FILE, exc)
except ApiError as e: except ApiError as e:
if e.status_code == 401: if e.status_code == 401:
print( print(
+7
View File
@@ -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()
+2 -1
View File
@@ -209,8 +209,9 @@ async def _serve_socket(auto_serve: bool = False) -> None:
# regular user and talks to this root daemon over the socket. 0o666 is # regular user and talks to this root daemon over the socket. 0o666 is
# intentional (standard for /run daemon sockets). # intentional (standard for /run daemon sockets).
# pi-lens-ignore: S103 # pi-lens-ignore: S103
_SOCKET_MODE = 0o666
os.chmod( os.chmod(
SOCKET_PATH, 0o666 SOCKET_PATH, _SOCKET_MODE
) # nosemgrep: python.lang.security.audit.insecure-file-permissions.insecure-file-permissions ) # nosemgrep: python.lang.security.audit.insecure-file-permissions.insecure-file-permissions
log.info("Daemon listening on %s", SOCKET_PATH) log.info("Daemon listening on %s", SOCKET_PATH)
+29 -11
View File
@@ -9,14 +9,19 @@ Uses NVML (via pynvml) for all operations:
import ctypes import ctypes
import logging import logging
from typing import List, Optional from typing import Any
try: try:
import pynvml import pynvml as _pynvml_import
_NVML_AVAILABLE = True _NVML_AVAILABLE = True
except ImportError: except ImportError:
_pynvml_import = None
_NVML_AVAILABLE = False _NVML_AVAILABLE = False
# Aliased as Any so attribute access is not flagged when the import failed.
pynvml: Any = _pynvml_import
log = logging.getLogger("nvcurve.hal.fans") log = logging.getLogger("nvcurve.hal.fans")
# We use fan index 0 (first/primary fan) for all operations. # We use fan index 0 (first/primary fan) for all operations.
@@ -35,7 +40,7 @@ def get_fan_info(gpu_index: int = 0) -> dict:
Returns None values on failure. Returns None values on failure.
""" """
out = { out: dict[str, float | None] = {
"fan_pct": None, "fan_pct": None,
"fan_mode": None, "fan_mode": None,
"min_fan_pct": None, "min_fan_pct": None,
@@ -75,7 +80,10 @@ def get_fan_info(gpu_index: int = 0) -> dict:
def set_fan_speed(gpu_index: int, pct: int) -> tuple[bool, str]: def set_fan_speed(gpu_index: int, pct: int) -> tuple[bool, str]:
"""Set fan speed to a percentage (0-100) on the primary fan.""" """Set fan speed to a percentage (0-100) on the primary fan."""
pct = max(0, min(100, int(pct))) try:
pct = max(0, min(100, int(pct)))
except (TypeError, ValueError):
return False, "Invalid fan speed"
if not _NVML_AVAILABLE: if not _NVML_AVAILABLE:
return False, "NVML not available" return False, "NVML not available"
try: try:
@@ -102,7 +110,9 @@ def reset_fan(gpu_index: int = 0) -> tuple[bool, str]:
try: try:
ret = subprocess.run( ret = subprocess.run(
["nvidia-smi", "-i", str(gpu_index), "-fan", "default"], ["nvidia-smi", "-i", str(gpu_index), "-fan", "default"],
capture_output=True, text=True, timeout=10, capture_output=True,
text=True,
timeout=10,
) )
if ret.returncode == 0: if ret.returncode == 0:
return True, "OK" return True, "OK"
@@ -123,19 +133,21 @@ def reset_fan(gpu_index: int = 0) -> tuple[bool, str]:
return False, f"Failed to reset fan: {exc}" return False, f"Failed to reset fan: {exc}"
def get_temp(gpu_index: int = 0) -> Optional[float]: def get_temp(gpu_index: int = 0) -> float | None:
"""Read current GPU temperature in °C.""" """Read current GPU temperature in °C."""
if not _NVML_AVAILABLE: if not _NVML_AVAILABLE:
return None return None
try: try:
handle = _get_handle(gpu_index) handle = _get_handle(gpu_index)
return float(pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU)) return float(
pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU)
)
except pynvml.NVMLError as exc: except pynvml.NVMLError as exc:
log.debug("get_temp: %s", exc) log.debug("get_temp: %s", exc)
return None return None
def interpolate_fan_speed(curve: List[dict], temp_c: float) -> Optional[int]: def interpolate_fan_speed(curve: list[dict], temp_c: float) -> int | None:
"""Interpolate target fan speed from a curve at a given temperature. """Interpolate target fan speed from a curve at a given temperature.
curve: list of {temp_c: int, fan_pct: int} sorted by temp_c curve: list of {temp_c: int, fan_pct: int} sorted by temp_c
@@ -144,7 +156,10 @@ def interpolate_fan_speed(curve: List[dict], temp_c: float) -> Optional[int]:
if not curve or len(curve) < 2: if not curve or len(curve) < 2:
return None return None
temp = float(temp_c) try:
temp = float(temp_c)
except (TypeError, ValueError):
return None
# Find the two surrounding points # Find the two surrounding points
for i in range(len(curve) - 1): for i in range(len(curve) - 1):
@@ -157,7 +172,10 @@ def interpolate_fan_speed(curve: List[dict], temp_c: float) -> Optional[int]:
if t0 <= temp <= t1: if t0 <= temp <= t1:
fraction = (temp - t0) / (t1 - t0) fraction = (temp - t0) / (t1 - t0)
result = f0 + fraction * (f1 - f0) result = f0 + fraction * (f1 - f0)
return max(0, min(100, int(round(result)))) try:
return max(0, min(100, int(round(result))))
except (TypeError, ValueError):
return None
# Outside range: clamp to first or last point # Outside range: clamp to first or last point
if temp <= curve[0]["temp_c"]: if temp <= curve[0]["temp_c"]:
@@ -165,7 +183,7 @@ def interpolate_fan_speed(curve: List[dict], temp_c: float) -> Optional[int]:
return max(0, min(100, curve[-1]["fan_pct"])) return max(0, min(100, curve[-1]["fan_pct"]))
def validate_curve(curve: List[dict]) -> tuple[bool, str]: def validate_curve(curve: list[dict]) -> tuple[bool, str]:
"""Validate a fan curve. """Validate a fan curve.
Returns (True, "OK") or (False, error_message). Returns (True, "OK") or (False, error_message).
+38 -20
View File
@@ -1,12 +1,17 @@
"""GPU discovery and initialization.""" """GPU discovery and initialization."""
import contextlib
import ctypes import ctypes
import logging
import sys import sys
from typing import Any
from ..nvapi.bootstrap import query_interface from ..nvapi.bootstrap import query_interface
from ..nvapi.constants import FUNC from ..nvapi.constants import FUNC
from ..nvapi.types import GpuInfo from ..nvapi.types import GpuInfo
log = logging.getLogger("nvcurve.hal.gpu")
def init_nvapi() -> None: def init_nvapi() -> None:
"""Initialize NvAPI. Must be called before any GPU operations.""" """Initialize NvAPI. Must be called before any GPU operations."""
@@ -19,7 +24,10 @@ def enumerate_gpus() -> tuple[ctypes.Array, int]:
"""Return (gpu_handles_array, count). Exits if no GPUs found.""" """Return (gpu_handles_array, count). Exits if no GPUs found."""
gpus = (ctypes.c_void_p * 64)() gpus = (ctypes.c_void_p * 64)()
ngpu = ctypes.c_int32() ngpu = ctypes.c_int32()
query_interface(FUNC["EnumPhysicalGPUs"])(ctypes.byref(gpus), ctypes.byref(ngpu)) enum_fn = query_interface(FUNC["EnumPhysicalGPUs"])
if enum_fn is None:
raise RuntimeError("NvAPI function EnumPhysicalGPUs not available")
enum_fn(ctypes.byref(gpus), ctypes.byref(ngpu))
if ngpu.value == 0: if ngpu.value == 0:
print("No NVIDIA GPUs found") print("No NVIDIA GPUs found")
sys.exit(1) sys.exit(1)
@@ -29,7 +37,10 @@ def enumerate_gpus() -> tuple[ctypes.Array, int]:
def get_gpu_name(gpu) -> str: def get_gpu_name(gpu) -> str:
"""Return the full name string for a GPU handle.""" """Return the full name string for a GPU handle."""
name_buf = ctypes.create_string_buffer(256) name_buf = ctypes.create_string_buffer(256)
query_interface(FUNC["GetFullName"])(gpu, name_buf) fn = query_interface(FUNC["GetFullName"])
if fn is None:
raise RuntimeError("NvAPI function GetFullName not available")
fn(gpu, name_buf)
return name_buf.value.decode(errors="replace") return name_buf.value.decode(errors="replace")
@@ -40,38 +51,45 @@ def discover_gpus() -> list[GpuInfo]:
infos = [] infos = []
try: try:
import pynvml import pynvml as _pynvml
pynvml.nvmlInit()
has_nvml = True _pynvml.nvmlInit()
except Exception: except Exception:
has_nvml = False _pynvml = None
# Aliased as Any so attribute access is not flagged when the import failed.
pynvml: Any = _pynvml
for i in range(count): for i in range(count):
name = get_gpu_name(gpus[i]) name = get_gpu_name(gpus[i])
uuid = None uuid = None
pci_bus_id = None pci_bus_id = None
if has_nvml: if pynvml is not None:
try: try:
handle = pynvml.nvmlDeviceGetHandleByIndex(i) handle = pynvml.nvmlDeviceGetHandleByIndex(i)
uuid = pynvml.nvmlDeviceGetUUID(handle) raw_uuid = pynvml.nvmlDeviceGetUUID(handle)
# NVML might return bytes # NVML might return bytes
if isinstance(uuid, bytes): if isinstance(raw_uuid, bytes):
uuid = uuid.decode('utf-8', errors='ignore') uuid = raw_uuid.decode("utf-8", errors="ignore")
elif raw_uuid is not None:
uuid = str(raw_uuid)
pci_info = pynvml.nvmlDeviceGetPciInfo(handle) pci_info = pynvml.nvmlDeviceGetPciInfo(handle)
# Parse something like "00000000:01:00.0" -> bus is 1 # Parse something like "00000000:01:00.0" -> bus is 1.
if isinstance(pci_info.bus, bytes): # PCI bus numbers are hex by convention (pynvml's field is an
pci_bus_id = int(pci_info.bus.decode('utf-8', errors='ignore'), 16) # int; the str/bytes branches are defensive).
bus = pci_info.bus
if isinstance(bus, bytes):
pci_bus_id = int(bus.decode("utf-8", errors="ignore"), 16)
elif isinstance(bus, str):
pci_bus_id = int(bus, 16)
else: else:
pci_bus_id = pci_info.bus pci_bus_id = int(bus)
except Exception: except Exception as exc:
pass log.debug("NVML query for GPU %d failed: %s", i, exc)
infos.append(GpuInfo(name=name, index=i, uuid=uuid, pci_bus_id=pci_bus_id)) infos.append(GpuInfo(name=name, index=i, uuid=uuid, pci_bus_id=pci_bus_id))
if has_nvml: if pynvml is not None:
try: with contextlib.suppress(Exception):
pynvml.nvmlShutdown() pynvml.nvmlShutdown()
except Exception:
pass
return infos return infos
+75 -38
View File
@@ -11,21 +11,26 @@ that are explicitly specified, leaving others unchanged on hardware.
""" """
import ctypes import ctypes
import subprocess
import logging import logging
from typing import Optional import subprocess
from typing import Any
try: try:
import pynvml import pynvml as _pynvml_import
_NVML_AVAILABLE = True _NVML_AVAILABLE = True
except ImportError: except ImportError:
_pynvml_import = None
_NVML_AVAILABLE = False _NVML_AVAILABLE = False
# Aliased as Any so attribute access is not flagged when the import failed.
pynvml: Any = _pynvml_import
log = logging.getLogger("nvcurve.hal.limits") log = logging.getLogger("nvcurve.hal.limits")
# ── NVML library / handle helpers ───────────────────────────────────────────── # ── NVML library / handle helpers ─────────────────────────────────────────────
_nvml_lib: Optional[ctypes.CDLL] = None _nvml_lib: ctypes.CDLL | None = None
def _nvml_cdll() -> ctypes.CDLL: def _nvml_cdll() -> ctypes.CDLL:
@@ -34,12 +39,12 @@ def _nvml_cdll() -> ctypes.CDLL:
if _nvml_lib is not None: if _nvml_lib is not None:
return _nvml_lib return _nvml_lib
# Prefer to reuse the library already loaded by pynvml to avoid dlopen races. # Prefer to reuse the library already loaded by pynvml to avoid dlopen races.
for attr in ("nvml", "_nvml"): # attribute name varies by pynvml version for attr in ("nvml", "_nvml"): # attribute name varies by pynvml version
mod = getattr(pynvml, attr, None) mod = getattr(pynvml, attr, None)
lib = getattr(mod, "_lib", None) or getattr(mod, "_nvmlLib", None) lib = getattr(mod, "_lib", None) or getattr(mod, "_nvmlLib", None)
if lib is not None: if lib is not None:
_nvml_lib = lib _nvml_lib = lib
return _nvml_lib return lib
_nvml_lib = ctypes.CDLL("libnvidia-ml.so.1") _nvml_lib = ctypes.CDLL("libnvidia-ml.so.1")
return _nvml_lib return _nvml_lib
@@ -53,9 +58,10 @@ def _get_handle(gpu_index: int):
# ── Power limit ─────────────────────────────────────────────────────────────── # ── Power limit ───────────────────────────────────────────────────────────────
def get_power_limit(gpu_index: int = 0) -> dict: def get_power_limit(gpu_index: int = 0) -> dict:
"""Return dict with power_limit_w, default_power_limit_w, min_power_limit_w, max_power_limit_w.""" """Return dict with power_limit_w, default_power_limit_w, min_power_limit_w, max_power_limit_w."""
out = { out: dict[str, int | None] = {
"power_limit_w": None, "power_limit_w": None,
"default_power_limit_w": None, "default_power_limit_w": None,
"min_power_limit_w": None, "min_power_limit_w": None,
@@ -71,8 +77,8 @@ def get_power_limit(gpu_index: int = 0) -> dict:
try: try:
default = pynvml.nvmlDeviceGetPowerManagementDefaultLimit(handle) default = pynvml.nvmlDeviceGetPowerManagementDefaultLimit(handle)
out["default_power_limit_w"] = default // 1000 out["default_power_limit_w"] = default // 1000
except Exception: except Exception as exc:
pass log.debug("nvmlDeviceGetPowerManagementDefaultLimit: %s", exc)
except Exception as exc: except Exception as exc:
log.warning("get_power_limit: %s", exc) log.warning("get_power_limit: %s", exc)
return out return out
@@ -89,7 +95,8 @@ def set_power_limit(limit_w: int, gpu_index: int = 0) -> tuple[bool, str]:
ret = subprocess.run( ret = subprocess.run(
["nvidia-smi", "-i", str(gpu_index), "-pl", str(limit_w)], ["nvidia-smi", "-i", str(gpu_index), "-pl", str(limit_w)],
capture_output=True, text=True, capture_output=True,
text=True,
) )
if ret.returncode == 0: if ret.returncode == 0:
return True, "OK" return True, "OK"
@@ -111,34 +118,38 @@ def set_power_limit(limit_w: int, gpu_index: int = 0) -> tuple[bool, str]:
# pynvml (nvidia-ml-py ≥ 12) exposes c_nvmlClockOffset_t and nvmlClockOffset_v1 # pynvml (nvidia-ml-py ≥ 12) exposes c_nvmlClockOffset_t and nvmlClockOffset_v1
# as ctypes objects; we use them when available and fall back to our own definition. # as ctypes objects; we use them when available and fall back to our own definition.
class _ClockOffset(ctypes.Structure): class _ClockOffset(ctypes.Structure):
_fields_ = [ _fields_ = [
("version", ctypes.c_uint), ("version", ctypes.c_uint),
("type", ctypes.c_uint), # nvmlClockType_t ("type", ctypes.c_uint), # nvmlClockType_t
("pstate", ctypes.c_uint), # nvmlPstates_t ("pstate", ctypes.c_uint), # nvmlPstates_t
("clockOffsetMHz", ctypes.c_int), ("clockOffsetMHz", ctypes.c_int),
] ]
_CLOCK_OFFSET_VER = (1 << 24) | ctypes.sizeof(_ClockOffset) # = 0x01000010 (16 bytes) _CLOCK_OFFSET_VER = (1 << 24) | ctypes.sizeof(_ClockOffset) # = 0x01000010 (16 bytes)
# NVML clock-type constants (same values as pynvml). # NVML clock-type constants (same values as pynvml).
_NVML_CLOCK_GRAPHICS = 0 _NVML_CLOCK_GRAPHICS = 0
_NVML_CLOCK_MEM = 2 _NVML_CLOCK_MEM = 2
def _make_clock_offset(clock_type: int, pstate: int = 0, offset_mhz: int = 0) -> ctypes.Structure: def _make_clock_offset(
clock_type: int, pstate: int = 0, offset_mhz: int = 0
) -> ctypes.Structure:
"""Return a populated nvmlClockOffset_t struct, using pynvml's type when available.""" """Return a populated nvmlClockOffset_t struct, using pynvml's type when available."""
if hasattr(pynvml, "c_nvmlClockOffset_t") and hasattr(pynvml, "nvmlClockOffset_v1"): if hasattr(pynvml, "c_nvmlClockOffset_t") and hasattr(pynvml, "nvmlClockOffset_v1"):
info = pynvml.c_nvmlClockOffset_t() info = pynvml.c_nvmlClockOffset_t()
info.version = pynvml.nvmlClockOffset_v1 info.version = pynvml.nvmlClockOffset_v1
info.type = clock_type info.type = clock_type
info.pstate = pstate info.pstate = pstate
info.clockOffsetMHz = offset_mhz info.clockOffsetMHz = offset_mhz
return info return info
info = _ClockOffset() info = _ClockOffset()
info.version = _CLOCK_OFFSET_VER info.version = _CLOCK_OFFSET_VER
info.type = clock_type info.type = clock_type
info.pstate = pstate info.pstate = pstate
info.clockOffsetMHz = offset_mhz info.clockOffsetMHz = offset_mhz
return info return info
@@ -158,7 +169,7 @@ def get_clock_offsets(gpu_index: int = 0) -> dict:
Keys: gpc_offset_mhz, mem_offset_mhz (both int or None on failure). Keys: gpc_offset_mhz, mem_offset_mhz (both int or None on failure).
Calls nvmlDeviceGetClockOffsets once per clock domain (GRAPHICS, MEM). Calls nvmlDeviceGetClockOffsets once per clock domain (GRAPHICS, MEM).
""" """
out = {"gpc_offset_mhz": None, "mem_offset_mhz": None} out: dict[str, int | None] = {"gpc_offset_mhz": None, "mem_offset_mhz": None}
if not _NVML_AVAILABLE: if not _NVML_AVAILABLE:
return out return out
try: try:
@@ -167,11 +178,15 @@ def get_clock_offsets(gpu_index: int = 0) -> dict:
# Try pynvml wrapper first (nvidia-ml-py ≥ 12 exposes it correctly). # Try pynvml wrapper first (nvidia-ml-py ≥ 12 exposes it correctly).
# Fall back to ctypes-direct if pynvml doesn't have it. # Fall back to ctypes-direct if pynvml doesn't have it.
_pynvml_get = getattr(pynvml, "nvmlDeviceGetClockOffsets", None) _pynvml_get = getattr(pynvml, "nvmlDeviceGetClockOffsets", None)
fn_get = _try_nvml_fn("nvmlDeviceGetClockOffsets") if _pynvml_get is None else None fn_get = (
_try_nvml_fn("nvmlDeviceGetClockOffsets") if _pynvml_get is None else None
)
used_new_api = False used_new_api = False
for clock_type, key in ((_NVML_CLOCK_GRAPHICS, "gpc_offset_mhz"), for clock_type, key in (
(_NVML_CLOCK_MEM, "mem_offset_mhz")): (_NVML_CLOCK_GRAPHICS, "gpc_offset_mhz"),
(_NVML_CLOCK_MEM, "mem_offset_mhz"),
):
info = _make_clock_offset(clock_type, pstate=0) info = _make_clock_offset(clock_type, pstate=0)
try: try:
if _pynvml_get is not None: if _pynvml_get is not None:
@@ -184,7 +199,9 @@ def get_clock_offsets(gpu_index: int = 0) -> dict:
out[key] = int(info.clockOffsetMHz) out[key] = int(info.clockOffsetMHz)
used_new_api = True used_new_api = True
else: else:
log.debug("nvmlDeviceGetClockOffsets(type=%d) returned %d", clock_type, rc) log.debug(
"nvmlDeviceGetClockOffsets(type=%d) returned %d", clock_type, rc
)
except Exception as exc: except Exception as exc:
log.debug("nvmlDeviceGetClockOffsets(type=%d): %s", clock_type, exc) log.debug("nvmlDeviceGetClockOffsets(type=%d): %s", clock_type, exc)
@@ -200,7 +217,9 @@ def get_clock_offsets(gpu_index: int = 0) -> dict:
if hasattr(pynvml, "nvmlDeviceGetMemClkVfOffset"): if hasattr(pynvml, "nvmlDeviceGetMemClkVfOffset"):
try: try:
res = pynvml.nvmlDeviceGetMemClkVfOffset(handle) res = pynvml.nvmlDeviceGetMemClkVfOffset(handle)
out["mem_offset_mhz"] = int(res[0] if isinstance(res, (list, tuple)) else res) out["mem_offset_mhz"] = int(
res[0] if isinstance(res, (list, tuple)) else res
)
except Exception as exc: except Exception as exc:
log.debug("nvmlDeviceGetMemClkVfOffset: %s", exc) log.debug("nvmlDeviceGetMemClkVfOffset: %s", exc)
@@ -210,8 +229,8 @@ def get_clock_offsets(gpu_index: int = 0) -> dict:
def set_clock_offsets( def set_clock_offsets(
gpc_offset_mhz: Optional[int] = None, gpc_offset_mhz: int | None = None,
mem_offset_mhz: Optional[int] = None, mem_offset_mhz: int | None = None,
gpu_index: int = 0, gpu_index: int = 0,
) -> tuple[bool, str]: ) -> tuple[bool, str]:
"""Set clock offsets (MHz) for the specified domains only. """Set clock offsets (MHz) for the specified domains only.
@@ -235,20 +254,35 @@ def set_clock_offsets(
domains.append((_NVML_CLOCK_MEM, mem_offset_mhz)) domains.append((_NVML_CLOCK_MEM, mem_offset_mhz))
_pynvml_set = getattr(pynvml, "nvmlDeviceSetClockOffsets", None) _pynvml_set = getattr(pynvml, "nvmlDeviceSetClockOffsets", None)
fn_set = _try_nvml_fn("nvmlDeviceSetClockOffsets") if _pynvml_set is None else None fn_set = (
_try_nvml_fn("nvmlDeviceSetClockOffsets") if _pynvml_set is None else None
)
if _pynvml_set is not None or fn_set is not None: if _pynvml_set is not None or fn_set is not None:
all_ok = True all_ok = True
for clock_type, offset in domains: for clock_type, offset in domains:
info = _make_clock_offset(clock_type, pstate=0, offset_mhz=offset) info = _make_clock_offset(clock_type, pstate=0, offset_mhz=offset)
try: try:
rc = _pynvml_set(handle, ctypes.byref(info)) if _pynvml_set else fn_set(handle, ctypes.byref(info)) if _pynvml_set is not None:
rc = _pynvml_set(handle, ctypes.byref(info))
elif fn_set is not None:
rc = fn_set(handle, ctypes.byref(info))
else:
break
if rc != 0: if rc != 0:
log.debug("nvmlDeviceSetClockOffsets(type=%d) returned %d — trying fallback", clock_type, rc) log.debug(
"nvmlDeviceSetClockOffsets(type=%d) returned %d — trying fallback",
clock_type,
rc,
)
all_ok = False all_ok = False
break break
except Exception as exc: except Exception as exc:
log.debug("nvmlDeviceSetClockOffsets(type=%d): %s — trying fallback", clock_type, exc) log.debug(
"nvmlDeviceSetClockOffsets(type=%d): %s — trying fallback",
clock_type,
exc,
)
all_ok = False all_ok = False
break break
if all_ok: if all_ok:
@@ -257,12 +291,16 @@ def set_clock_offsets(
# Deprecated per-domain fallback (works on Blackwell/driver 590.x). # Deprecated per-domain fallback (works on Blackwell/driver 590.x).
errs = [] errs = []
if gpc_offset_mhz is not None and hasattr(pynvml, "nvmlDeviceSetGpcClkVfOffset"): if gpc_offset_mhz is not None and hasattr(
pynvml, "nvmlDeviceSetGpcClkVfOffset"
):
try: try:
pynvml.nvmlDeviceSetGpcClkVfOffset(handle, gpc_offset_mhz) pynvml.nvmlDeviceSetGpcClkVfOffset(handle, gpc_offset_mhz)
except Exception as exc: except Exception as exc:
errs.append(f"GPC: {exc}") errs.append(f"GPC: {exc}")
if mem_offset_mhz is not None and hasattr(pynvml, "nvmlDeviceSetMemClkVfOffset"): if mem_offset_mhz is not None and hasattr(
pynvml, "nvmlDeviceSetMemClkVfOffset"
):
try: try:
pynvml.nvmlDeviceSetMemClkVfOffset(handle, mem_offset_mhz) pynvml.nvmlDeviceSetMemClkVfOffset(handle, mem_offset_mhz)
except Exception as exc: except Exception as exc:
@@ -278,6 +316,7 @@ def set_clock_offsets(
# ── Range queries ───────────────────────────────────────────────────────────── # ── Range queries ─────────────────────────────────────────────────────────────
def get_mem_offset_range(gpu_index: int = 0) -> dict: def get_mem_offset_range(gpu_index: int = 0) -> dict:
"""Return the min/max allowed memory clock offset (MHz). """Return the min/max allowed memory clock offset (MHz).
@@ -285,7 +324,7 @@ def get_mem_offset_range(gpu_index: int = 0) -> dict:
Uses nvmlDeviceGetMemClkMinMaxVfOffset; falls back to observed RTX values. Uses nvmlDeviceGetMemClkMinMaxVfOffset; falls back to observed RTX values.
""" """
# Observed RTX 5090 defaults (NvAPI GetClockBoostRanges says -1000/+3000). # Observed RTX 5090 defaults (NvAPI GetClockBoostRanges says -1000/+3000).
out = {"min_mem_offset_mhz": -2000, "max_mem_offset_mhz": 3000} out: dict[str, int] = {"min_mem_offset_mhz": -2000, "max_mem_offset_mhz": 3000}
if not _NVML_AVAILABLE: if not _NVML_AVAILABLE:
return out return out
try: try:
@@ -317,5 +356,3 @@ def get_mem_offset_range(gpu_index: int = 0) -> dict:
except Exception as exc: except Exception as exc:
log.debug("get_mem_offset_range: %s", exc) log.debug("get_mem_offset_range: %s", exc)
return out return out
+61 -30
View File
@@ -2,18 +2,20 @@
import ctypes import ctypes
import json import json
import logging
import os import os
import struct import struct
from datetime import datetime from datetime import datetime
from typing import Optional
from ..nvapi.bootstrap import nvcall_raw from ..nvapi.bootstrap import nvcall_raw
from ..nvapi.constants import FUNC, CT_SIZE, CT_BASE, CT_STRIDE, CT_DELTA_OFF, CT_POINTS from ..nvapi.constants import CT_BASE, CT_DELTA_OFF, CT_SIZE, CT_STRIDE, FUNC
from ..nvapi.types import SnapshotInfo from ..nvapi.types import SnapshotInfo
from .vfcurve import read_clock_table_raw, get_boost_mask from .vfcurve import get_boost_mask, read_clock_table_raw
log = logging.getLogger("nvcurve.hal.snapshot")
def save(gpu, gpu_name: str, snapshot_dir: str, max_snapshots: int = 0) -> Optional[str]: def save(gpu, gpu_name: str, snapshot_dir: str, max_snapshots: int = 0) -> str | None:
"""Save the current ClockBoostTable to disk. """Save the current ClockBoostTable to disk.
Writes both a binary .bin file and a human-readable .json metadata file. Writes both a binary .bin file and a human-readable .json metadata file.
@@ -25,13 +27,21 @@ def save(gpu, gpu_name: str, snapshot_dir: str, max_snapshots: int = 0) -> Optio
print(f"Failed to read ClockBoostTable: {err}") print(f"Failed to read ClockBoostTable: {err}")
return None return None
os.makedirs(snapshot_dir, exist_ok=True) try:
os.makedirs(snapshot_dir, exist_ok=True)
except OSError as exc:
print(f"Failed to create snapshot dir {snapshot_dir}: {exc}")
return None
ts = datetime.now().strftime("%Y%m%d_%H%M%S") ts = datetime.now().strftime("%Y%m%d_%H%M%S")
bin_path = os.path.join(snapshot_dir, f"clock_boost_table_{ts}.bin") bin_path = os.path.join(snapshot_dir, f"clock_boost_table_{ts}.bin")
meta_path = os.path.join(snapshot_dir, f"clock_boost_table_{ts}.json") meta_path = os.path.join(snapshot_dir, f"clock_boost_table_{ts}.json")
with open(bin_path, "wb") as f: try:
f.write(raw) with open(bin_path, "wb") as f:
f.write(raw)
except OSError as exc:
print(f"Failed to write snapshot {bin_path}: {exc}")
return None
offsets = [] offsets = []
max_entries = (len(raw) - CT_BASE) // CT_STRIDE max_entries = (len(raw) - CT_BASE) // CT_STRIDE
@@ -48,10 +58,14 @@ def save(gpu, gpu_name: str, snapshot_dir: str, max_snapshots: int = 0) -> Optio
"offsets_kHz": offsets, "offsets_kHz": offsets,
"nonzero_offsets": sum(1 for o in offsets if o != 0), "nonzero_offsets": sum(1 for o in offsets if o != 0),
} }
with open(meta_path, "w") as f: try:
json.dump(meta, f, indent=2) with open(meta_path, "w") as f:
json.dump(meta, f, indent=2)
except OSError as exc:
print(f"Failed to write snapshot metadata {meta_path}: {exc}")
return None
print(f"Snapshot saved:") print("Snapshot saved:")
print(f" Binary: {bin_path}") print(f" Binary: {bin_path}")
print(f" Metadata: {meta_path}") print(f" Metadata: {meta_path}")
print(f" Size: {len(raw)} bytes") print(f" Size: {len(raw)} bytes")
@@ -65,20 +79,22 @@ def save(gpu, gpu_name: str, snapshot_dir: str, max_snapshots: int = 0) -> Optio
def _prune_snapshots(snapshot_dir: str, max_snapshots: int) -> None: def _prune_snapshots(snapshot_dir: str, max_snapshots: int) -> None:
"""Delete oldest snapshots (both .bin and .json) to stay within max_snapshots.""" """Delete oldest snapshots (both .bin and .json) to stay within max_snapshots."""
bins = sorted( # Oldest first (lexicographic = chronological for our timestamp format).
f for f in os.listdir(snapshot_dir) if f.endswith(".bin") try:
) # oldest first (lexicographic = chronological for our timestamp format) bins = sorted(f for f in os.listdir(snapshot_dir) if f.endswith(".bin"))
except OSError:
return
excess = len(bins) - max_snapshots excess = len(bins) - max_snapshots
for fname in bins[:excess]: for fname in bins[:excess]:
stem = fname[:-4] # strip .bin stem = fname[:-4] # strip .bin
for ext in (".bin", ".json"): for ext in (".bin", ".json"):
try: try:
os.remove(os.path.join(snapshot_dir, stem + ext)) os.remove(os.path.join(snapshot_dir, stem + ext))
except OSError: except OSError as exc:
pass log.debug("Could not remove %s: %s", stem + ext, exc)
def restore(gpu, snapshot_dir: str, filepath: str = None) -> bool: def restore(gpu, snapshot_dir: str, filepath: str | None = None) -> bool:
"""Restore a ClockBoostTable snapshot from disk. """Restore a ClockBoostTable snapshot from disk.
If no filepath is given, uses the most recent snapshot in snapshot_dir. If no filepath is given, uses the most recent snapshot in snapshot_dir.
@@ -88,10 +104,14 @@ def restore(gpu, snapshot_dir: str, filepath: str = None) -> bool:
if not os.path.isdir(snapshot_dir): if not os.path.isdir(snapshot_dir):
print(f"No snapshots found in {snapshot_dir}") print(f"No snapshots found in {snapshot_dir}")
return False return False
bins = sorted( try:
[f for f in os.listdir(snapshot_dir) if f.endswith(".bin")], bins = sorted(
reverse=True, [f for f in os.listdir(snapshot_dir) if f.endswith(".bin")],
) reverse=True,
)
except OSError:
print(f"No snapshots found in {snapshot_dir}")
return False
if not bins: if not bins:
print(f"No snapshot .bin files in {snapshot_dir}") print(f"No snapshot .bin files in {snapshot_dir}")
return False return False
@@ -101,8 +121,12 @@ def restore(gpu, snapshot_dir: str, filepath: str = None) -> bool:
print(f"Snapshot file not found: {filepath}") print(f"Snapshot file not found: {filepath}")
return False return False
with open(filepath, "rb") as f: try:
raw = f.read() with open(filepath, "rb") as f:
raw = f.read()
except OSError as exc:
print(f"Failed to read snapshot {filepath}: {exc}")
return False
if len(raw) != CT_SIZE: if len(raw) != CT_SIZE:
print(f"Snapshot size mismatch: expected {CT_SIZE}, got {len(raw)}") print(f"Snapshot size mismatch: expected {CT_SIZE}, got {len(raw)}")
@@ -134,8 +158,13 @@ def list_snapshots(snapshot_dir: str) -> list[SnapshotInfo]:
if not os.path.isdir(snapshot_dir): if not os.path.isdir(snapshot_dir):
return [] return []
try:
fnames = sorted(os.listdir(snapshot_dir), reverse=True)
except OSError:
return []
results = [] results = []
for fname in sorted(os.listdir(snapshot_dir), reverse=True): for fname in fnames:
if not fname.endswith(".json"): if not fname.endswith(".json"):
continue continue
meta_path = os.path.join(snapshot_dir, fname) meta_path = os.path.join(snapshot_dir, fname)
@@ -143,13 +172,15 @@ def list_snapshots(snapshot_dir: str) -> list[SnapshotInfo]:
with open(meta_path) as f: with open(meta_path) as f:
meta = json.load(f) meta = json.load(f)
bin_path = meta.get("file", meta_path.replace(".json", ".bin")) bin_path = meta.get("file", meta_path.replace(".json", ".bin"))
results.append(SnapshotInfo( results.append(
filepath=bin_path, SnapshotInfo(
timestamp=meta.get("timestamp", ""), filepath=bin_path,
gpu=meta.get("gpu", ""), timestamp=meta.get("timestamp", ""),
nonzero_offsets=meta.get("nonzero_offsets", 0), gpu=meta.get("gpu", ""),
size=meta.get("size", 0), nonzero_offsets=meta.get("nonzero_offsets", 0),
)) size=meta.get("size", 0),
)
)
except (json.JSONDecodeError, KeyError): except (json.JSONDecodeError, KeyError):
continue continue
+89 -38
View File
@@ -21,12 +21,12 @@ def _gpu_stable_key(info) -> str:
def apply_profile(gpu_index: int, name: str, cfg) -> list[str]: def apply_profile(gpu_index: int, name: str, cfg) -> list[str]:
"""Apply a named profile to the given GPU. Returns a list of error strings.""" """Apply a named profile to the given GPU. Returns a list of error strings."""
from .native import load_profile
from ..hal.gpu import get_gpu from ..hal.gpu import get_gpu
from ..hal.limits import set_clock_offsets, set_power_limit from ..hal.limits import set_clock_offsets, set_power_limit
from ..hal.vfcurve import write_offsets, reset_offsets
from ..hal.snapshot import save as snapshot_save from ..hal.snapshot import save as snapshot_save
from ..hal.vfcurve import reset_offsets, write_offsets
from ..safety import validate_write from ..safety import validate_write
from .native import load_profile
safe_name = "".join(c for c in name if c.isalnum() or c in " _-()").strip() safe_name = "".join(c for c in name if c.isalnum() or c in " _-()").strip()
filepath = os.path.join(cfg.profile_dir, f"{safe_name}.json") filepath = os.path.join(cfg.profile_dir, f"{safe_name}.json")
@@ -48,19 +48,25 @@ def apply_profile(gpu_index: int, name: str, cfg) -> list[str]:
errs.append(f"Power limit: {msg}") errs.append(f"Power limit: {msg}")
if profile.curve_deltas: if profile.curve_deltas:
deltas = {int(k): v for k, v in profile.curve_deltas.items()} try:
errors = validate_write(deltas, cfg.max_delta_khz) deltas = {int(k): v for k, v in profile.curve_deltas.items()}
if errors: except ValueError:
errs.append("Curve: " + "; ".join(errors)) errs.append("Curve: invalid point keys in profile")
else: else:
if cfg.auto_snapshot: errors = validate_write(deltas, cfg.max_delta_khz)
try: if errors:
snapshot_save(gpu, gpu_name, cfg.snapshot_dir, cfg.max_snapshots) errs.append("Curve: " + "; ".join(errors))
except Exception as exc: else:
log.warning("Auto-snapshot failed: %s", exc) if cfg.auto_snapshot:
ret, desc = write_offsets(gpu, deltas) try:
if ret != 0: snapshot_save(
errs.append(f"Curve write failed ({ret}): {desc}") gpu, gpu_name, cfg.snapshot_dir, cfg.max_snapshots
)
except Exception as exc:
log.warning("Auto-snapshot failed: %s", exc)
ret, desc = write_offsets(gpu, deltas)
if ret != 0:
errs.append(f"Curve write failed ({ret}): {desc}")
else: else:
reset_offsets(gpu) reset_offsets(gpu)
@@ -69,9 +75,9 @@ def apply_profile(gpu_index: int, name: str, cfg) -> list[str]:
def apply_with_retry(gpu_index: int, name: str, cfg, max_retries: int = 3) -> bool: def apply_with_retry(gpu_index: int, name: str, cfg, max_retries: int = 3) -> bool:
"""Apply a named profile with read-back verification, retrying on mismatch.""" """Apply a named profile with read-back verification, retrying on mismatch."""
from .native import load_profile
from ..hal.gpu import get_gpu from ..hal.gpu import get_gpu
from ..hal.vfcurve import read_clock_offsets from ..hal.vfcurve import read_clock_offsets
from .native import load_profile
safe_name = "".join(c for c in name if c.isalnum() or c in " _-()").strip() safe_name = "".join(c for c in name if c.isalnum() or c in " _-()").strip()
filepath = os.path.join(cfg.profile_dir, f"{safe_name}.json") filepath = os.path.join(cfg.profile_dir, f"{safe_name}.json")
@@ -82,51 +88,88 @@ def apply_with_retry(gpu_index: int, name: str, cfg, max_retries: int = 3) -> bo
log.warning("Auto-load profile %r not found — skipping GPU %d", name, gpu_index) log.warning("Auto-load profile %r not found — skipping GPU %d", name, gpu_index)
return False return False
expected: dict[int, int] = ( try:
{int(k): v for k, v in profile.curve_deltas.items()} expected: dict[int, int] = (
if profile.curve_deltas else {} {int(k): v for k, v in profile.curve_deltas.items()}
) if profile.curve_deltas
else {}
)
except ValueError:
log.warning(
"Profile %r has invalid curve point keys — skipping GPU %d",
name,
gpu_index,
)
return False
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
errs = apply_profile(gpu_index, name, cfg) errs = apply_profile(gpu_index, name, cfg)
except Exception as exc: except Exception as exc:
log.warning("Auto-load attempt %d/%d exception: %s", attempt + 1, max_retries, exc) log.warning(
"Auto-load attempt %d/%d exception: %s", attempt + 1, max_retries, exc
)
errs = [str(exc)] errs = [str(exc)]
if errs: if errs:
log.warning("Auto-load attempt %d/%d errors: %s", log.warning(
attempt + 1, max_retries, "; ".join(errs)) "Auto-load attempt %d/%d errors: %s",
attempt + 1,
max_retries,
"; ".join(errs),
)
elif expected: elif expected:
gpu, _ = get_gpu(index=gpu_index) gpu, _ = get_gpu(index=gpu_index)
offsets, err = read_clock_offsets(gpu) offsets, err = read_clock_offsets(gpu)
if offsets is None: if offsets is None:
log.warning("Auto-load attempt %d/%d: read-back failed: %s", log.warning(
attempt + 1, max_retries, err) "Auto-load attempt %d/%d: read-back failed: %s",
attempt + 1,
max_retries,
err,
)
else: else:
mismatches = [ mismatches = [
f"pt{idx}: expected {val/1000:+.0f}MHz got {offsets[idx]/1000:+.0f}MHz" f"pt{idx}: expected {val / 1000:+.0f}MHz got {offsets[idx] / 1000:+.0f}MHz"
for idx, val in expected.items() for idx, val in expected.items()
if idx < len(offsets) and offsets[idx] != val if idx < len(offsets) and offsets[idx] != val
] ]
if not mismatches: if not mismatches:
log.info("Auto-load profile %r verified on GPU %d (attempt %d/%d)", log.info(
name, gpu_index, attempt + 1, max_retries) "Auto-load profile %r verified on GPU %d (attempt %d/%d)",
name,
gpu_index,
attempt + 1,
max_retries,
)
return True return True
log.warning("Auto-load attempt %d/%d: read-back mismatch — %s", log.warning(
attempt + 1, max_retries, "; ".join(mismatches)) "Auto-load attempt %d/%d: read-back mismatch — %s",
attempt + 1,
max_retries,
"; ".join(mismatches),
)
else: else:
log.info("Auto-load profile %r applied on GPU %d (attempt %d/%d)", log.info(
name, gpu_index, attempt + 1, max_retries) "Auto-load profile %r applied on GPU %d (attempt %d/%d)",
name,
gpu_index,
attempt + 1,
max_retries,
)
return True return True
if attempt < max_retries - 1: if attempt < max_retries - 1:
delay = 2 ** attempt # 1s, 2s, 4s delay = 2**attempt # 1s, 2s, 4s
log.info("Retrying auto-load in %ds…", delay) log.info("Retrying auto-load in %ds…", delay)
time.sleep(delay) time.sleep(delay)
log.warning("Auto-load profile %r failed after %d attempts on GPU %d", log.warning(
name, max_retries, gpu_index) "Auto-load profile %r failed after %d attempts on GPU %d",
name,
max_retries,
gpu_index,
)
return False return False
@@ -153,13 +196,19 @@ def run_autoload() -> None:
return return
from ..config import Config from ..config import Config
cfg = Config() cfg = Config()
for key in ("max_delta_khz", "auto_snapshot", "max_snapshots", for key in (
"snapshot_dir", "profile_dir"): "max_delta_khz",
"auto_snapshot",
"max_snapshots",
"snapshot_dir",
"profile_dir",
):
if key in cfg_data: if key in cfg_data:
setattr(cfg, key, cfg_data[key]) setattr(cfg, key, cfg_data[key])
from ..hal.gpu import init_nvapi, discover_gpus from ..hal.gpu import discover_gpus, init_nvapi
from ..hal.monitoring import init_nvml, shutdown_nvml from ..hal.monitoring import init_nvml, shutdown_nvml
# Retry NvAPI init — the driver may not be fully ready at early boot. # Retry NvAPI init — the driver may not be fully ready at early boot.
@@ -189,7 +238,9 @@ def run_autoload() -> None:
if gpu_idx is None: if gpu_idx is None:
log.warning("Auto-load: no GPU found with key %r — skipping", gpu_key) log.warning("Auto-load: no GPU found with key %r — skipping", gpu_key)
continue continue
log.info("Auto-loading profile %r on GPU %d (%s)", profile_name, gpu_idx, gpu_key) log.info(
"Auto-loading profile %r on GPU %d (%s)", profile_name, gpu_idx, gpu_key
)
apply_with_retry(gpu_idx, profile_name, cfg) apply_with_retry(gpu_idx, profile_name, cfg)
shutdown_nvml() shutdown_nvml()
+29 -17
View File
@@ -1,39 +1,52 @@
"""Native profile storage and schema.""" """Native profile storage and schema."""
import json
import os
import glob import glob
from dataclasses import dataclass, asdict import json
from typing import Dict, Optional, List import logging
import os
from dataclasses import asdict, dataclass
log = logging.getLogger("nvcurve.profiles.native")
@dataclass @dataclass
class ProfileData: class ProfileData:
name: str name: str
gpu_name: str gpu_name: str
curve_deltas: Dict[str, int] # { "index": delta_khz } curve_deltas: dict[str, int] # { "index": delta_khz }
mem_offset_mhz: Optional[int] = None mem_offset_mhz: int | None = None
power_limit_w: Optional[int] = None power_limit_w: int | None = None
fan_curve: Optional[List[Dict[str, int]]] = None fan_curve: list[dict[str, int]] | None = None
def save_profile(profile_dir: str, data: ProfileData) -> str: def save_profile(profile_dir: str, data: ProfileData) -> str:
"""Save profile to JSON, sanitising the filename.""" """Save profile to JSON, sanitising the filename."""
os.makedirs(profile_dir, exist_ok=True) try:
os.makedirs(profile_dir, exist_ok=True)
except OSError as exc:
raise RuntimeError(f"Cannot create profile dir {profile_dir}: {exc}") from exc
safe_name = "".join(c for c in data.name if c.isalnum() or c in " _-()").strip() safe_name = "".join(c for c in data.name if c.isalnum() or c in " _-()").strip()
if not safe_name: if not safe_name:
safe_name = "Unnamed" safe_name = "Unnamed"
filepath = os.path.join(profile_dir, f"{safe_name}.json") filepath = os.path.join(profile_dir, f"{safe_name}.json")
with open(filepath, "w", encoding="utf-8") as f: try:
json.dump(asdict(data), f, indent=2) with open(filepath, "w", encoding="utf-8") as f:
json.dump(asdict(data), f, indent=2)
except OSError as exc:
raise RuntimeError(f"Cannot write profile {filepath}: {exc}") from exc
return filepath return filepath
def load_profile(filepath: str) -> ProfileData: def load_profile(filepath: str) -> ProfileData:
"""Load profile from JSON.""" """Load profile from JSON."""
with open(filepath, "r", encoding="utf-8") as f: try:
data = json.load(f) with open(filepath, encoding="utf-8") as f:
data = json.load(f)
except FileNotFoundError:
raise
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError(f"Cannot read profile {filepath}: {exc}") from exc
# Migrate old field names. # Migrate old field names.
if "vram_p0_offset_mhz" in data and "mem_offset_mhz" not in data: if "vram_p0_offset_mhz" in data and "mem_offset_mhz" not in data:
data["mem_offset_mhz"] = data.pop("vram_p0_offset_mhz") data["mem_offset_mhz"] = data.pop("vram_p0_offset_mhz")
@@ -43,7 +56,7 @@ def load_profile(filepath: str) -> ProfileData:
return ProfileData(**data) return ProfileData(**data)
def list_profiles(profile_dir: str) -> List[ProfileData]: def list_profiles(profile_dir: str) -> list[ProfileData]:
"""Return a list of all safely readable profiles.""" """Return a list of all safely readable profiles."""
if not os.path.exists(profile_dir): if not os.path.exists(profile_dir):
return [] return []
@@ -51,9 +64,8 @@ def list_profiles(profile_dir: str) -> List[ProfileData]:
for fp in glob.glob(os.path.join(profile_dir, "*.json")): for fp in glob.glob(os.path.join(profile_dir, "*.json")):
try: try:
profiles.append(load_profile(fp)) profiles.append(load_profile(fp))
except Exception as e: except Exception as exc:
# log warning ideally, but swallowing for robustness log.debug("Skipping unreadable profile %s: %s", fp, exc)
pass
# Sort alphabetically by name # Sort alphabetically by name
profiles.sort(key=lambda p: p.name.lower()) profiles.sort(key=lambda p: p.name.lower())
return profiles return profiles
+102 -45
View File
@@ -90,8 +90,8 @@ def _open_browser_as_user(url: str) -> None:
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
return return
except Exception: except Exception as exc:
pass log.debug("runuser xdg-open failed, falling back to webbrowser: %s", exc)
import webbrowser import webbrowser
webbrowser.open(url) webbrowser.open(url)
@@ -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}
@@ -1638,7 +1692,10 @@ def _resolve_dist_dir() -> str:
the project-root layout used during local development. the project-root layout used during local development.
""" """
try: try:
from importlib.resources import files as _resource_files # Project requires Python >= 3.12, so the 3.7-compat finding is a false positive.
from importlib.resources import ( # nosemgrep: python.lang.compatibility.python37.python37-compatibility-importlib2
files as _resource_files,
)
candidate = _resource_files("nvcurve") / "frontend" / "dist" candidate = _resource_files("nvcurve") / "frontend" / "dist"
if candidate.is_dir(): if candidate.is_dir():