Initial import: bare-metal stacks, Server UI, GPU fan, tutorials.

Infrastructure configs for GMKtec K11 (Docker, vLLM, LocalAI, ComfyUI,
control-plane, gpu-fan agent, Server UI with CLI/file explorer/GPU fan curve).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
tomasz-syn-grzegorza
2026-07-05 12:02:04 +00:00
commit 359afb3a59
153 changed files with 18169 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# DEPRECATED — use stacks/control-plane/.env.example
# Copy: cp ../control-plane/.env.example ../control-plane/.env
+4
View File
@@ -0,0 +1,4 @@
.env
.venv/
__pycache__/
*.pyc
+154
View File
@@ -0,0 +1,154 @@
# GPU Fan Control stack
Sterowanie wentylatorami **RTX 3090 Ti** na headless Ubuntu przez NVML — bez `nvidia-settings` i bez GUI.
**Panel webowy** jest w **Server UI** (`http://<host>:8091` → zakładka **GPU Fan**). Ten stack uruchamia tylko **agenta API** na localhost.
## Porty
| Serwis | Port | Dostęp |
|--------|------|--------|
| GPU Fan agent API | **18090** | `127.0.0.1` tylko (systemd, root) |
| GPU Fan UI | — | Server UI **:8091** (zakładka GPU Fan) |
Port **8090** nie jest już używany w produkcji.
## Jak to działa
```mermaid
flowchart LR
browser["Przeglądarka :8091"]
serverUI["server-ui"]
agent["fan_daemon.py :18090"]
nvml["NVML / pynvml"]
gpu["RTX 3090 Ti"]
browser --> serverUI
serverUI -->|"proxy /api/gpu-fan"| agent
agent --> nvml
nvml --> gpu
```
| Element | Opis |
|---------|------|
| Agent | `fan_daemon.py` — pętla NVML + API REST |
| Dev UI | `app.py` — monolit z UI (tylko dev / DRY_RUN) |
| Logika | `fan_controller.py` — interpolacja krzywej |
| Konfiguracja | `/etc/gpu-fan/curve.json` |
| systemd | `gpu-fan.service` (root, auto-restart) |
## Wymagania
- Sterownik NVIDIA ≥ 520 (testowane: **595-server-open**)
- Root / sudo (zapis NVML wymaga uprawnień root)
- Python 3 + venv (instalowane przez `install.sh`)
- **Server UI** na :8091 (proxy do agenta)
## Szybki start
```bash
cd stacks/gpu-fan
cp ../control-plane/.env.example ../control-plane/.env
sudo scripts/install.sh
sudo systemctl start gpu-fan
cd ../server-ui
sudo scripts/install.sh
```
Panel: `http://<IP-serwera>:8091` → zakładka **GPU Fan**.
Po zmianach w kodzie:
```bash
sudo scripts/install.sh && sudo systemctl restart gpu-fan
cd ../server-ui && sudo scripts/install.sh && sudo systemctl restart server-ui
```
Konfiguracja: produkcja `/opt/control-plane/.env`, dev `stacks/control-plane/.env`.
### Klucz API
Jeden wspólny `API_KEY` w `/opt/control-plane/.env` — auth panelu Server UI i agenta gpu-fan (proxy `/api/gpu-fan/*`).
W panelu Server UI wpisz ten sam klucz w polu **API Key** (lub `?api_key=...` w URL).
## Preset max cooling
Domyślna krzywa w [`curve.default.json`](curve.default.json):
| Temp | Speed |
|------|-------|
| 30°C | 50% |
| 40°C | 65% |
| 50°C | 80% |
| 55°C | 90% |
| 60°C | 100% |
| 70°C+ | 100% |
## Tryby
| Tryb | Opis |
|------|------|
| `curve` | Krzywa z JSON — interpolacja liniowa |
| `manual` | Stała prędkość (np. 100% awaryjnie) |
| `auto` | Oddaje sterowanie driverowi NVIDIA |
## API agenta (localhost :18090)
| Endpoint | Metoda | Opis |
|----------|--------|------|
| `/api/status` | GET | Temperatura, wentylatory, moc, tryb |
| `/api/curve` | GET/PUT | Odczyt / zapis krzywej |
| `/api/mode` | POST | `{"mode":"auto\|curve\|manual","speed":100}` |
| `/api/reload` | POST | Przeładuj `curve.json` (jak `SIGHUP`) |
Z LAN używaj proxy Server UI: `/api/gpu-fan/status`, `/api/gpu-fan/curve`, itd.
## Ograniczenia NVIDIA
- `nvidia-smi` **nie steruje** wentylatorami — tylko odczyt
- API akceptuje **0%** (= auto) lub **30100%**
- Po `systemctl stop gpu-fan` wentylatory wracają do trybu auto drivera
## Struktura
```
stacks/gpu-fan/
├── README.md
├── fan_daemon.py # produkcja — agent API
├── app.py # dev — UI + API (opcjonalnie)
├── fan_controller.py
├── curve.default.json
├── requirements.txt
├── gpu-fan.service
├── .env.example
├── static/index.html # referencja UI (wbudowane w server-ui)
└── scripts/
├── install.sh
├── enable-lan.sh # konfiguruje agent + wskazówka :8091
└── status.sh
```
## Troubleshooting
**`Insufficient Permissions`** — uruchom jako root (`sudo systemctl start gpu-fan`).
**Panel GPU Fan pusty / 502** — sprawdź agent: `curl -s http://127.0.0.1:18090/api/status -H "X-API-Key: ..."`
**Port 18090 zajęty**`scripts/status.sh` / `sudo scripts/status.sh --cleanup`
**Przeładuj krzywą bez restartu:**
```bash
sudo systemctl reload gpu-fan
```
## Dokumentacja
| Dokument | Opis |
|----------|------|
| [docs/00-START-TUTAJ.md](docs/00-START-TUTAJ.md) | Mapa — zacznij tutaj |
| [docs/02-OTWIERANIE-UI-W-PRZEGLADARCE.md](docs/02-OTWIERANIE-UI-W-PRZEGLADARCE.md) | Panel w Server UI :8091 |
| [docs/05-DLACZEGO-NIE-DOCKER.md](docs/05-DLACZEGO-NIE-DOCKER.md) | Dlaczego host, nie Docker |
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""GPU fan control web UI + NVML daemon (single process)."""
from __future__ import annotations
import logging
import os
import signal
import sys
import threading
from pathlib import Path
from typing import Any
import uvicorn
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from fan_controller import (
FanControlError,
FanController,
MAX_CURVE_POINTS,
MIN_CURVE_POINTS,
MIN_FAN_SPEED,
MAX_FAN_SPEED,
curve_to_dict,
parse_curve,
)
STACK_DIR = Path(__file__).resolve().parent
# Unified control-plane env (see stacks/control-plane/env_loader.py)
for _cp in ("/opt/control-plane", "/repo/stacks/control-plane", str(STACK_DIR.parent / "control-plane")):
if _cp not in sys.path and Path(_cp).exists():
sys.path.insert(0, _cp)
from env_loader import load_control_plane_env # noqa: E402
STATIC_DIR = STACK_DIR / "static"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
log = logging.getLogger(__name__)
def _is_localhost_bind(host: str) -> bool:
return host.strip().lower() in ("127.0.0.1", "localhost", "::1")
def _resolve_host_port(env: dict[str, str]) -> tuple[str, int]:
host = env.get("GPU_FAN_API_HOST") or env.get("GPU_FAN_HOST", "0.0.0.0")
port_raw = env.get("GPU_FAN_API_PORT") or env.get("GPU_FAN_PORT", "8090")
return host, int(port_raw)
ENV = load_control_plane_env(STACK_DIR)
HOST, PORT = _resolve_host_port(ENV)
API_KEY = ENV.get("API_KEY", "")
CURVE_PATH = Path(ENV.get("CURVE_PATH", "/etc/gpu-fan/curve.json"))
POLL_INTERVAL = float(ENV.get("POLL_INTERVAL", "2.0"))
GPU_INDEX = int(ENV.get("GPU_INDEX", "0"))
DRY_RUN = ENV.get("DRY_RUN", "").lower() in ("1", "true", "yes")
controller = FanController(
curve_path=CURVE_PATH,
gpu_index=GPU_INDEX,
poll_interval=POLL_INTERVAL,
)
controller.dry_run = DRY_RUN
app = FastAPI(title="GPU Fan Control", version="1.0.0")
class CurvePoint(BaseModel):
temp: int = Field(ge=0, le=120)
speed: int = Field(ge=0, le=100)
class CurveUpdate(BaseModel):
points: list[CurvePoint] = Field(min_length=MIN_CURVE_POINTS, max_length=MAX_CURVE_POINTS)
class ModeUpdate(BaseModel):
mode: str
speed: int | None = Field(default=None, ge=MIN_FAN_SPEED, le=MAX_FAN_SPEED)
def require_auth(request: Request) -> None:
if not API_KEY:
return
key = request.headers.get("X-API-Key", "")
if key != API_KEY:
raise HTTPException(status_code=401, detail="Invalid or missing API key")
@app.get("/")
def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
@app.get("/api/status")
def api_status(_: None = Depends(require_auth)) -> dict[str, Any]:
return controller.get_metrics()
@app.get("/api/curve")
def api_get_curve(_: None = Depends(require_auth)) -> dict[str, Any]:
points = controller.get_curve()
return {"points": [{"temp": t, "speed": s} for t, s in points]}
@app.put("/api/curve")
def api_put_curve(body: CurveUpdate, _: None = Depends(require_auth)) -> dict[str, Any]:
points = [(p.temp, p.speed) for p in body.points]
try:
parse_curve(curve_to_dict(points))
controller.save_curve_file(points)
controller.set_mode("curve")
except FanControlError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "curve": curve_to_dict(controller.get_curve())}
@app.post("/api/mode")
def api_set_mode(body: ModeUpdate, _: None = Depends(require_auth)) -> dict[str, Any]:
try:
controller.set_mode(body.mode, body.speed)
if body.mode != "auto":
controller.update_once()
except FanControlError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "mode": controller.mode, "manual_speed": controller.manual_speed}
@app.post("/api/reload")
def api_reload(_: None = Depends(require_auth)) -> dict[str, Any]:
try:
controller.reload_curve()
except FanControlError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "curve": curve_to_dict(controller.get_curve())}
def run_daemon_thread() -> None:
controller.run_loop()
def main() -> None:
if os.geteuid() != 0 and not DRY_RUN:
log.error("GPU fan control requires root (NVML write access). Run with sudo.")
sys.exit(1)
if not _is_localhost_bind(HOST) and not API_KEY:
log.error(
"API_KEY is required when GPU_FAN_HOST=%s (LAN/public bind). "
"Set API_KEY in .env",
HOST,
)
sys.exit(1)
controller.init_nvml()
def shutdown_handler(signum: int, _frame: object) -> None:
log.info("Received signal %s", signum)
if signum == signal.SIGHUP:
try:
controller.reload_curve()
except FanControlError as exc:
log.error("Curve reload failed: %s", exc)
return
controller.shutdown()
sys.exit(0)
signal.signal(signal.SIGTERM, shutdown_handler)
signal.signal(signal.SIGINT, shutdown_handler)
signal.signal(signal.SIGHUP, shutdown_handler)
thread = threading.Thread(target=run_daemon_thread, daemon=True)
thread.start()
if _is_localhost_bind(HOST):
log.info("Web UI at http://127.0.0.1:%d", PORT)
else:
log.info("Web UI listening on 0.0.0.0:%d (LAN — API key required)", PORT)
uvicorn.run(app, host=HOST, port=PORT, log_level="info")
if __name__ == "__main__":
if os.environ.get("GPU_FAN_LEGACY_UI", "").lower() in ("1", "true", "yes"):
main()
else:
from fan_daemon import main as daemon_main
daemon_main()
@@ -0,0 +1,228 @@
# GPU Fan — Docker vs host (raport techniczny)
**Data:** 2026-07-04
**Stack:** `stacks/gpu-fan/`
**Host docelowy:** gmktec-k11, RTX 3090 Ti, Ubuntu headless
---
## 1. Executive summary
**GPU Fan musi działać na hoście jako usługa systemd (root), nie w Dockerze.**
Aplikacja steruje wentylatorami karty NVIDIA przez zapis do NVML (`nvmlDeviceSetFanSpeed_v2`, `nvmlDeviceSetFanControlPolicy`). Na kartach GeForce wymaga to uprawnień root i bezpośredniego dostępu do sterownika hosta. Repo nie zawiera Dockerfile ani compose dla tego stacku — jedyny wspierany model to `sudo scripts/install.sh``/opt/gpu-fan` + `gpu-fan.service`.
Docker jest teoretycznie możliwy (privileged container, host network, mount `/dev/nvidia*`), ale kruchy, nieutrzymywany i niezgodny z architekturą ubuntu-bare-metal (gpu-fan jako daemon sprzętowy obok workloadów AI w kontenerach).
---
## 2. Co robi aplikacja
| Komponent | Plik | Rola |
|-----------|------|------|
| Pętla sterowania | `fan_controller.py` | Odczyt temp/mocy, interpolacja krzywej, zapis prędkości wentylatorów |
| Web UI + API | `app.py` | FastAPI na porcie **8090**, wątek daemon NVML |
| UI statyczne | `static/index.html` | Wykres krzywej, status live, edycja trybu |
| Krzywa | `/etc/gpu-fan/curve.json` | Mapowanie temp °C → speed % |
### Tryby pracy
| Tryb | Zachowanie |
|------|------------|
| `curve` | Prędkość z krzywej JSON (interpolacja liniowa, 37 punktów) |
| `manual` | Stała prędkość 30100% |
| `auto` | Przywraca politykę drivera NVIDIA (`NVML_FAN_POLICY_TEMPERATURE_CONTINOUS_SW`) |
### API (port 8090)
| Endpoint | Metoda | Uwagi |
|----------|--------|-------|
| `/` | GET | Web UI |
| `/api/status` | GET | Metryki GPU + tryb |
| `/api/curve` | GET/PUT | Odczyt/zapis krzywej |
| `/api/mode` | POST | Zmiana trybu |
| `/api/reload` | POST | Przeładowanie `curve.json` (jak SIGHUP) |
Nagłówek `X-API-Key` wymagany gdy `GPU_FAN_HOST` ≠ localhost (domyślnie LAN bind `0.0.0.0`).
### Shutdown
Przy `SIGTERM` / `SIGINT` kontroler wywołuje `_restore_auto_policy()` przed `nvmlShutdown()` — wentylatory nie zostają w trybie manual po zatrzymaniu usługi.
---
## 3. Zależności sprzętowe i software
| Zależność | Wymagana | Uwagi |
|-----------|----------|-------|
| NVIDIA driver ≥ 520 | Tak | Testowane: 595-server-open |
| `nvidia-ml-py` (pynvml) | Tak | Jedyny interfejs sterowania w kodzie |
| `nvidia-smi` | Nie w kodzie | Tylko weryfikacja w dokumentacji; **nie ustawia** wentylatorów |
| Root (euid 0) | Tak | `app.py` kończy się błędem bez root (chyba że `DRY_RUN=true`) |
| `nvidia-persistenced` | Zalecane | `gpu-fan.service` After=/Wants= |
| IPMI | Nie | Brak referencji w kodzie |
| D-Bus | Nie | Brak referencji |
| X11 / nvidia-settings | Nie | Headless — celowo unikane |
| Python 3 + venv | Tak | FastAPI, uvicorn |
### Ścieżki produkcyjne
| Ścieżka | Zawartość |
|---------|-----------|
| `/opt/gpu-fan/` | Kod aplikacji (rsync z repo przez `install.sh`) |
| `/opt/control-plane/.env` | `API_KEY`, `GPU_FAN_API_*`, `CURVE_PATH`, … |
| `/etc/gpu-fan/curve.json` | Krzywa temp → speed |
| `/etc/systemd/system/gpu-fan.service` | Unit systemd |
**Uwaga:** `stacks/control-plane/.env` w repo ≠ `/opt/control-plane/.env``setup-control-plane-env.sh` migruje i synchronizuje.
---
## 4. Obecny model wdrożenia
```
repo stacks/gpu-fan/
│ sudo scripts/install.sh
/opt/gpu-fan/ ← kod + .venv + .env
/etc/gpu-fan/curve.json
/etc/systemd/system/gpu-fan.service
│ systemctl enable --now gpu-fan
Proces root: python app.py
├── wątek: fan_controller.run_loop() (co POLL_INTERVAL s)
└── uvicorn: 0.0.0.0:8090
```
Skrypty pomocnicze:
| Skrypt | Cel |
|--------|-----|
| `scripts/install.sh` | Instalacja produkcyjna |
| `scripts/enable-lan.sh` | `GPU_FAN_HOST=0.0.0.0`, API_KEY, restart |
| `scripts/start.sh` | Foreground debug (wymaga stop systemd) |
| `scripts/status.sh` | Diagnostyka portu/procesu |
| `scripts/self-test.sh` | Test krzywej, NVML read, API dry-run |
---
## 5. Analiza Docker — dlaczego nie
### Brak artefaktów w repo
- Brak `Dockerfile`, `compose.yaml`, profilu w `server-ui/stacks.yaml`
- Inne stacki GPU (ComfyUI, LocalAI, vLLM) używają Docker; gpu-fan jest wyjątkiem celowym
### Blokery techniczne
| Bloker | Szczegóły |
|--------|-----------|
| NVML write na GeForce | `nvmlDeviceSetFanSpeed_v2` wymaga root; kontenery GPU (`NVIDIA_DRIVER_CAPABILITIES=compute,utility`) nie gwarantują zapisu fan policy |
| Coupling do host driver | Wersja NVML w kontenerze musi pasować do kernel drivera hosta |
| Lifecycle | `docker kill` / crash kontenera może pominąć `_restore_auto_policy()` → wentylatory w manual |
| `nvidia-persistenced` | Daemon na hoście; kontener nie zarządza persystencją GPU |
| Privileged + host network | Minimalny „Docker” wyglądałby jak host install z dodatkową warstwą — bez korzyści |
### Hipotetyczny kontener (nie implementować)
Gdyby ktoś eksperymentował:
```yaml
# NIE WDRAŻAĆ — tylko dokumentacja ryzyka
privileged: true
network_mode: host
user: root
pid: host # opcjonalnie, nadal ryzykowne
volumes:
- /etc/gpu-fan:/etc/gpu-fan
devices:
- /dev/nvidia0
- /dev/nvidiactl
- /dev/nvidia-uvm
```
Nawet wtedy sukces nie jest gwarantowany na RTX 3090 Ti; repo nie będzie tego utrzymywać.
---
## 6. Współistnienie z Docker AI stacks
```
┌─────────────────────────────────────────────────┐
│ Host (gmktec-k11) │
│ │
│ gpu-fan.service (root, :8090) ──NVML──► GPU │
│ │
│ ┌─────────────┐ ┌──────────┐ ┌──────────┐ │
│ │ comfyui │ │ localai │ │ vllm │ │
│ │ :8188 │ │ :8070 │ │ :8000 │ │
│ └─────────────┘ └──────────┘ └──────────┘ │
│ Docker containers (GPU compute) │
└─────────────────────────────────────────────────┘
```
- ComfyUI/LocalAI obciążają GPU → temperatura rośnie → gpu-fan reaguje krzywą
- Zatrzymaj gpu-fan **nie** jest wymagane przed startem kontenerów AI
- Polityka GPU w Server UI (jeden duży workload) dotyczy LLM/SD, nie gpu-fan
- Firewall (NPMPlus): port 8090 nie powinien być publiczny; LAN + API key
Źródło: `manual-tutorial/06-gpu-fan-control.md` — „gpu-fan.service na hoście (NVML, root)”.
---
## 7. Checklist operacyjny (coding-agent)
### Instalacja / upgrade
- [ ] `nvidia-smi` działa
- [ ] `sudo scripts/install.sh` z katalogu `stacks/gpu-fan`
- [ ] `sudo scripts/enable-lan.sh` jeśli dostęp z LAN
- [ ] `sudo systemctl enable --now gpu-fan`
- [ ] `curl -s http://127.0.0.1:18090/api/status -H "X-API-Key: $(grep ^API_KEY= /opt/control-plane/.env | cut -d= -f2)"` → JSON z `temperature_c`
### Po zmianie kodu
```bash
sudo scripts/install.sh && sudo systemctl restart gpu-fan
```
### Diagnostyka
```bash
systemctl status gpu-fan
journalctl -u gpu-fan -f
scripts/status.sh
sudo scripts/status.sh --cleanup # tylko gdy port zajęty przez osierocony proces
```
### Czego nie robić
- Nie uruchamiać `start.sh` i systemd jednocześnie (port 8090)
- Nie pakować gpu-fan do Docker bez nowego ADR i testów na sprzęcie
- Nie edytować tylko `stacks/control-plane/.env` — produkcja czyta `/opt/control-plane/.env`
---
## 8. Rekomendacja
| Decyzja | Uzasadnienie |
|---------|--------------|
| **Zostaw na hoście (systemd)** | Wspierane, przetestowane, bezpieczny shutdown, zgodne z tutorial 06 |
| **Nie dodawaj Docker** | Brak wartości, wysokie ryzyko, duplikacja root access |
| **Dokumentacja użytkownika** | `docs/` — kroki instalacji i troubleshooting |
| **Ten raport** | `coding-agent/DOCKER-VS-HOST-REPORT.md` — odniesienie dla agentów |
---
## 9. Pliki źródłowe (indeks)
| Plik | Kluczowe fragmenty |
|------|-------------------|
| `app.py:161-164` | Wymóg root |
| `fan_controller.py:250-279` | NVML fan policy + speed write |
| `fan_controller.py:335-343` | Shutdown → auto policy |
| `gpu-fan.service` | User=root, After=nvidia-persistenced |
| `scripts/install.sh` | rsync → /opt/gpu-fan |
| `requirements.txt` | fastapi, uvicorn, nvidia-ml-py |
+8
View File
@@ -0,0 +1,8 @@
{
"30": 50,
"40": 65,
"50": 80,
"55": 90,
"60": 100,
"70": 100
}
+8
View File
@@ -0,0 +1,8 @@
{
"30": 50,
"40": 65,
"50": 80,
"55": 90,
"60": 100,
"70": 100
}
+56
View File
@@ -0,0 +1,56 @@
# GPU Fan — START TUTAJ
**Cel:** Wiedzieć od czego zacząć i czy ta aplikacja idzie do Dockera.
**Czas:** 2 minuty czytania
**Wymagania:** Brak (to tylko mapa dokumentacji)
---
## Jednozdaniowa odpowiedź
**GPU Fan NIE działa w Dockerze — instalujesz go na hoście (systemd), raz, i zapominasz.**
Steruje wentylatorami karty graficznej. Reszta (ComfyUI, LocalAI) może być w Dockerze — to osobne programy.
---
## Mapa dokumentacji
| Plik | Kiedy czytać |
|------|----------------|
| [01-INSTALACJA-KROK-PO-KROKU.md](01-INSTALACJA-KROK-PO-KROKU.md) | Pierwsza instalacja na serwerze |
| [02-OTWIERANIE-UI-W-PRZEGLADARCE.md](02-OTWIERANIE-UI-W-PRZEGLADARCE.md) | Chcesz otworzyć panel w przeglądarce |
| [03-KRZYWa-I-TRYBY.md](03-KRZYWa-I-TRYBY.md) | Chcesz zmienić chłodzenie / tryb wentylatorów |
| [04-CZESTE-BLEDY.md](04-CZESTE-BLEDY.md) | Coś nie działa |
| [05-DLACZEGO-NIE-DOCKER.md](05-DLACZEGO-NIE-DOCKER.md) | „A czemu nie w kontenerze?” |
Dla agentów AI / deweloperów: [../coding-agent/DOCKER-VS-HOST-REPORT.md](../coding-agent/DOCKER-VS-HOST-REPORT.md)
---
## Szybka ścieżka (jeśli się spieszysz)
```bash
cd ~/cursor/ubuntu-bare-metal/stacks/gpu-fan
nvidia-smi # musi pokazać kartę — jeśli nie, najpierw napraw driver
sudo scripts/install.sh
sudo scripts/enable-lan.sh
sudo systemctl start gpu-fan
sudo systemctl status gpu-fan
```
Potem: [02-OTWIERANIE-UI-W-PRZEGLADARCE.md](02-OTWIERANIE-UI-W-PRZEGLADARCE.md)
---
## Co zobaczysz gdy działa
- `systemctl status gpu-fan`**active (running)**
- W przeglądarce: panel z temperaturą GPU i wykresem krzywej (port **8090**)
- Wentylatory reagują na temperaturę (w trybie **curve**)
---
## Co zrobić gdy nie działa
→ [04-CZESTE-BLEDY.md](04-CZESTE-BLEDY.md)
@@ -0,0 +1,127 @@
# Instalacja GPU Fan — krok po kroku
**Cel:** Zainstalować sterowanie wentylatorami GPU na serwerze.
**Czas:** ~10 minut
**Wymagania:** `sudo`, działający `nvidia-smi`, internet (pip)
---
## Krok 0 — Sprawdź kartę graficzną
Na serwerze wpisz:
```bash
nvidia-smi
```
**Co zobaczysz gdy OK:** tabela z nazwą karty (np. RTX 3090 Ti), driverem, temperaturą.
**Gdy nie działa:** najpierw napraw sterownik NVIDIA. Bez tego GPU Fan nie ma sensu.
---
## Krok 1 — Wejdź do katalogu stacku
```bash
cd ~/cursor/ubuntu-bare-metal/stacks/gpu-fan
```
(Ścieżka może być inna — ważne żebyś był w folderze z plikiem `app.py`.)
---
## Krok 2 — Zainstaluj na hoście (NIE Docker)
```bash
sudo scripts/install.sh
```
Skrypt:
- kopiuje pliki do `/opt/gpu-fan`
- tworzy `/etc/gpu-fan/curve.json` (domyślna krzywa chłodzenia)
- tworzy `/opt/control-plane/.env` z losowym `API_KEY` (przy pierwszej instalacji)
- instaluje usługę systemd `gpu-fan`
**Co zobaczysz gdy OK:** komunikat „Installed to /opt/gpu-fan” i instrukcja startu.
---
## Krok 3 — Włącz dostęp z sieci lokalnej (LAN)
```bash
sudo scripts/enable-lan.sh
```
Ustawia nasłuch na `0.0.0.0:8090` i upewnia się że jest `API_KEY`.
---
## Krok 4 — Uruchom usługę
```bash
sudo systemctl start gpu-fan
sudo systemctl enable gpu-fan
```
`enable` = start automatyczny po restarcie serwera.
---
## Krok 5 — Sprawdź czy działa
```bash
sudo systemctl status gpu-fan
```
**Co zobaczysz gdy OK:** `Active: active (running)` na zielono.
Logi na żywo:
```bash
journalctl -u gpu-fan -f
```
(Wyjdź: Ctrl+C)
Test API (skopiuj całość):
```bash
API_KEY=$(grep ^API_KEY= /opt/control-plane/.env | cut -d= -f2)
curl -s http://127.0.0.1:8090/api/status -H "X-API-Key: $API_KEY" | head -c 200
```
**Co zobaczysz gdy OK:** JSON z `"temperature_c"` i `"mode"`.
---
## Krok 6 — Otwórz panel w przeglądarce
→ [02-OTWIERANIE-UI-W-PRZEGLADARCE.md](02-OTWIERANIE-UI-W-PRZEGLADARCE.md)
---
## Po aktualizacji kodu w repo
Jeśli zmieniłeś pliki w `stacks/gpu-fan/`:
```bash
cd ~/cursor/ubuntu-bare-metal/stacks/gpu-fan
sudo scripts/install.sh
sudo systemctl restart gpu-fan
```
---
## Czego NIE robić
| Nie rób tego | Dlaczego |
|--------------|----------|
| `sudo scripts/start.sh` + `systemctl start gpu-fan` naraz | Dwa programy na porcie 8090 — błąd |
| Instalacja w Dockerze | Nie wspierane — patrz [05-DLACZEGO-NIE-DOCKER.md](05-DLACZEGO-NIE-DOCKER.md) |
| Edycja tylko `stacks/control-plane/.env` | Produkcja czyta `/opt/control-plane/.env` |
---
## Co zrobić gdy nie działa
→ [04-CZESTE-BLEDY.md](04-CZESTE-BLEDY.md)
@@ -0,0 +1,100 @@
# Otwieranie panelu GPU Fan w przeglądarce
**Cel:** Wejść do panelu sterowania wentylatorami GPU z komputera w sieci LAN.
**Czas:** ~3 minuty
**Wymagania:** Działające usługi `gpu-fan` (agent) i `server-ui` (panel)
> UI gpu-fan **nie** jest już na porcie 8090. Użyj **Server UI** na porcie **8091**, zakładka **GPU Fan**.
---
## Krok 1 — Sprawdź IP serwera
Na serwerze:
```bash
hostname -I | awk '{print $1}'
```
Przykład wyniku: `192.168.100.90` — to Twój adres w LAN.
---
## Krok 2 — Pobierz klucz API
Na serwerze:
```bash
grep ^API_KEY= /opt/control-plane/.env
# lub ten sam klucz z agenta:
grep ^API_KEY= /opt/control-plane/.env
```
Przykład: `API_KEY=a1b2c3d4e5f6...` — skopiuj część **po** znaku `=`.
---
## Krok 3 — Otwórz URL w przeglądarce
Z innego komputera w tej samej sieci WiFi/LAN:
```
http://192.168.100.90:8091/?api_key=WKLEJ_TUTAJ_KLUCZ#gpu-fan
```
Zamień:
- `192.168.100.90` → IP z kroku 1
- `WKLEJ_TUTAJ_KLUCZ` → wartość z kroku 2
Kliknij zakładkę **GPU Fan** (lub użyj `#gpu-fan` w URL).
**Co zobaczysz gdy OK:** wykres krzywej wentylatorów, temperatura GPU, monitoring mocy/VRAM.
Klucz zapisze się w przeglądarce (localStorage `server-ui-api-key`) — przy kolejnych wizytach wystarczy `http://IP:8091/#gpu-fan`.
---
## Dostęp tylko z samego serwera
```
http://127.0.0.1:8091/#gpu-fan
```
(API key może być wymagany gdy `SERVER_UI_HOST=0.0.0.0`.)
---
## Dostęp przez SSH (opcjonalnie)
Na **swoim** laptopie:
```bash
ssh -L 8091:127.0.0.1:8091 TWOJ_USER@192.168.100.90
```
W przeglądarce na laptopie: `http://localhost:8091/#gpu-fan`
---
## Co zrobić gdy nie działa
| Objaw | Co zrobić |
|-------|-----------|
| Strona się nie ładuje | `sudo systemctl status server-ui` — czy **running**? |
| Zakładka GPU Fan pusta / błąd 502 | `sudo systemctl status gpu-fan` — agent na :18090 |
| `401` / brak danych | Zły klucz API — sprawdź `/opt/control-plane/.env` |
| Agent nie odpowiada | `curl -s http://127.0.0.1:18090/api/status -H "X-API-Key: KLUCZ"` |
Test proxy:
```bash
curl -s http://127.0.0.1:8091/api/gpu-fan/health -H "X-API-Key: $(grep ^API_KEY= /opt/control-plane/.env | cut -d= -f2)"
```
---
## Bezpieczeństwo (krótko)
- Panel **nie** wystawiaj na internet bez firewall / VPN.
- W LAN używaj `API_KEY` w Server UI.
- Agent gpu-fan (:18090) nasłuchuje tylko na localhost.
+112
View File
@@ -0,0 +1,112 @@
# Krzywa temperatury i tryby wentylatorów
**Cel:** Zrozumieć jak GPU Fan reguluje wentylatory i kiedy zmienić tryb.
**Czas:** ~5 minut
**Wymagania:** Działający panel (patrz [02-OTWIERANIE-UI-W-PRZEGLADARCE.md](02-OTWIERANIE-UI-W-PRZEGLADARCE.md))
---
## Trzy tryby — który wybrać
| Tryb | Kiedy używać | Co robi |
|------|--------------|---------|
| **curve** | Normalna praca (domyślny) | Im wyższa temp GPU, tym szybsze wentylatory — według krzywej |
| **manual** | Awaryjnie: „na full” lub stała prędkość | Ustawiasz np. 100% ręcznie |
| **auto** | Chcesz oddać sterowanie driverowi NVIDIA | Jak bez GPU Fan — driver sam decyduje |
**Rekomendacja:** zostaw **curve** przy obciążeniu AI (ComfyUI, LocalAI). Użyj **manual 100%** tylko gdy karta się przegrzewa i chcesz na chwilę max chłodzenia.
---
## Jak działa krzywa (curve)
Plik: `/etc/gpu-fan/curve.json`
Przykład (uproszczony):
```json
{
"30": 50,
"40": 65,
"50": 80,
"55": 90,
"60": 100,
"70": 100
}
```
Znaczenie: przy **50°C** wentylatory ~**80%**, przy **60°C** i wyżej → **100%**.
Między punktami program **interpoluje** (płynna zmiana).
### Zasady (ważne)
- **3 do 7** punktów
- Temperatury **rosnąco**, każda **unikalna**
- Prędkość: **0** (= auto w API) albo **30100%** (wartości 129 są niedozwolone)
---
## Domyślna krzywa „max cooling”
Po instalacji masz agresywne chłodzenie (bezpieczne dla RTX 3090 Ti pod obciążeniem):
| Temp GPU | Wentylatory |
|----------|-------------|
| 30°C | 50% |
| 40°C | 65% |
| 50°C | 80% |
| 55°C | 90% |
| 60°C+ | 100% |
Źródło: `curve.default.json` w repo → kopiowane do `/etc/gpu-fan/curve.json`.
---
## Edycja w panelu web
1. Otwórz UI (port 8090)
2. Zmień punkty na wykresie / w tabeli
3. Kliknij **Zapisz** — zapisuje do `/etc/gpu-fan/curve.json` i włącza tryb **curve**
---
## Edycja z terminala
```bash
sudo nano /etc/gpu-fan/curve.json
sudo systemctl reload gpu-fan
```
`reload` przeładowuje plik bez pełnego restartu.
---
## Co zobaczysz w panelu
- **Temperatura** — aktualna temp GPU
- **Fan speeds** — odczyt z karty (%)
- **Target** — co program próbuje ustawić (w trybie curve/manual)
- **Mode** — curve / manual / auto
---
## Po zatrzymaniu usługi
```bash
sudo systemctl stop gpu-fan
```
Wentylatory wracają do trybu **auto** drivera NVIDIA — to zamierzone (bezpieczeństwo).
---
## Co zrobić gdy wentylatory „dziwnie” się zachowują
| Sytuacja | Wyjaśnienie |
|----------|-------------|
| 0% przy niskiej temp w trybie **auto** | Normalne — karta wyłącza wentylatory przy idle |
| Głośno od razu w **curve** | Domyślna krzywa jest agresywna — obniż prędkości w JSON |
| Brak reakcji | Sprawdź tryb — czy na pewno **curve**, nie **auto** |
Więcej: [04-CZESTE-BLEDY.md](04-CZESTE-BLEDY.md)
+163
View File
@@ -0,0 +1,163 @@
# Częste błędy GPU Fan
**Cel:** Naprawić typowe problemy bez zgadywania.
**Czas:** zależy od problemu (215 min)
**Wymagania:** Dostęp SSH do serwera, `sudo`
---
## Szybka diagnostyka (zrób to najpierw)
```bash
sudo systemctl status gpu-fan
nvidia-smi
scripts/status.sh
```
Skopiuj wynik jeśli dalej nie działa.
---
## Błąd: `Insufficient Permissions` w logach
**Przyczyna:** Program nie działa jako root.
**Naprawa:**
```bash
sudo systemctl restart gpu-fan
sudo systemctl status gpu-fan
```
Nie uruchamiaj `python app.py` bez sudo (chyba że `DRY_RUN=true` tylko do testów).
---
## Błąd: `address already in use` / port 8090 zajęty
**Przyczyna:** Dwie kopie programu naraz (najczęściej systemd + `scripts/start.sh`).
**Naprawa:**
```bash
sudo systemctl stop gpu-fan
sudo scripts/status.sh --cleanup
sudo systemctl start gpu-fan
```
**Nie zmieniaj portu na 8091** — to maskuje problem, nie go rozwiązuje.
---
## Błąd: Strona w przeglądarce się nie ładuje
| Sprawdź | Komenda |
|---------|---------|
| Usługa działa? | `sudo systemctl status gpu-fan` |
| Port nasłuchuje? | `ss -tlnp \| grep 8090` |
| LAN włączony? | `grep GPU_FAN_HOST /opt/control-plane/.env` → powinno być `0.0.0.0` |
| Firewall? | Upewnij się że LAN może dojść do 8090 |
Włącz LAN:
```bash
sudo scripts/enable-lan.sh
```
---
## Błąd: `401` / Invalid API key
**Przyczyna:** Zły klucz w URL lub brak nagłówka.
**Naprawa:**
```bash
grep ^API_KEY= /opt/control-plane/.env
```
Użyj w przeglądarce:
```
http://IP_SERWERA:8090/?api_key=KLUCZ_Z_PLIKU
```
Patrz: [02-OTWIERANIE-UI-W-PRZEGLADARCE.md](02-OTWIERANIE-UI-W-PRZEGLADARCE.md)
---
## Błąd: Zmieniam `.env` w repo i nic się nie dzieje
**Przyczyna:** Produkcja czyta **`/opt/control-plane/.env`**, nie `stacks/control-plane/.env`.
**Naprawa:**
```bash
sudo nano /opt/control-plane/.env
sudo systemctl restart gpu-fan
```
Albo pełna reinstalacja kodu (nie nadpisuje istniejącego `.env`):
```bash
sudo scripts/install.sh
```
---
## Błąd: `nvidia-smi` nie działa
**Przyczyna:** Brak / zły sterownik NVIDIA.
GPU Fan **nie naprawi** drivera. Najpierw napraw GPU w systemie, potem wróć do instalacji.
---
## Błąd: Po Ctrl+Z w terminalu port zajęty
**Przyczyna:** Proces zawieszony w tle nadal trzyma port.
**Naprawa:**
```bash
jobs -l # zobacz zawieszone
kill %1 # numer z jobs
# albo:
sudo scripts/status.sh --cleanup
```
---
## Błąd: Krzywa się nie zapisuje
Sprawdź format JSON — 37 punktów, temp unikalne, speed 0 lub 30100.
```bash
sudo cat /etc/gpu-fan/curve.json
journalctl -u gpu-fan -n 30
```
---
## Błąd: Chcę uruchomić w Dockerze
**Odpowiedź:** Nie rób tego. Patrz [05-DLACZEGO-NIE-DOCKER.md](05-DLACZEGO-NIE-DOCKER.md).
---
## Pełny reset (ostateczność)
```bash
sudo systemctl stop gpu-fan
sudo scripts/status.sh --cleanup
cd ~/cursor/ubuntu-bare-metal/stacks/gpu-fan
sudo scripts/install.sh
sudo scripts/enable-lan.sh
sudo systemctl start gpu-fan
```
Logi:
```bash
journalctl -u gpu-fan -f
```
@@ -0,0 +1,78 @@
# Dlaczego GPU Fan NIE jest w Dockerze
**Cel:** Zrozumieć dlaczego instalujemy na hoście, a nie jak ComfyUI.
**Czas:** 3 minuty
**Wymagania:** Brak
---
## Krótka odpowiedź
GPU Fan **musi** siedzieć na hoście obok sterownika NVIDIA. W Dockerze **nie ma** gotowego obrazu w tym repo i **nie planujemy** go dodawać.
ComfyUI / LocalAI = programy w kontenerach (obliczenia AI).
GPU Fan = „termometr + regulator wentylatorów” na poziomie sprzętu.
---
## Analogia
| Co | Gdzie | Dlaczego |
|----|-------|----------|
| Aplikacja w przeglądarce | Docker OK | Nie dotyka wentylatorów bezpośrednio |
| Sterownik wentylatorów karty | Host (root) | Tylko system z uprawnieniami root może pisać do NVML |
To jak termostat w kotłowni — nie wkładasz go do pudełka z aplikacją webową w innym pokoju.
---
## Co by się stało w Dockerze (gdyby ktoś próbował)
1. Kontener musiałby być **privileged** i **root**
2. I tak często dostaniesz **Insufficient Permissions** na kartach GeForce
3. Po awaryjnym `docker kill` wentylatory mogłyby zostać w trybie ręcznym
4. Zero korzyści — i tak potrzebujesz tego samego sterownika na hoście
Dlatego w repo jest tylko:
```bash
sudo scripts/install.sh
sudo systemctl start gpu-fan
```
---
## Jak to wygląda na serwerze (poprawnie)
```
HOST (Ubuntu)
├── gpu-fan.service ← steruje wentylatorami (port 8090)
├── sterownik NVIDIA
└── Docker
├── comfyui :8188
├── localai :8070
└── vllm :8000
```
Wszystko może działać **równocześnie**. GPU Fan nie koliduje z kontenerami AI.
---
## „Ale inne stacki są w Dockerze!”
Tak — bo tam Docker **ma sens** (izolacja aplikacji, GPU do inference).
GPU Fan to **daemon sprzętowy** — jak `nvidia-persistenced`, nie jak aplikacja webowa.
---
## Gdzie jest pełna analiza techniczna
Dla agentów / deweloperów:
[../coding-agent/DOCKER-VS-HOST-REPORT.md](../coding-agent/DOCKER-VS-HOST-REPORT.md)
---
## Co robić zamiast Dockera
→ [01-INSTALACJA-KROK-PO-KROKU.md](01-INSTALACJA-KROK-PO-KROKU.md)
+358
View File
@@ -0,0 +1,358 @@
"""NVML fan control with JSON curve, modes, and graceful shutdown."""
from __future__ import annotations
import json
import logging
import signal
import threading
import time
from pathlib import Path
from typing import Any
import pynvml
log = logging.getLogger(__name__)
MIN_CURVE_POINTS = 3
MAX_CURVE_POINTS = 7
MIN_FAN_SPEED = 30
MAX_FAN_SPEED = 100
class FanControlError(Exception):
pass
def _clamp_speed(speed: int) -> int:
if speed == 0:
return 0
return max(MIN_FAN_SPEED, min(MAX_FAN_SPEED, speed))
def parse_curve(raw: dict[str, Any]) -> list[tuple[int, int]]:
if not raw:
raise FanControlError("Curve is empty")
points: list[tuple[int, int]] = []
for temp_str, speed in raw.items():
try:
temp = int(temp_str)
speed_int = int(speed)
except (TypeError, ValueError) as exc:
raise FanControlError(f"Invalid curve point {temp_str!r}: {speed!r}") from exc
if not 0 <= temp <= 120:
raise FanControlError(f"Temperature {temp} out of range 0-120")
if speed_int != 0 and not MIN_FAN_SPEED <= speed_int <= MAX_FAN_SPEED:
raise FanControlError(
f"Fan speed {speed_int}% invalid (use 0 or {MIN_FAN_SPEED}-{MAX_FAN_SPEED})"
)
points.append((temp, speed_int))
points.sort(key=lambda p: p[0])
temps = [p[0] for p in points]
if len(set(temps)) != len(temps):
raise FanControlError("Temperatures must be unique")
if len(points) < MIN_CURVE_POINTS or len(points) > MAX_CURVE_POINTS:
raise FanControlError(
f"Curve must have {MIN_CURVE_POINTS}-{MAX_CURVE_POINTS} points"
)
return points
def curve_to_dict(points: list[tuple[int, int]]) -> dict[str, int]:
return {str(temp): speed for temp, speed in points}
def interpolate_speed(temp: int, curve: list[tuple[int, int]]) -> int:
if temp <= curve[0][0]:
return curve[0][1]
if temp >= curve[-1][0]:
return curve[-1][1]
for i in range(len(curve) - 1):
t1, s1 = curve[i]
t2, s2 = curve[i + 1]
if t1 <= temp <= t2:
if t2 == t1:
return s2
ratio = (temp - t1) / (t2 - t1)
return int(s1 + ratio * (s2 - s1))
return curve[-1][1]
class FanController:
def __init__(
self,
curve_path: Path,
gpu_index: int = 0,
poll_interval: float = 2.0,
) -> None:
self.curve_path = curve_path
self.gpu_index = gpu_index
self.poll_interval = poll_interval
self._lock = threading.Lock()
self._running = False
self._mode = "curve"
self._manual_speed = 100
self._curve: list[tuple[int, int]] = []
self._handle: Any = None
self._fan_count = 0
self._gpu_name = ""
self._last_metrics: dict[str, Any] = {}
self._auto_active = False
self.dry_run = False
@property
def mode(self) -> str:
with self._lock:
return self._mode
@property
def manual_speed(self) -> int:
with self._lock:
return self._manual_speed
def load_curve_file(self) -> list[tuple[int, int]]:
if not self.curve_path.exists():
raise FanControlError(f"Curve file not found: {self.curve_path}")
raw = json.loads(self.curve_path.read_text(encoding="utf-8"))
return parse_curve(raw)
def save_curve_file(self, points: list[tuple[int, int]]) -> None:
validated = parse_curve(curve_to_dict(points))
self.curve_path.parent.mkdir(parents=True, exist_ok=True)
self.curve_path.write_text(
json.dumps(curve_to_dict(validated), indent=2) + "\n",
encoding="utf-8",
)
with self._lock:
self._curve = validated
log.info("Saved curve: %s", validated)
def reload_curve(self) -> None:
curve = self.load_curve_file()
with self._lock:
self._curve = curve
log.info("Reloaded curve: %s", curve)
def set_mode(self, mode: str, manual_speed: int | None = None) -> None:
if mode not in ("auto", "curve", "manual"):
raise FanControlError(f"Unknown mode: {mode}")
with self._lock:
self._mode = mode
if manual_speed is not None:
self._manual_speed = _clamp_speed(manual_speed)
log.info("Mode set to %s (manual_speed=%s)", mode, manual_speed)
def get_curve(self) -> list[tuple[int, int]]:
with self._lock:
return list(self._curve)
def get_metrics(self) -> dict[str, Any]:
with self._lock:
return dict(self._last_metrics)
def init_nvml(self) -> None:
pynvml.nvmlInit()
count = pynvml.nvmlDeviceGetCount()
if self.gpu_index >= count:
raise FanControlError(
f"GPU index {self.gpu_index} out of range (found {count} GPU(s))"
)
handle = pynvml.nvmlDeviceGetHandleByIndex(self.gpu_index)
name = pynvml.nvmlDeviceGetName(handle)
fan_count = pynvml.nvmlDeviceGetNumFans(handle)
self._handle = handle
self._fan_count = fan_count
self._gpu_name = name if isinstance(name, str) else name.decode()
self.reload_curve()
log.info("GPU %d: %s (%d fan(s))", self.gpu_index, self._gpu_name, fan_count)
def _read_metrics(self) -> dict[str, Any]:
handle = self._handle
assert handle is not None
temp = pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU)
util = pynvml.nvmlDeviceGetUtilizationRates(handle)
power_mw = pynvml.nvmlDeviceGetPowerUsage(handle)
mem_info = pynvml.nvmlDeviceGetMemoryInfo(handle)
memory_used_mb = mem_info.used // (1024**2)
memory_total_mb = mem_info.total // (1024**2)
power_limit_w: float | None = None
clock_graphics_mhz: int | None = None
clock_memory_mhz: int | None = None
try:
power_limit_w = round(
pynvml.nvmlDeviceGetEnforcedPowerLimit(handle) / 1000, 1
)
except pynvml.NVMLError:
pass
try:
clock_graphics_mhz = pynvml.nvmlDeviceGetClockInfo(
handle, pynvml.NVML_CLOCK_GRAPHICS
)
except pynvml.NVMLError:
pass
try:
clock_memory_mhz = pynvml.nvmlDeviceGetClockInfo(
handle, pynvml.NVML_CLOCK_MEM
)
except pynvml.NVMLError:
pass
fan_speeds: list[int] = []
for fan_idx in range(self._fan_count):
try:
fan_speeds.append(pynvml.nvmlDeviceGetFanSpeed_v2(handle, fan_idx))
except pynvml.NVMLError:
fan_speeds.append(-1)
with self._lock:
mode = self._mode
manual_speed = self._manual_speed
curve = list(self._curve)
if mode == "curve":
target = interpolate_speed(temp, curve)
elif mode == "manual":
target = manual_speed
else:
target = None
metrics = {
"gpu_index": self.gpu_index,
"gpu_name": self._gpu_name,
"temperature_c": temp,
"fan_speeds_pct": fan_speeds,
"target_speed_pct": target,
"power_w": round(power_mw / 1000, 1),
"power_limit_w": power_limit_w,
"utilization_pct": util.gpu,
"memory_utilization_pct": util.memory,
"memory_used_mb": memory_used_mb,
"memory_total_mb": memory_total_mb,
"clock_graphics_mhz": clock_graphics_mhz,
"clock_memory_mhz": clock_memory_mhz,
"mode": mode,
"manual_speed_pct": manual_speed,
"curve": curve_to_dict(curve),
}
with self._lock:
self._last_metrics = metrics
return metrics
def _set_manual_policy(self) -> None:
handle = self._handle
assert handle is not None
for fan_idx in range(self._fan_count):
pynvml.nvmlDeviceSetFanControlPolicy(
handle, fan_idx, pynvml.NVML_FAN_POLICY_MANUAL
)
def _restore_auto_policy(self) -> None:
if self._handle is None:
return
log.info("Restoring automatic fan control...")
for fan_idx in range(self._fan_count):
try:
pynvml.nvmlDeviceSetFanControlPolicy(
self._handle,
fan_idx,
pynvml.NVML_FAN_POLICY_TEMPERATURE_CONTINOUS_SW,
)
log.info("Fan %d: restored to auto", fan_idx)
except pynvml.NVMLError as exc:
log.error("Fan %d: could not restore auto: %s", fan_idx, exc)
def _apply_fan_speed(self, speed: int) -> None:
handle = self._handle
assert handle is not None
speed = _clamp_speed(speed)
self._set_manual_policy()
for fan_idx in range(self._fan_count):
pynvml.nvmlDeviceSetFanSpeed_v2(handle, fan_idx, speed)
def update_once(self) -> dict[str, Any]:
metrics = self._read_metrics()
mode = metrics["mode"]
if mode == "auto":
if not self._auto_active and not self.dry_run:
self._restore_auto_policy()
self._auto_active = True
elif self.dry_run:
self._auto_active = True
return metrics
self._auto_active = False
target = metrics["target_speed_pct"]
if target is None:
return metrics
if self.dry_run:
log.info(
"[dry-run] GPU %d: %d°C -> %d%% (fans: %s)",
self.gpu_index,
metrics["temperature_c"],
target,
metrics["fan_speeds_pct"],
)
return metrics
try:
self._apply_fan_speed(target)
log.info(
"GPU %d: %d°C -> %d%% (fans: %s)",
self.gpu_index,
metrics["temperature_c"],
target,
metrics["fan_speeds_pct"],
)
except pynvml.NVMLError as exc:
log.error("Failed to set fan speed: %s", exc)
metrics["error"] = str(exc)
return metrics
def run_loop(self) -> None:
self._running = True
log.info("Fan control loop started (interval=%ss)", self.poll_interval)
while self._running:
try:
self.update_once()
except Exception:
log.exception("Fan control loop error")
time.sleep(self.poll_interval)
def stop(self) -> None:
self._running = False
def shutdown(self) -> None:
self.stop()
if not self.dry_run:
self._restore_auto_policy()
try:
pynvml.nvmlShutdown()
except pynvml.NVMLError:
pass
log.info("Fan controller shut down")
def install_signal_handlers(self) -> None:
def handler(signum: int, _frame: Any) -> None:
log.info("Received signal %s", signum)
if signum == signal.SIGHUP:
try:
self.reload_curve()
except FanControlError as exc:
log.error("Curve reload failed: %s", exc)
return
self.shutdown()
signal.signal(signal.SIGTERM, handler)
signal.signal(signal.SIGINT, handler)
signal.signal(signal.SIGHUP, handler)
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""GPU fan control NVML daemon + API only (no web UI)."""
from __future__ import annotations
import logging
import os
import signal
import sys
import threading
from pathlib import Path
from typing import Any
import uvicorn
from fastapi import Depends, FastAPI, HTTPException, Request
from pydantic import BaseModel, Field
from fan_controller import (
FanControlError,
FanController,
MAX_CURVE_POINTS,
MIN_CURVE_POINTS,
MIN_FAN_SPEED,
MAX_FAN_SPEED,
curve_to_dict,
parse_curve,
)
STACK_DIR = Path(__file__).resolve().parent
# Unified control-plane env (see stacks/control-plane/env_loader.py)
for _cp in ("/opt/control-plane", "/repo/stacks/control-plane", str(STACK_DIR.parent / "control-plane")):
if _cp not in sys.path and Path(_cp).exists():
sys.path.insert(0, _cp)
from env_loader import load_control_plane_env # noqa: E402
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
log = logging.getLogger(__name__)
def _resolve_host_port(env: dict[str, str]) -> tuple[str, int]:
"""Agent API bind — prefer GPU_FAN_API_*; never fall back to legacy LAN :8090."""
if env.get("GPU_FAN_API_HOST") or env.get("GPU_FAN_API_PORT"):
host = env.get("GPU_FAN_API_HOST", "127.0.0.1")
port_raw = env.get("GPU_FAN_API_PORT", "18090")
else:
host = "127.0.0.1"
port_raw = "18090"
return host, int(port_raw)
def _is_localhost_bind(host: str) -> bool:
return host.strip().lower() in ("127.0.0.1", "localhost", "::1")
ENV = load_control_plane_env(STACK_DIR)
HOST, PORT = _resolve_host_port(ENV)
API_KEY = ENV.get("API_KEY", "")
CURVE_PATH = Path(ENV.get("CURVE_PATH", "/etc/gpu-fan/curve.json"))
POLL_INTERVAL = float(ENV.get("POLL_INTERVAL", "2.0"))
GPU_INDEX = int(ENV.get("GPU_INDEX", "0"))
DRY_RUN = ENV.get("DRY_RUN", "").lower() in ("1", "true", "yes")
controller = FanController(
curve_path=CURVE_PATH,
gpu_index=GPU_INDEX,
poll_interval=POLL_INTERVAL,
)
controller.dry_run = DRY_RUN
app = FastAPI(title="GPU Fan Agent", version="1.0.0")
class CurvePoint(BaseModel):
temp: int = Field(ge=0, le=120)
speed: int = Field(ge=0, le=100)
class CurveUpdate(BaseModel):
points: list[CurvePoint] = Field(min_length=MIN_CURVE_POINTS, max_length=MAX_CURVE_POINTS)
class ModeUpdate(BaseModel):
mode: str
speed: int | None = Field(default=None, ge=MIN_FAN_SPEED, le=MAX_FAN_SPEED)
def require_auth(request: Request) -> None:
if not API_KEY:
return
key = request.headers.get("X-API-Key", "")
if key != API_KEY:
raise HTTPException(status_code=401, detail="Invalid or missing API key")
@app.get("/api/status")
def api_status(_: None = Depends(require_auth)) -> dict[str, Any]:
return controller.get_metrics()
@app.get("/api/curve")
def api_get_curve(_: None = Depends(require_auth)) -> dict[str, Any]:
points = controller.get_curve()
return {"points": [{"temp": t, "speed": s} for t, s in points]}
@app.put("/api/curve")
def api_put_curve(body: CurveUpdate, _: None = Depends(require_auth)) -> dict[str, Any]:
points = [(p.temp, p.speed) for p in body.points]
try:
parse_curve(curve_to_dict(points))
controller.save_curve_file(points)
controller.set_mode("curve")
except FanControlError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "curve": curve_to_dict(controller.get_curve())}
@app.post("/api/mode")
def api_set_mode(body: ModeUpdate, _: None = Depends(require_auth)) -> dict[str, Any]:
try:
controller.set_mode(body.mode, body.speed)
if body.mode != "auto":
controller.update_once()
except FanControlError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "mode": controller.mode, "manual_speed": controller.manual_speed}
@app.post("/api/reload")
def api_reload(_: None = Depends(require_auth)) -> dict[str, Any]:
try:
controller.reload_curve()
except FanControlError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "curve": curve_to_dict(controller.get_curve())}
def run_daemon_thread() -> None:
controller.run_loop()
def main() -> None:
if os.geteuid() != 0 and not DRY_RUN:
log.error("GPU fan control requires root (NVML write access). Run with sudo.")
sys.exit(1)
if not _is_localhost_bind(HOST) and not API_KEY:
log.error(
"API_KEY is required when GPU_FAN_API_HOST=%s (LAN/public bind). "
"Set API_KEY in .env",
HOST,
)
sys.exit(1)
controller.init_nvml()
def shutdown_handler(signum: int, _frame: object) -> None:
log.info("Received signal %s", signum)
if signum == signal.SIGHUP:
try:
controller.reload_curve()
except FanControlError as exc:
log.error("Curve reload failed: %s", exc)
return
controller.shutdown()
sys.exit(0)
signal.signal(signal.SIGTERM, shutdown_handler)
signal.signal(signal.SIGINT, shutdown_handler)
signal.signal(signal.SIGHUP, shutdown_handler)
thread = threading.Thread(target=run_daemon_thread, daemon=True)
thread.start()
log.info("GPU fan agent API at http://%s:%d", HOST, PORT)
log.info("Web UI: use Server UI at :8091 (GPU Fan tab)")
uvicorn.run(app, host=HOST, port=PORT, log_level="info")
if __name__ == "__main__":
main()
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=GPU Fan Control (NVML agent API)
After=nvidia-persistenced.service
Wants=nvidia-persistenced.service
[Service]
Type=simple
User=root
WorkingDirectory=/opt/gpu-fan
EnvironmentFile=-/opt/control-plane/.env
ExecStart=/opt/gpu-fan/.venv/bin/python /opt/gpu-fan/fan_daemon.py
Restart=on-failure
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=15
[Install]
WantedBy=multi-user.target
+3
View File
@@ -0,0 +1,3 @@
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
nvidia-ml-py>=12.560.0
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Configure gpu-fan agent for localhost + ensure API key. UI is in Server UI :8091.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
SERVER_UI_DIR="$(cd "${STACK_DIR}/../server-ui" && pwd)"
CONTROL_PLANE_ENV="/opt/control-plane/.env"
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo ${SCRIPT_DIR}/enable-lan.sh"
exit 1
fi
"${SCRIPT_DIR}/install.sh"
set_env_var() {
local file="$1" key="$2" val="$3"
if grep -q "^${key}=" "${file}"; then
sed -i "s|^${key}=.*|${key}=${val}|" "${file}"
else
echo "${key}=${val}" >> "${file}"
fi
}
for pair in "GPU_FAN_API_HOST=127.0.0.1" "GPU_FAN_API_PORT=18090"; do
set_env_var "${CONTROL_PLANE_ENV}" "${pair%%=*}" "${pair#*=}"
done
if ! grep -q '^API_KEY=.\{8,\}' "${CONTROL_PLANE_ENV}"; then
KEY="$(openssl rand -hex 16)"
set_env_var "${CONTROL_PLANE_ENV}" "API_KEY" "${KEY}"
echo "Generated new API_KEY in ${CONTROL_PLANE_ENV}"
else
echo "API_KEY already set in ${CONTROL_PLANE_ENV}"
fi
systemctl restart gpu-fan
sleep 2
LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
echo ""
echo "gpu-fan agent: $(systemctl is-active gpu-fan)"
echo "Agent API: http://127.0.0.1:18090 (localhost only)"
echo ""
echo "GPU Fan panel (LAN):"
echo " http://${LAN_IP:-<server-ip>}:8091 → zakładka GPU Fan"
echo ""
echo "API key (Server UI + gpu-fan):"
echo " grep ^API_KEY= ${CONTROL_PLANE_ENV}"
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
SERVER_UI_DIR="$(cd "${STACK_DIR}/../server-ui" && pwd)"
INSTALL_DIR="/opt/gpu-fan"
CONFIG_DIR="/etc/gpu-fan"
SERVICE_NAME="gpu-fan.service"
AGENT_PORT=18090
CONTROL_PLANE_ENV="/opt/control-plane/.env"
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo ${SCRIPT_DIR}/install.sh"
exit 1
fi
echo "=== GPU Fan Control — install ==="
bash "${SERVER_UI_DIR}/scripts/setup-control-plane-env.sh"
apt-get update -qq
apt-get install -y python3-venv python3-pip
mkdir -p "${INSTALL_DIR}" "${CONFIG_DIR}"
rsync -a --delete \
--exclude '.venv' \
--exclude '.env' \
--exclude '__pycache__' \
"${STACK_DIR}/" "${INSTALL_DIR}/"
if [[ ! -f "${CONFIG_DIR}/curve.json" ]]; then
cp "${INSTALL_DIR}/curve.default.json" "${CONFIG_DIR}/curve.json"
echo "Installed default curve: ${CONFIG_DIR}/curve.json"
fi
python3 -m venv "${INSTALL_DIR}/.venv"
"${INSTALL_DIR}/.venv/bin/pip" install --upgrade pip -q
"${INSTALL_DIR}/.venv/bin/pip" install -r "${INSTALL_DIR}/requirements.txt" -q
install -m 644 "${INSTALL_DIR}/gpu-fan.service" "/etc/systemd/system/${SERVICE_NAME}"
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}"
systemctl restart "${SERVICE_NAME}"
sleep 2
API_KEY_VAL="$(grep '^API_KEY=' "${CONTROL_PLANE_ENV}" | cut -d= -f2- || true)"
LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
echo ""
echo "Installed to ${INSTALL_DIR}"
echo "Service: $(systemctl is-active "${SERVICE_NAME}" 2>/dev/null || echo unknown)"
echo "Env: ${CONTROL_PLANE_ENV}"
echo "Curve config: ${CONFIG_DIR}/curve.json"
echo "Agent API: 127.0.0.1:${AGENT_PORT} (localhost only)"
echo ""
echo "GPU Fan UI (Server UI, LAN):"
echo " http://${LAN_IP:-<server-ip>}:8091/#gpu-fan"
echo ""
echo "API key:"
echo " grep ^API_KEY= ${CONTROL_PLANE_ENV}"
echo ""
if ss -tlnp 2>/dev/null | grep -q ":${AGENT_PORT}"; then
echo "Port ${AGENT_PORT}: listening"
if [[ -n "${API_KEY_VAL}" ]]; then
STATUS="$(curl -sf "http://127.0.0.1:${AGENT_PORT}/api/status" -H "X-API-Key: ${API_KEY_VAL}" 2>/dev/null | head -c 120 || true)"
echo "Agent status: ${STATUS}..."
fi
else
echo "WARN: port ${AGENT_PORT} not listening — check: journalctl -u ${SERVICE_NAME} -n 20"
fi
echo ""
echo "Logs:"
echo " journalctl -u ${SERVICE_NAME} -f"
echo ""
echo "Pełna instalacja (gpu-fan + Server UI):"
echo " sudo ${SERVER_UI_DIR}/scripts/install-control-plane.sh"
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# Self-test: curve logic, NVML read, API (dry-run). Full fan write needs sudo.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${STACK_DIR}"
mkdir -p config
cp -n curve.default.json config/curve.json 2>/dev/null || true
export CURVE_PATH="${STACK_DIR}/config/curve.json"
export DRY_RUN=true
export GPU_FAN_PORT=18090
echo "=== 1. Curve logic ==="
.venv/bin/python -c "
from fan_controller import parse_curve, interpolate_speed
pts = parse_curve({'30':50,'40':65,'50':80,'55':90,'60':100,'70':100})
assert interpolate_speed(45, pts) in (72, 73)
assert interpolate_speed(70, pts) == 100
print('OK')
"
echo "=== 2. NVML read ==="
.venv/bin/python -c "
import pynvml
pynvml.nvmlInit()
h = pynvml.nvmlDeviceGetHandleByIndex(0)
t = pynvml.nvmlDeviceGetTemperature(h, pynvml.NVML_TEMPERATURE_GPU)
print(f'GPU temp: {t}C')
pynvml.nvmlShutdown()
print('OK')
"
echo "=== 3. API dry-run (background) ==="
.venv/bin/python app.py &
APP_PID=$!
trap 'kill $APP_PID 2>/dev/null || true' EXIT
sleep 2
STATUS=$(curl -sf "http://127.0.0.1:${GPU_FAN_PORT}/api/status")
echo "$STATUS" | .venv/bin/python -m json.tool | head -20
TARGET=$(echo "$STATUS" | .venv/bin/python -c "import sys,json; print(json.load(sys.stdin)['target_speed_pct'])")
TEMP=$(echo "$STATUS" | .venv/bin/python -c "import sys,json; print(json.load(sys.stdin)['temperature_c'])")
echo "Temp=${TEMP}C target=${TARGET}%"
curl -sf -X PUT "http://127.0.0.1:${GPU_FAN_PORT}/api/curve" \
-H 'Content-Type: application/json' \
-d '{"points":[{"temp":30,"speed":50},{"temp":40,"speed":65},{"temp":50,"speed":80},{"temp":55,"speed":90},{"temp":60,"speed":100},{"temp":70,"speed":100}]}' \
| .venv/bin/python -m json.tool >/dev/null
echo "Curve PUT: OK"
curl -sf -X POST "http://127.0.0.1:${GPU_FAN_PORT}/api/mode" \
-H 'Content-Type: application/json' \
-d '{"mode":"manual","speed":100}' >/dev/null
echo "Mode manual 100%: OK"
kill $APP_PID 2>/dev/null || true
trap - EXIT
echo ""
if sudo -n true 2>/dev/null; then
echo "=== 4. Fan write (root) ==="
sudo -n env CURVE_PATH="$CURVE_PATH" .venv/bin/python -c "
from pathlib import Path
from fan_controller import FanController
c = FanController(Path('$CURVE_PATH'), 0, 2.0)
c.init_nvml()
c.set_mode('manual', 50)
m = c.update_once()
print(f\"Applied: {m['temperature_c']}C -> {m['target_speed_pct']}%\")
c.set_mode('auto')
c.update_once()
c.shutdown()
print('OK')
"
else
echo "=== 4. Fan write (root) — SKIP (sudo needs password) ==="
echo " Run: sudo scripts/install.sh && sudo systemctl start gpu-fan"
fi
echo ""
echo "All automated checks passed."
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
CONTROL_PLANE_ENV="${STACK_DIR}/../control-plane/.env"
EXAMPLE="${STACK_DIR}/../control-plane/.env.example"
cd "${STACK_DIR}"
if [[ ! -f "${CONTROL_PLANE_ENV}" ]]; then
cp "${EXAMPLE}" "${CONTROL_PLANE_ENV}"
echo "Created ${CONTROL_PLANE_ENV} from example"
fi
set -a
# shellcheck disable=SC1091
source "${CONTROL_PLANE_ENV}"
set +a
export CONTROL_PLANE_ENV="${CONTROL_PLANE_ENV}"
CURVE_PATH="${CURVE_PATH:-/etc/gpu-fan/curve.json}"
export CURVE_PATH
if [[ ! -f "${CURVE_PATH}" ]]; then
if [[ "${EUID}" -eq 0 ]]; then
mkdir -p "$(dirname "${CURVE_PATH}")"
cp curve.default.json "${CURVE_PATH}"
else
mkdir -p "${STACK_DIR}/config"
CURVE_PATH="${STACK_DIR}/config/curve.json"
export CURVE_PATH
[[ -f "${CURVE_PATH}" ]] || cp curve.default.json "${CURVE_PATH}"
echo "Using local curve: ${CURVE_PATH}"
fi
fi
if [[ ! -d .venv ]]; then
python3 -m venv .venv
.venv/bin/pip install -q -r requirements.txt
fi
if [[ "${EUID}" -ne 0 ]]; then
echo "ERROR: Fan control requires root. Use:"
echo " sudo -E ${SCRIPT_DIR}/start.sh"
echo "Or install system-wide:"
echo " sudo ${SCRIPT_DIR}/install.sh && sudo systemctl start gpu-fan"
exit 1
fi
if systemctl is-active --quiet gpu-fan 2>/dev/null; then
echo "ERROR: gpu-fan.service is already running (systemd)."
echo " Use: sudo systemctl stop gpu-fan"
echo " Or: sudo journalctl -u gpu-fan -f"
echo ""
echo "Do not run scripts/start.sh alongside systemd — one instance only."
exit 1
fi
PORT="${GPU_FAN_API_PORT:-18090}"
HOST="${GPU_FAN_API_HOST:-127.0.0.1}"
if ss -tln "sport = :${PORT}" 2>/dev/null | grep -q LISTEN; then
echo "ERROR: Port ${PORT} is already in use."
echo ""
ss -tlnp "sport = :${PORT}" 2>/dev/null || ss -tln "sport = :${PORT}"
echo ""
echo "Common causes:"
echo " - Suspended foreground start (Ctrl+Z) — run: jobs -l && kill %<n>"
echo " - Orphan process — run: sudo ${SCRIPT_DIR}/status.sh"
echo " - systemd still stopping — wait a few seconds"
exit 1
fi
echo "=== GPU Fan Control (foreground) ==="
echo "Agent API: http://${HOST}:${PORT}"
echo "Curve: ${CURVE_PATH}"
echo ""
echo "Stop with Ctrl+C (not Ctrl+Z — suspended process keeps the port)."
echo ""
exec .venv/bin/python app.py
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Diagnose gpu-fan port conflicts and running instances.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
OPT_ENV="/opt/control-plane/.env"
REPO_ENV="${STACK_DIR}/../control-plane/.env"
echo "=== gpu-fan status ==="
echo ""
if systemctl list-unit-files gpu-fan.service &>/dev/null; then
echo "systemd:"
systemctl is-active gpu-fan 2>/dev/null && systemctl status gpu-fan --no-pager -l 2>/dev/null | head -8 || echo " inactive"
else
echo "systemd: gpu-fan.service not installed"
fi
echo ""
echo "Ports 80908099:"
if ss -tlnp 2>/dev/null | grep -E ':809[0-9]' ; then
:
elif ss -tln 2>/dev/null | grep -E ':809[0-9]' ; then
echo " (run as root for process names: sudo ss -tlnp)"
else
echo " (none listening)"
fi
echo ""
echo "gpu-fan processes:"
pgrep -af '/opt/gpu-fan/.venv/bin/python|stacks/gpu-fan/.venv/bin/python|gpu-fan/app.py' 2>/dev/null \
|| echo " (none)"
echo ""
echo "Shell suspended jobs (Ctrl+Z):"
if jobs -l 2>/dev/null | grep -q .; then
jobs -l
echo " Kill with: kill %<job-number>"
else
echo " (none in this shell)"
fi
echo ""
echo "GPU_FAN_API_PORT config:"
[[ -f "${OPT_ENV}" ]] && echo " /opt/control-plane/.env: $(grep '^GPU_FAN_API_PORT=' "${OPT_ENV}" || echo '(not set)')"
[[ -f "${REPO_ENV}" ]] && echo " repo control-plane: $(grep '^GPU_FAN_API_PORT=' "${REPO_ENV}" || echo '(not set)')"
echo " Note: production uses /opt/control-plane/.env (shared with Server UI)"
echo ""
if [[ "${1:-}" == "--cleanup" ]]; then
if [[ "${EUID}" -ne 0 ]]; then
echo "ERROR: --cleanup requires root: sudo ${SCRIPT_DIR}/status.sh --cleanup"
exit 1
fi
echo "=== cleanup ==="
systemctl stop gpu-fan 2>/dev/null || true
pkill -f '/opt/gpu-fan/.venv/bin/python /opt/gpu-fan/app.py' 2>/dev/null || true
pkill -f 'stacks/gpu-fan/.venv/bin/python app.py' 2>/dev/null || true
sleep 0.5
if ss -tln 2>/dev/null | grep -qE ':809[0-9]'; then
echo "WARNING: ports still in use:"
ss -tlnp 2>/dev/null | grep -E ':809[0-9]' || ss -tln | grep -E ':809[0-9]'
echo "Check suspended jobs in other shells: jobs -l"
else
echo "Ports 809x are free."
fi
echo ""
echo "Start production instance:"
echo " sudo systemctl start gpu-fan"
echo "Or foreground debug (systemd stopped):"
echo " sudo ${SCRIPT_DIR}/start.sh"
fi
+755
View File
@@ -0,0 +1,755 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GPU Fan Control — RTX 3090 Ti</title>
<style>
:root {
--bg: #0d1117;
--panel: #161b22;
--border: #30363d;
--text: #e6edf3;
--muted: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--orange: #d29922;
--red: #f85149;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
h1 { margin: 0; font-size: 1.25rem; font-weight: 600; }
.subtitle { color: var(--muted); font-size: 0.875rem; }
main { padding: 1.5rem; max-width: 1100px; margin: 0 auto; }
.grid {
display: grid;
grid-template-columns: 1fr 320px;
gap: 1rem;
}
@media (max-width: 900px) {
.grid { grid-template-columns: 1fr; }
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
}
.card h2 {
margin: 0 0 1rem;
font-size: 0.95rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stats { display: grid; gap: 0.75rem; }
.stat {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.stat-value { font-size: 1.5rem; font-weight: 600; }
.stat-label { color: var(--muted); font-size: 0.85rem; }
.temp-hot { color: var(--red); }
.temp-warm { color: var(--orange); }
.temp-cool { color: var(--green); }
.mode-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
background: #21262d;
border: 1px solid var(--border);
}
#curve-svg {
width: 100%;
height: 360px;
background: #0d1117;
border-radius: 6px;
cursor: crosshair;
touch-action: none;
}
.axis-label { fill: var(--muted); font-size: 11px; }
.curve-line { stroke: var(--accent); stroke-width: 2; fill: none; }
.curve-fill { fill: rgba(88, 166, 255, 0.08); }
.curve-point {
fill: var(--accent);
stroke: #fff;
stroke-width: 2;
cursor: grab;
}
.curve-point:active { cursor: grabbing; }
table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
th, td {
padding: 0.4rem 0.5rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
th { color: var(--muted); font-weight: 500; }
input[type="number"] {
width: 4rem;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: 4px;
padding: 0.25rem 0.4rem;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
button {
background: #21262d;
border: 1px solid var(--border);
color: var(--text);
padding: 0.5rem 0.9rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
}
button:hover { border-color: var(--accent); }
button.primary {
background: #238636;
border-color: #2ea043;
}
button.primary:hover { background: #2ea043; }
button.danger {
background: #da3633;
border-color: #f85149;
}
#toast {
position: fixed;
bottom: 1rem;
right: 1rem;
padding: 0.75rem 1rem;
border-radius: 6px;
background: var(--panel);
border: 1px solid var(--border);
display: none;
z-index: 100;
}
#toast.error { border-color: var(--red); color: var(--red); }
#toast.ok { border-color: var(--green); }
.hint { color: var(--muted); font-size: 0.8rem; margin-top: 0.5rem; }
.monitoring-section { margin-top: 1rem; }
.gauge-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1rem;
}
@media (max-width: 700px) {
.gauge-grid { grid-template-columns: 1fr; }
}
.gauge {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem 1rem;
}
.gauge-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.gauge-label { color: var(--muted); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; }
.gauge-value { font-size: 1.25rem; font-weight: 600; }
.gauge-bar {
height: 10px;
background: #21262d;
border-radius: 5px;
overflow: hidden;
}
.gauge-fill {
height: 100%;
border-radius: 5px;
transition: width 0.4s ease;
}
.gauge-fill.util { background: linear-gradient(90deg, var(--green), var(--orange), var(--red)); }
.gauge-fill.power { background: linear-gradient(90deg, var(--accent), var(--orange), var(--red)); }
.gauge-fill.vram { background: linear-gradient(90deg, #6e40c9, var(--accent)); }
.gauge-sub { color: var(--muted); font-size: 0.75rem; margin-top: 0.35rem; }
.chart-wrap {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.chart-legend {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 0.5rem;
font-size: 0.8rem;
color: var(--muted);
}
.chart-legend span::before {
content: '';
display: inline-block;
width: 10px;
height: 3px;
margin-right: 0.35rem;
vertical-align: middle;
border-radius: 1px;
}
.legend-util::before { background: var(--accent); }
.legend-power::before { background: var(--orange); }
.legend-temp::before { background: var(--red); }
#history-canvas {
width: 100%;
height: 140px;
display: block;
}
.sensor-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
@media (max-width: 700px) {
.sensor-grid { grid-template-columns: repeat(2, 1fr); }
}
.sensor-tile {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem;
text-align: center;
}
.sensor-value {
font-size: 1.35rem;
font-weight: 600;
line-height: 1.2;
}
.sensor-label {
color: var(--muted);
font-size: 0.75rem;
margin-top: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
</style>
</head>
<body>
<header>
<div>
<h1>GPU Fan Control</h1>
<div class="subtitle" id="gpu-name">Ładowanie…</div>
</div>
<span class="mode-badge" id="mode-badge"></span>
</header>
<main>
<div class="grid">
<div class="card">
<h2>Krzywa wentylatorów</h2>
<svg id="curve-svg" viewBox="0 0 600 360">
<defs>
<linearGradient id="tempGrad" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="#3fb950" stop-opacity="0.15"/>
<stop offset="50%" stop-color="#d29922" stop-opacity="0.15"/>
<stop offset="100%" stop-color="#f85149" stop-opacity="0.15"/>
</linearGradient>
</defs>
<rect x="50" y="20" width="520" height="300" fill="url(#tempGrad)" stroke="#30363d"/>
<text x="300" y="355" class="axis-label" text-anchor="middle">Temperatura (°C)</text>
<text x="15" y="170" class="axis-label" transform="rotate(-90 15 170)" text-anchor="middle">Prędkość (%)</text>
<path id="curve-fill" class="curve-fill"/>
<polyline id="curve-line" class="curve-line"/>
<g id="points-layer"></g>
<line id="live-temp" stroke="#f85149" stroke-width="1" stroke-dasharray="4 4" opacity="0.7"/>
<circle id="live-dot" r="5" fill="#f85149" opacity="0.9"/>
</svg>
<p class="hint">Przeciągnij punkty na wykresie. Prędkość: 30100% (API NVIDIA). Min. 3, max. 7 punktów.</p>
<table id="points-table">
<thead>
<tr><th>#</th><th>Temp (°C)</th><th>Speed (%)</th><th></th></tr>
</thead>
<tbody></tbody>
</table>
<div class="actions">
<button type="button" id="btn-add">+ Punkt</button>
<button type="button" class="primary" id="btn-save">Zapisz krzywą</button>
<button type="button" id="btn-reload">Przeładuj z pliku</button>
<button type="button" id="btn-auto">Tryb auto (driver)</button>
<button type="button" class="danger" id="btn-max">Manual 100%</button>
</div>
</div>
<div class="card">
<h2>Status GPU</h2>
<div class="stats">
<div class="stat">
<span class="stat-label">Wentylatory</span>
<span class="stat-value" id="stat-fans"></span>
</div>
<div class="stat">
<span class="stat-label">Cel (krzywa)</span>
<span class="stat-value" id="stat-target"></span>
</div>
</div>
</div>
</div>
<div class="card monitoring-section">
<h2>Monitoring GPU</h2>
<div class="gauge-grid">
<div class="gauge">
<div class="gauge-header">
<span class="gauge-label">Obciążenie GPU</span>
<span class="gauge-value" id="gauge-util-val"></span>
</div>
<div class="gauge-bar"><div class="gauge-fill util" id="gauge-util-bar" style="width:0%"></div></div>
</div>
<div class="gauge">
<div class="gauge-header">
<span class="gauge-label">Pobór mocy</span>
<span class="gauge-value" id="gauge-power-val"></span>
</div>
<div class="gauge-bar"><div class="gauge-fill power" id="gauge-power-bar" style="width:0%"></div></div>
<div class="gauge-sub" id="gauge-power-sub"></div>
</div>
<div class="gauge">
<div class="gauge-header">
<span class="gauge-label">Pamięć VRAM</span>
<span class="gauge-value" id="gauge-vram-val"></span>
</div>
<div class="gauge-bar"><div class="gauge-fill vram" id="gauge-vram-bar" style="width:0%"></div></div>
<div class="gauge-sub" id="gauge-vram-sub"></div>
</div>
</div>
<div class="chart-wrap">
<div class="chart-legend">
<span class="legend-util">Wykorzystanie GPU (%)</span>
<span class="legend-power">Moc (W)</span>
<span class="legend-temp">Temperatura (°C)</span>
</div>
<canvas id="history-canvas"></canvas>
</div>
<div class="sensor-grid">
<div class="sensor-tile">
<div class="sensor-value" id="sensor-temp"></div>
<div class="sensor-label">Temperatura</div>
</div>
<div class="sensor-tile">
<div class="sensor-value" id="sensor-fans"></div>
<div class="sensor-label">Wentylatory</div>
</div>
<div class="sensor-tile">
<div class="sensor-value" id="sensor-gpu-clock"></div>
<div class="sensor-label">Taktowanie GPU</div>
</div>
<div class="sensor-tile">
<div class="sensor-value" id="sensor-mem-clock"></div>
<div class="sensor-label">Taktowanie pamięci</div>
</div>
<div class="sensor-tile">
<div class="sensor-value" id="sensor-mem-util"></div>
<div class="sensor-label">Wykorzystanie pamięci</div>
</div>
<div class="sensor-tile">
<div class="sensor-value" id="sensor-mode"></div>
<div class="sensor-label">Tryb wentylatora</div>
</div>
</div>
</div>
</main>
<div id="toast"></div>
<script>
const PAD = { left: 50, top: 20, width: 520, height: 300 };
const TEMP_MIN = 20;
const TEMP_MAX = 90;
const SPEED_MIN = 30;
const SPEED_MAX = 100;
let points = [];
let liveTemp = null;
let dragging = null;
const history = [];
const HISTORY_MAX = 60;
let apiKey = localStorage.getItem('gpu-fan-api-key') || '';
const urlKey = new URLSearchParams(location.search).get('api_key');
if (urlKey) {
apiKey = urlKey;
localStorage.setItem('gpu-fan-api-key', urlKey);
history.replaceState({}, '', location.pathname);
}
function headers() {
const h = { 'Content-Type': 'application/json' };
if (apiKey) h['X-API-Key'] = apiKey;
return h;
}
function toast(msg, type = 'ok') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = type;
el.style.display = 'block';
setTimeout(() => { el.style.display = 'none'; }, 3500);
}
async function api(path, opts = {}) {
const res = await fetch(path, { ...opts, headers: { ...headers(), ...opts.headers } });
if (res.status === 401) {
const key = prompt('Podaj API key (X-API-Key):');
if (key) {
apiKey = key;
localStorage.setItem('gpu-fan-api-key', key);
return api(path, opts);
}
throw new Error('Brak autoryzacji');
}
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || res.statusText);
return data;
}
function tempToX(t) {
return PAD.left + ((t - TEMP_MIN) / (TEMP_MAX - TEMP_MIN)) * PAD.width;
}
function speedToY(s) {
return PAD.top + PAD.height - ((s - SPEED_MIN) / (SPEED_MAX - SPEED_MIN)) * PAD.height;
}
function xToTemp(x) {
return Math.round(TEMP_MIN + ((x - PAD.left) / PAD.width) * (TEMP_MAX - TEMP_MIN));
}
function yToSpeed(y) {
const raw = SPEED_MIN + ((PAD.top + PAD.height - y) / PAD.height) * (SPEED_MAX - SPEED_MIN);
return Math.round(Math.max(SPEED_MIN, Math.min(SPEED_MAX, raw)));
}
function sortPoints() {
points.sort((a, b) => a.temp - b.temp);
}
function drawCurve() {
sortPoints();
const pts = points.map(p => `${tempToX(p.temp)},${speedToY(p.speed)}`).join(' ');
document.getElementById('curve-line').setAttribute('points', pts);
const fill = points.length
? `${PAD.left},${PAD.top + PAD.height} ` + pts + ` ${PAD.left + PAD.width},${PAD.top + PAD.height}`
: '';
document.getElementById('curve-fill').setAttribute('d', fill ? `M ${fill} Z` : '');
const layer = document.getElementById('points-layer');
layer.innerHTML = '';
points.forEach((p, i) => {
const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
c.setAttribute('cx', tempToX(p.temp));
c.setAttribute('cy', speedToY(p.speed));
c.setAttribute('r', 8);
c.classList.add('curve-point');
c.dataset.index = i;
c.addEventListener('mousedown', e => { dragging = i; e.preventDefault(); });
c.addEventListener('touchstart', e => { dragging = i; e.preventDefault(); }, { passive: false });
layer.appendChild(c);
});
if (liveTemp != null) {
const x = tempToX(liveTemp);
document.getElementById('live-temp').setAttribute('x1', x);
document.getElementById('live-temp').setAttribute('y1', PAD.top);
document.getElementById('live-temp').setAttribute('x2', x);
document.getElementById('live-temp').setAttribute('y2', PAD.top + PAD.height);
document.getElementById('live-dot').setAttribute('cx', x);
document.getElementById('live-dot').setAttribute('cy', speedToY(interpolate(liveTemp)));
document.getElementById('live-dot').style.display = '';
} else {
document.getElementById('live-dot').style.display = 'none';
}
renderTable();
}
function interpolate(temp) {
if (!points.length) return SPEED_MIN;
if (temp <= points[0].temp) return points[0].speed;
if (temp >= points[points.length - 1].temp) return points[points.length - 1].speed;
for (let i = 0; i < points.length - 1; i++) {
const a = points[i], b = points[i + 1];
if (temp >= a.temp && temp <= b.temp) {
const r = (temp - a.temp) / (b.temp - a.temp);
return Math.round(a.speed + r * (b.speed - a.speed));
}
}
return points[points.length - 1].speed;
}
function renderTable() {
const tbody = document.querySelector('#points-table tbody');
tbody.innerHTML = '';
points.forEach((p, i) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${i + 1}</td>
<td><input type="number" min="0" max="120" value="${p.temp}" data-i="${i}" data-field="temp"></td>
<td><input type="number" min="30" max="100" value="${p.speed}" data-i="${i}" data-field="speed"></td>
<td><button type="button" data-del="${i}" ${points.length <= 3 ? 'disabled' : ''}>×</button></td>
`;
tbody.appendChild(tr);
});
tbody.querySelectorAll('input').forEach(inp => {
inp.addEventListener('change', () => {
const i = +inp.dataset.i;
const v = +inp.value;
if (inp.dataset.field === 'temp') points[i].temp = v;
else points[i].speed = Math.max(SPEED_MIN, Math.min(SPEED_MAX, v));
drawCurve();
});
});
tbody.querySelectorAll('button[data-del]').forEach(btn => {
btn.addEventListener('click', () => {
if (points.length <= 3) return;
points.splice(+btn.dataset.del, 1);
drawCurve();
});
});
}
function onPointerMove(clientX, clientY) {
if (dragging === null) return;
const svg = document.getElementById('curve-svg');
const rect = svg.getBoundingClientRect();
const scaleX = 600 / rect.width;
const scaleY = 360 / rect.height;
const x = (clientX - rect.left) * scaleX;
const y = (clientY - rect.top) * scaleY;
let temp = xToTemp(x);
let speed = yToSpeed(y);
temp = Math.max(TEMP_MIN, Math.min(TEMP_MAX, temp));
const prev = dragging > 0 ? points[dragging - 1].temp + 1 : TEMP_MIN;
const next = dragging < points.length - 1 ? points[dragging + 1].temp - 1 : TEMP_MAX;
points[dragging].temp = Math.max(prev, Math.min(next, temp));
points[dragging].speed = speed;
drawCurve();
}
document.addEventListener('mousemove', e => onPointerMove(e.clientX, e.clientY));
document.addEventListener('mouseup', () => { dragging = null; });
document.addEventListener('touchmove', e => {
if (e.touches[0]) onPointerMove(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: false });
document.addEventListener('touchend', () => { dragging = null; });
async function loadCurve() {
const data = await api('/api/curve');
points = data.points;
drawCurve();
}
function fmtMb(mb) {
if (mb == null) return '—';
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
return `${mb} MB`;
}
function tempClass(temp) {
return temp >= 70 ? 'temp-hot' : temp >= 55 ? 'temp-warm' : 'temp-cool';
}
function pushHistory(s) {
history.push({
t: Date.now(),
util: s.utilization_pct ?? 0,
power: s.power_w ?? 0,
temp: s.temperature_c ?? 0,
});
if (history.length > HISTORY_MAX) history.shift();
}
function updateGauges(s) {
const util = s.utilization_pct ?? 0;
document.getElementById('gauge-util-val').textContent = `${util}%`;
document.getElementById('gauge-util-bar').style.width = `${Math.min(100, util)}%`;
const power = s.power_w ?? 0;
const powerLimit = s.power_limit_w;
document.getElementById('gauge-power-val').textContent = `${power} W`;
const powerPct = powerLimit ? Math.min(100, (power / powerLimit) * 100) : Math.min(100, (power / 450) * 100);
document.getElementById('gauge-power-bar').style.width = `${powerPct}%`;
document.getElementById('gauge-power-sub').textContent =
powerLimit != null ? `Limit: ${powerLimit} W` : 'Limit: —';
const used = s.memory_used_mb;
const total = s.memory_total_mb;
if (used != null && total) {
const pct = Math.min(100, (used / total) * 100);
document.getElementById('gauge-vram-val').textContent = fmtMb(used);
document.getElementById('gauge-vram-bar').style.width = `${pct}%`;
document.getElementById('gauge-vram-sub').textContent = `${fmtMb(used)} / ${fmtMb(total)}`;
} else {
document.getElementById('gauge-vram-val').textContent = '—';
document.getElementById('gauge-vram-bar').style.width = '0%';
document.getElementById('gauge-vram-sub').textContent = '—';
}
}
function drawSparklines() {
const canvas = document.getElementById('history-canvas');
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const w = rect.width;
const h = rect.height;
const pad = { top: 8, right: 8, bottom: 8, left: 8 };
const plotW = w - pad.left - pad.right;
const plotH = h - pad.top - pad.bottom;
ctx.fillStyle = '#0d1117';
ctx.fillRect(0, 0, w, h);
if (history.length < 2) {
ctx.fillStyle = '#8b949e';
ctx.font = '12px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Zbieranie danych…', w / 2, h / 2);
return;
}
const maxPower = Math.max(...history.map(p => p.power), 1);
const maxTemp = Math.max(...history.map(p => p.temp), 1);
function drawSeries(key, color, maxVal) {
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
const n = history.length;
history.forEach((p, i) => {
const x = pad.left + (i / Math.max(1, n - 1)) * plotW;
const y = pad.top + plotH - (p[key] / maxVal) * plotH;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
}
drawSeries('util', '#58a6ff', 100);
drawSeries('power', '#d29922', maxPower);
drawSeries('temp', '#f85149', maxTemp);
}
function updateSensorTiles(s) {
const temp = s.temperature_c;
const tempEl = document.getElementById('sensor-temp');
tempEl.textContent = temp != null ? `${temp}°C` : '—';
tempEl.className = 'sensor-value ' + (temp != null ? tempClass(temp) : '');
document.getElementById('sensor-fans').textContent =
(s.fan_speeds_pct || []).map(f => f >= 0 ? `${f}%` : '—').join(' / ') || '—';
document.getElementById('sensor-gpu-clock').textContent =
s.clock_graphics_mhz != null ? `${s.clock_graphics_mhz} MHz` : '—';
document.getElementById('sensor-mem-clock').textContent =
s.clock_memory_mhz != null ? `${s.clock_memory_mhz} MHz` : '—';
document.getElementById('sensor-mem-util').textContent =
s.memory_utilization_pct != null ? `${s.memory_utilization_pct}%` : '—';
document.getElementById('sensor-mode').textContent = s.mode || '—';
}
async function refreshStatus() {
try {
const s = await api('/api/status');
document.getElementById('gpu-name').textContent = s.gpu_name || '—';
document.getElementById('mode-badge').textContent = `Tryb: ${s.mode}`;
liveTemp = s.temperature_c;
document.getElementById('stat-fans').textContent =
(s.fan_speeds_pct || []).map(f => f >= 0 ? `${f}%` : '—').join(' / ') || '—';
document.getElementById('stat-target').textContent =
s.target_speed_pct != null ? `${s.target_speed_pct}%` : 'auto';
pushHistory(s);
updateGauges(s);
updateSensorTiles(s);
drawSparklines();
drawCurve();
} catch (e) {
console.error(e);
}
}
document.getElementById('btn-save').addEventListener('click', async () => {
try {
await api('/api/curve', { method: 'PUT', body: JSON.stringify({ points }) });
toast('Krzywa zapisana');
refreshStatus();
} catch (e) { toast(e.message, 'error'); }
});
document.getElementById('btn-reload').addEventListener('click', async () => {
try {
await api('/api/reload', { method: 'POST' });
await loadCurve();
toast('Przeładowano z pliku');
} catch (e) { toast(e.message, 'error'); }
});
document.getElementById('btn-auto').addEventListener('click', async () => {
try {
await api('/api/mode', { method: 'POST', body: JSON.stringify({ mode: 'auto' }) });
toast('Tryb auto — sterowanie driverem NVIDIA');
refreshStatus();
} catch (e) { toast(e.message, 'error'); }
});
document.getElementById('btn-max').addEventListener('click', async () => {
try {
await api('/api/mode', { method: 'POST', body: JSON.stringify({ mode: 'manual', speed: 100 }) });
toast('Manual 100%');
refreshStatus();
} catch (e) { toast(e.message, 'error'); }
});
document.getElementById('btn-add').addEventListener('click', () => {
if (points.length >= 7) { toast('Max 7 punktów', 'error'); return; }
const last = points[points.length - 1];
const temp = Math.min(TEMP_MAX - 5, (last?.temp ?? 40) + 10);
const speed = Math.min(100, (last?.speed ?? 50) + 10);
points.push({ temp, speed });
drawCurve();
});
loadCurve().catch(e => toast(e.message, 'error'));
refreshStatus();
setInterval(refreshStatus, 2000);
window.addEventListener('resize', () => { if (history.length) drawSparklines(); });
</script>
</body>
</html>