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:
@@ -0,0 +1,2 @@
|
||||
# DEPRECATED — use stacks/control-plane/.env.example
|
||||
# Copy: cp ../control-plane/.env.example ../control-plane/.env
|
||||
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -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 **30–100%**
|
||||
- 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 |
|
||||
@@ -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, 3–7 punktów) |
|
||||
| `manual` | Stała prędkość 30–100% |
|
||||
| `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 |
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"30": 50,
|
||||
"40": 65,
|
||||
"50": 80,
|
||||
"55": 90,
|
||||
"60": 100,
|
||||
"70": 100
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"30": 50,
|
||||
"40": 65,
|
||||
"50": 80,
|
||||
"55": 90,
|
||||
"60": 100,
|
||||
"70": 100
|
||||
}
|
||||
@@ -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 Wi‑Fi/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.
|
||||
@@ -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 **30–100%** (wartości 1–29 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)
|
||||
@@ -0,0 +1,163 @@
|
||||
# Częste błędy GPU Fan
|
||||
|
||||
**Cel:** Naprawić typowe problemy bez zgadywania.
|
||||
**Czas:** zależy od problemu (2–15 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 — 3–7 punktów, temp unikalne, speed 0 lub 30–100.
|
||||
|
||||
```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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.32.0
|
||||
nvidia-ml-py>=12.560.0
|
||||
Executable
+51
@@ -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}"
|
||||
Executable
+80
@@ -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"
|
||||
Executable
+85
@@ -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."
|
||||
Executable
+83
@@ -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
|
||||
Executable
+74
@@ -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 8090–8099:"
|
||||
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
|
||||
@@ -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ść: 30–100% (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>
|
||||
Reference in New Issue
Block a user