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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
tomasz-syn-grzegorza
2026-07-05 12:02:04 +00:00
commit 359afb3a59
153 changed files with 18169 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# Data disk mount point
DATA_ROOT=/data
# ComfyUI web UI (default ComfyUI port)
COMFYUI_PORT=8188
# yanwk/comfyui-boot — CUDA 12.6 slim (GPU in container)
COMFYUI_IMAGE=yanwk/comfyui-boot:cu126-slim
# Use only the discrete NVIDIA GPU
CUDA_VISIBLE_DEVICES=0
# Extra CLI args passed to ComfyUI (e.g. --fast)
CLI_ARGS=
+1
View File
@@ -0,0 +1 @@
.env
+134
View File
@@ -0,0 +1,134 @@
# ComfyUI stack
[ComfyUI](https://github.com/comfyanonymous/ComfyUI) — grafowy interfejs do generowania obrazów (Stable Diffusion, Flux, …). Stack oparty na obrazie [`yanwk/comfyui-boot`](https://github.com/YanWenKun/ComfyUI-Docker).
Zamiast [Stability Matrix](https://github.com/LykosAI/StabilityMatrix) (GUI desktop) używamy ComfyUI w Dockerze — zgodnie z filozofią headless serwera i tutoriala [03b](../../manual-tutorial/03b-system-tools.md).
## Porty
| Serwis | Port | URL |
|--------|------|-----|
| ComfyUI web UI | **8188** | `http://HOST:8188` |
| LocalAI (osobny stack) | 8070 | LLM / chat — **nie równolegle z dużym modelem SD** |
## Jak to działa
```mermaid
flowchart LR
browser["Przeglądarka :8188"]
comfyui["Kontener comfyui"]
gpu["RTX 3090 Ti"]
models["/data/apps/comfyui/models"]
browser --> comfyui
comfyui --> gpu
comfyui --> models
```
| Element | Opis |
|---------|------|
| Obraz | `yanwk/comfyui-boot:cu126-slim` |
| Konfiguracja | `.env` + `docker-compose.yml` |
| Modele | `/data/apps/comfyui/models` (puste na start — pobierz ręcznie lub przez ComfyUI-Manager) |
| Pierwszy start | Kopiuje ComfyUI do `/data/apps/comfyui/storage/` (~kilka minut) |
## Struktura
```
stacks/comfyui/
├── README.md
├── docker-compose.yml
├── .env.example
├── .gitignore
└── scripts/
├── ensure-dirs.sh
├── pull.sh
└── start.sh
```
Na dysku `/data`:
```
/data/apps/comfyui/
├── storage/ # kopia ComfyUI z obrazu (pierwszy start)
├── models/ # checkpoints, LoRA, VAE, …
├── cache/
│ ├── hf-hub/
│ └── torch-hub/
├── input/
├── output/
├── custom_nodes/
└── workflows/
```
## Workflow (bez modelu)
```bash
cd stacks/comfyui
cp .env.example .env
./scripts/pull.sh
./scripts/start.sh
```
Weryfikacja:
```bash
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8188/
# UI: http://<IP-serwera>:8188
```
## Zmienne `.env`
| Zmienna | Opis | Domyślnie |
|---------|------|-----------|
| `DATA_ROOT` | Mount dysku danych | `/data` |
| `COMFYUI_PORT` | Port na hoście | `8188` |
| `COMFYUI_IMAGE` | Obraz Docker | `yanwk/comfyui-boot:cu126-slim` |
| `CUDA_VISIBLE_DEVICES` | GPU | `0` |
| `CLI_ARGS` | Dodatkowe flagi ComfyUI | puste |
## Polityka GPU (LocalAI ↔ ComfyUI)
RTX 3090 Ti 24 GB — **jeden** duży workload GPU naraz.
Przed startem ComfyUI z dużym modelem (SDXL, Flux):
```bash
cd ../localai
docker compose --profile localai stop localai
```
Skrypt `start.sh` ostrzega, gdy `localai` jest uruchomiony.
W Server UI (port 8091) → Stop/Start stack `localai` lub `comfyui` według potrzeb.
## Modele (później)
- **ComfyUI-Manager** w UI (custom node w obrazie yanwk) — pobieranie modeli i węzłów
- Ręcznie: pliki do `/data/apps/comfyui/models/checkpoints/` (lub odpowiednie podkatalogi)
Szacunki VRAM (przy zatrzymanym LocalAI):
| Model | VRAM (orientacyjnie) |
|-------|----------------------|
| SD 1.5 | ~46 GB |
| SDXL | ~812 GB |
| Flux | ~1220 GB |
## Zarządzanie
```bash
docker compose --profile comfyui ps
docker compose --profile comfyui logs -f comfyui
docker compose --profile comfyui restart comfyui
docker compose --profile comfyui down
```
## Dokumentacja
- Tutorial: [manual-tutorial/07-comfyui-stack.md](../../manual-tutorial/07-comfyui-stack.md)
- Research Stability Matrix: [`coding-agent/STABILITYMATRIX-RESEARCH.md`](../../coding-agent/STABILITYMATRIX-RESEARCH.md)
- Wdrożenie: [`coding-agent/COMFYUI-DEPLOYMENT.md`](../../coding-agent/COMFYUI-DEPLOYMENT.md)
Upstream: [github.com/comfyanonymous/ComfyUI](https://github.com/comfyanonymous/ComfyUI)
+1
View File
@@ -0,0 +1 @@
docker-compose.yml
+33
View File
@@ -0,0 +1,33 @@
name: comfyui
services:
comfyui:
image: ${COMFYUI_IMAGE:-yanwk/comfyui-boot:cu126-slim}
container_name: comfyui
profiles:
- comfyui
restart: unless-stopped
init: true
ports:
- "${COMFYUI_PORT:-8188}:8188"
environment:
- CUDA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:-0}
- NVIDIA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:-0}
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
- CLI_ARGS=${CLI_ARGS:-}
volumes:
- ${DATA_ROOT:-/data}/apps/comfyui/storage:/root
- ${DATA_ROOT:-/data}/apps/comfyui/models:/root/ComfyUI/models
- ${DATA_ROOT:-/data}/apps/comfyui/cache/hf-hub:/root/.cache/huggingface/hub
- ${DATA_ROOT:-/data}/apps/comfyui/cache/torch-hub:/root/.cache/torch/hub
- ${DATA_ROOT:-/data}/apps/comfyui/input:/root/ComfyUI/input
- ${DATA_ROOT:-/data}/apps/comfyui/output:/root/ComfyUI/output
- ${DATA_ROOT:-/data}/apps/comfyui/custom_nodes:/root/ComfyUI/custom_nodes
- ${DATA_ROOT:-/data}/apps/comfyui/workflows:/root/ComfyUI/user/default/workflows
gpus: all
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8188/"]
interval: 1m
timeout: 30s
retries: 5
start_period: 3m
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Create ComfyUI data directories on the data disk.
ensure_comfyui_dirs() {
local data_root="${1:-/data}"
mkdir -p \
"${data_root}/apps/comfyui/storage" \
"${data_root}/apps/comfyui/models" \
"${data_root}/apps/comfyui/cache/hf-hub" \
"${data_root}/apps/comfyui/cache/torch-hub" \
"${data_root}/apps/comfyui/input" \
"${data_root}/apps/comfyui/output" \
"${data_root}/apps/comfyui/custom_nodes" \
"${data_root}/apps/comfyui/workflows"
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
ensure_comfyui_dirs "${1:-/data}"
fi
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${STACK_DIR}"
if [[ -f .env ]]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
docker compose --profile comfyui pull
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/ensure-dirs.sh"
cd "${STACK_DIR}"
if [[ ! -f .env ]]; then
echo "ERROR: .env not found. Run: cp .env.example .env"
exit 1
fi
set -a
# shellcheck disable=SC1091
source .env
set +a
DATA_ROOT="${DATA_ROOT:-/data}"
if ! mountpoint -q "${DATA_ROOT}" 2>/dev/null; then
echo "ERROR: ${DATA_ROOT} is not mounted"
exit 1
fi
ensure_comfyui_dirs "${DATA_ROOT}"
if ! docker info &>/dev/null; then
echo "ERROR: Docker is not running"
exit 1
fi
if docker ps --format '{{.Names}}' | grep -qx localai; then
echo "UWAGA: Kontener localai jest uruchomiony."
echo " Na RTX 3090 Ti 24 GB uruchom tylko jeden duży workload GPU."
echo " Zatrzymaj LocalAI przed generowaniem obrazów:"
echo " cd ../localai && docker compose --profile localai stop localai"
echo ""
read -r -p "Kontynuować mimo to? [y/N]: " confirm
if [[ "${confirm,,}" != "y" ]]; then
exit 1
fi
fi
echo "=== ComfyUI stack ==="
echo "Image: ${COMFYUI_IMAGE:-yanwk/comfyui-boot:cu126-slim}"
echo "Port: ${COMFYUI_PORT:-8188}"
echo "Models: ${DATA_ROOT}/apps/comfyui/models"
echo "GPU: CUDA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:-0}"
echo ""
echo "Pierwszy start kopiuje ComfyUI do ${DATA_ROOT}/apps/comfyui/storage/ (~kilka minut)."
echo ""
docker compose --profile comfyui pull
docker compose --profile comfyui up -d
echo ""
echo "Started. Follow logs:"
echo " docker compose --profile comfyui logs -f comfyui"
echo ""
echo "Web UI (po starcie):"
echo " http://localhost:${COMFYUI_PORT:-8188}"
echo ""
echo "Health:"
echo " curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:${COMFYUI_PORT:-8188}/"
+34
View File
@@ -0,0 +1,34 @@
# Unified control plane credentials (gpu-fan + Server UI)
# Production: /opt/control-plane/.env
# Dev: cp stacks/control-plane/.env.example stacks/control-plane/.env
# --- Shared auth (Server UI panel + gpu-fan agent proxy) ---
API_KEY=change-me-generate-with-openssl-rand-hex-16
# --- Server UI ---
SERVER_UI_HOST=0.0.0.0
SERVER_UI_PORT=8091
REPO_ROOT=/home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal
DOCKER_GID=999
# GPU fan agent URL (Server UI proxy target)
GPU_FAN_AGENT_URL=http://127.0.0.1:18090
# --- gpu-fan agent (NVML daemon) ---
GPU_FAN_API_HOST=127.0.0.1
GPU_FAN_API_PORT=18090
# Fan curve config (created by install.sh under /etc/gpu-fan/)
CURVE_PATH=/etc/gpu-fan/curve.json
POLL_INTERVAL=2.0
GPU_INDEX=0
# --- File explorer (Server UI) ---
FILE_EXPLORER_ROOT=/
FILE_EXPLORER_MAX_BYTES=2097152
# --- CLI terminal (Server UI) ---
CLI_ENABLED=1
CLI_SHELL=/bin/bash
CLI_DEFAULT_CWD=/home/tomasz-syn-grzegorza
CLI_MAX_SESSIONS=5
+1
View File
@@ -0,0 +1 @@
.env
+127
View File
@@ -0,0 +1,127 @@
"""Load unified control-plane environment from file + os.environ."""
from __future__ import annotations
import os
from pathlib import Path
_ENV_OVERRIDE_KEYS = (
"API_KEY",
"SERVER_UI_HOST",
"SERVER_UI_PORT",
"REPO_ROOT",
"DOCKER_GID",
"GPU_FAN_AGENT_URL",
"GPU_FAN_API_HOST",
"GPU_FAN_API_PORT",
"GPU_FAN_HOST",
"GPU_FAN_PORT",
"CURVE_PATH",
"POLL_INTERVAL",
"GPU_INDEX",
"DRY_RUN",
"FILE_EXPLORER_ROOT",
"FILE_EXPLORER_MAX_BYTES",
"CLI_ENABLED",
"CLI_SHELL",
"CLI_DEFAULT_CWD",
"CLI_MAX_SESSIONS",
)
def _parse_env_file(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
if not path.is_file():
return values
try:
text = path.read_text(encoding="utf-8")
except OSError:
# e.g. /opt/control-plane/.env is root-only; systemd still injects via os.environ
return values
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, val = line.partition("=")
values[key.strip()] = val.strip()
return values
def _merge_into(target: dict[str, str], source: dict[str, str]) -> None:
for key, val in source.items():
if val:
target[key] = val
def _is_production_stack_dir(stack_dir: Path) -> bool:
try:
return stack_dir.resolve().parts[1:2] == ("opt",)
except IndexError:
return False
def control_plane_env_paths(stack_dir: Path) -> list[Path]:
"""Candidate .env files in load order (earlier = lower priority among files)."""
paths: list[Path] = []
custom = os.environ.get("CONTROL_PLANE_ENV", "").strip()
if custom:
paths.append(Path(custom))
if _is_production_stack_dir(stack_dir):
# Production (/opt/server-ui, /opt/gpu-fan): single canonical file only.
prod = Path("/opt/control-plane/.env")
if prod not in paths:
paths.append(prod)
docker_repo = Path("/repo/stacks/control-plane/.env")
if docker_repo.is_file() and docker_repo not in paths:
paths.append(docker_repo)
return paths
# Dev (repo stacks/*): stacks/control-plane/.env only — no legacy per-service .env.
repo_control_plane = stack_dir.parent / "control-plane" / ".env"
if repo_control_plane.is_file() and repo_control_plane not in paths:
paths.append(repo_control_plane)
docker_repo = Path("/repo/stacks/control-plane/.env")
if docker_repo.is_file() and docker_repo not in paths:
paths.append(docker_repo)
return paths
def api_key_source(stack_dir: Path, values: dict[str, str]) -> str:
"""Human-readable hint for logs (no secret values)."""
if os.environ.get("API_KEY"):
return "systemd/os.environ"
for path in control_plane_env_paths(stack_dir):
parsed = _parse_env_file(path)
if parsed.get("API_KEY"):
return str(path)
if values.get("API_KEY"):
return "merged env"
return "not configured"
def load_control_plane_env(stack_dir: Path) -> dict[str, str]:
"""Merge env files then apply os.environ (systemd EnvironmentFile wins)."""
values: dict[str, str] = {}
for path in control_plane_env_paths(stack_dir):
_merge_into(values, _parse_env_file(path))
for key in _ENV_OVERRIDE_KEYS:
if key in os.environ:
values[key] = os.environ[key]
values.setdefault("GPU_FAN_AGENT_URL", "http://127.0.0.1:18090")
return values
def ensure_control_plane_import_path() -> None:
"""Add /opt/control-plane to sys.path for production imports."""
import sys
opt = "/opt/control-plane"
if opt not in sys.path:
sys.path.insert(0, opt)
repo = Path(__file__).resolve().parent
repo_str = str(repo)
if repo_str not in sys.path:
sys.path.insert(0, repo_str)
+2
View File
@@ -0,0 +1,2 @@
# DEPRECATED — use stacks/control-plane/.env.example
# Copy: cp ../control-plane/.env.example ../control-plane/.env
+4
View File
@@ -0,0 +1,4 @@
.env
.venv/
__pycache__/
*.pyc
+154
View File
@@ -0,0 +1,154 @@
# GPU Fan Control stack
Sterowanie wentylatorami **RTX 3090 Ti** na headless Ubuntu przez NVML — bez `nvidia-settings` i bez GUI.
**Panel webowy** jest w **Server UI** (`http://<host>:8091` → zakładka **GPU Fan**). Ten stack uruchamia tylko **agenta API** na localhost.
## Porty
| Serwis | Port | Dostęp |
|--------|------|--------|
| GPU Fan agent API | **18090** | `127.0.0.1` tylko (systemd, root) |
| GPU Fan UI | — | Server UI **:8091** (zakładka GPU Fan) |
Port **8090** nie jest już używany w produkcji.
## Jak to działa
```mermaid
flowchart LR
browser["Przeglądarka :8091"]
serverUI["server-ui"]
agent["fan_daemon.py :18090"]
nvml["NVML / pynvml"]
gpu["RTX 3090 Ti"]
browser --> serverUI
serverUI -->|"proxy /api/gpu-fan"| agent
agent --> nvml
nvml --> gpu
```
| Element | Opis |
|---------|------|
| Agent | `fan_daemon.py` — pętla NVML + API REST |
| Dev UI | `app.py` — monolit z UI (tylko dev / DRY_RUN) |
| Logika | `fan_controller.py` — interpolacja krzywej |
| Konfiguracja | `/etc/gpu-fan/curve.json` |
| systemd | `gpu-fan.service` (root, auto-restart) |
## Wymagania
- Sterownik NVIDIA ≥ 520 (testowane: **595-server-open**)
- Root / sudo (zapis NVML wymaga uprawnień root)
- Python 3 + venv (instalowane przez `install.sh`)
- **Server UI** na :8091 (proxy do agenta)
## Szybki start
```bash
cd stacks/gpu-fan
cp ../control-plane/.env.example ../control-plane/.env
sudo scripts/install.sh
sudo systemctl start gpu-fan
cd ../server-ui
sudo scripts/install.sh
```
Panel: `http://<IP-serwera>:8091` → zakładka **GPU Fan**.
Po zmianach w kodzie:
```bash
sudo scripts/install.sh && sudo systemctl restart gpu-fan
cd ../server-ui && sudo scripts/install.sh && sudo systemctl restart server-ui
```
Konfiguracja: produkcja `/opt/control-plane/.env`, dev `stacks/control-plane/.env`.
### Klucz API
Jeden wspólny `API_KEY` w `/opt/control-plane/.env` — auth panelu Server UI i agenta gpu-fan (proxy `/api/gpu-fan/*`).
W panelu Server UI wpisz ten sam klucz w polu **API Key** (lub `?api_key=...` w URL).
## Preset max cooling
Domyślna krzywa w [`curve.default.json`](curve.default.json):
| Temp | Speed |
|------|-------|
| 30°C | 50% |
| 40°C | 65% |
| 50°C | 80% |
| 55°C | 90% |
| 60°C | 100% |
| 70°C+ | 100% |
## Tryby
| Tryb | Opis |
|------|------|
| `curve` | Krzywa z JSON — interpolacja liniowa |
| `manual` | Stała prędkość (np. 100% awaryjnie) |
| `auto` | Oddaje sterowanie driverowi NVIDIA |
## API agenta (localhost :18090)
| Endpoint | Metoda | Opis |
|----------|--------|------|
| `/api/status` | GET | Temperatura, wentylatory, moc, tryb |
| `/api/curve` | GET/PUT | Odczyt / zapis krzywej |
| `/api/mode` | POST | `{"mode":"auto\|curve\|manual","speed":100}` |
| `/api/reload` | POST | Przeładuj `curve.json` (jak `SIGHUP`) |
Z LAN używaj proxy Server UI: `/api/gpu-fan/status`, `/api/gpu-fan/curve`, itd.
## Ograniczenia NVIDIA
- `nvidia-smi` **nie steruje** wentylatorami — tylko odczyt
- API akceptuje **0%** (= auto) lub **30100%**
- Po `systemctl stop gpu-fan` wentylatory wracają do trybu auto drivera
## Struktura
```
stacks/gpu-fan/
├── README.md
├── fan_daemon.py # produkcja — agent API
├── app.py # dev — UI + API (opcjonalnie)
├── fan_controller.py
├── curve.default.json
├── requirements.txt
├── gpu-fan.service
├── .env.example
├── static/index.html # referencja UI (wbudowane w server-ui)
└── scripts/
├── install.sh
├── enable-lan.sh # konfiguruje agent + wskazówka :8091
└── status.sh
```
## Troubleshooting
**`Insufficient Permissions`** — uruchom jako root (`sudo systemctl start gpu-fan`).
**Panel GPU Fan pusty / 502** — sprawdź agent: `curl -s http://127.0.0.1:18090/api/status -H "X-API-Key: ..."`
**Port 18090 zajęty**`scripts/status.sh` / `sudo scripts/status.sh --cleanup`
**Przeładuj krzywą bez restartu:**
```bash
sudo systemctl reload gpu-fan
```
## Dokumentacja
| Dokument | Opis |
|----------|------|
| [docs/00-START-TUTAJ.md](docs/00-START-TUTAJ.md) | Mapa — zacznij tutaj |
| [docs/02-OTWIERANIE-UI-W-PRZEGLADARCE.md](docs/02-OTWIERANIE-UI-W-PRZEGLADARCE.md) | Panel w Server UI :8091 |
| [docs/05-DLACZEGO-NIE-DOCKER.md](docs/05-DLACZEGO-NIE-DOCKER.md) | Dlaczego host, nie Docker |
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""GPU fan control web UI + NVML daemon (single process)."""
from __future__ import annotations
import logging
import os
import signal
import sys
import threading
from pathlib import Path
from typing import Any
import uvicorn
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from fan_controller import (
FanControlError,
FanController,
MAX_CURVE_POINTS,
MIN_CURVE_POINTS,
MIN_FAN_SPEED,
MAX_FAN_SPEED,
curve_to_dict,
parse_curve,
)
STACK_DIR = Path(__file__).resolve().parent
# Unified control-plane env (see stacks/control-plane/env_loader.py)
for _cp in ("/opt/control-plane", "/repo/stacks/control-plane", str(STACK_DIR.parent / "control-plane")):
if _cp not in sys.path and Path(_cp).exists():
sys.path.insert(0, _cp)
from env_loader import load_control_plane_env # noqa: E402
STATIC_DIR = STACK_DIR / "static"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
log = logging.getLogger(__name__)
def _is_localhost_bind(host: str) -> bool:
return host.strip().lower() in ("127.0.0.1", "localhost", "::1")
def _resolve_host_port(env: dict[str, str]) -> tuple[str, int]:
host = env.get("GPU_FAN_API_HOST") or env.get("GPU_FAN_HOST", "0.0.0.0")
port_raw = env.get("GPU_FAN_API_PORT") or env.get("GPU_FAN_PORT", "8090")
return host, int(port_raw)
ENV = load_control_plane_env(STACK_DIR)
HOST, PORT = _resolve_host_port(ENV)
API_KEY = ENV.get("API_KEY", "")
CURVE_PATH = Path(ENV.get("CURVE_PATH", "/etc/gpu-fan/curve.json"))
POLL_INTERVAL = float(ENV.get("POLL_INTERVAL", "2.0"))
GPU_INDEX = int(ENV.get("GPU_INDEX", "0"))
DRY_RUN = ENV.get("DRY_RUN", "").lower() in ("1", "true", "yes")
controller = FanController(
curve_path=CURVE_PATH,
gpu_index=GPU_INDEX,
poll_interval=POLL_INTERVAL,
)
controller.dry_run = DRY_RUN
app = FastAPI(title="GPU Fan Control", version="1.0.0")
class CurvePoint(BaseModel):
temp: int = Field(ge=0, le=120)
speed: int = Field(ge=0, le=100)
class CurveUpdate(BaseModel):
points: list[CurvePoint] = Field(min_length=MIN_CURVE_POINTS, max_length=MAX_CURVE_POINTS)
class ModeUpdate(BaseModel):
mode: str
speed: int | None = Field(default=None, ge=MIN_FAN_SPEED, le=MAX_FAN_SPEED)
def require_auth(request: Request) -> None:
if not API_KEY:
return
key = request.headers.get("X-API-Key", "")
if key != API_KEY:
raise HTTPException(status_code=401, detail="Invalid or missing API key")
@app.get("/")
def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
@app.get("/api/status")
def api_status(_: None = Depends(require_auth)) -> dict[str, Any]:
return controller.get_metrics()
@app.get("/api/curve")
def api_get_curve(_: None = Depends(require_auth)) -> dict[str, Any]:
points = controller.get_curve()
return {"points": [{"temp": t, "speed": s} for t, s in points]}
@app.put("/api/curve")
def api_put_curve(body: CurveUpdate, _: None = Depends(require_auth)) -> dict[str, Any]:
points = [(p.temp, p.speed) for p in body.points]
try:
parse_curve(curve_to_dict(points))
controller.save_curve_file(points)
controller.set_mode("curve")
except FanControlError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "curve": curve_to_dict(controller.get_curve())}
@app.post("/api/mode")
def api_set_mode(body: ModeUpdate, _: None = Depends(require_auth)) -> dict[str, Any]:
try:
controller.set_mode(body.mode, body.speed)
if body.mode != "auto":
controller.update_once()
except FanControlError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "mode": controller.mode, "manual_speed": controller.manual_speed}
@app.post("/api/reload")
def api_reload(_: None = Depends(require_auth)) -> dict[str, Any]:
try:
controller.reload_curve()
except FanControlError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "curve": curve_to_dict(controller.get_curve())}
def run_daemon_thread() -> None:
controller.run_loop()
def main() -> None:
if os.geteuid() != 0 and not DRY_RUN:
log.error("GPU fan control requires root (NVML write access). Run with sudo.")
sys.exit(1)
if not _is_localhost_bind(HOST) and not API_KEY:
log.error(
"API_KEY is required when GPU_FAN_HOST=%s (LAN/public bind). "
"Set API_KEY in .env",
HOST,
)
sys.exit(1)
controller.init_nvml()
def shutdown_handler(signum: int, _frame: object) -> None:
log.info("Received signal %s", signum)
if signum == signal.SIGHUP:
try:
controller.reload_curve()
except FanControlError as exc:
log.error("Curve reload failed: %s", exc)
return
controller.shutdown()
sys.exit(0)
signal.signal(signal.SIGTERM, shutdown_handler)
signal.signal(signal.SIGINT, shutdown_handler)
signal.signal(signal.SIGHUP, shutdown_handler)
thread = threading.Thread(target=run_daemon_thread, daemon=True)
thread.start()
if _is_localhost_bind(HOST):
log.info("Web UI at http://127.0.0.1:%d", PORT)
else:
log.info("Web UI listening on 0.0.0.0:%d (LAN — API key required)", PORT)
uvicorn.run(app, host=HOST, port=PORT, log_level="info")
if __name__ == "__main__":
if os.environ.get("GPU_FAN_LEGACY_UI", "").lower() in ("1", "true", "yes"):
main()
else:
from fan_daemon import main as daemon_main
daemon_main()
@@ -0,0 +1,228 @@
# GPU Fan — Docker vs host (raport techniczny)
**Data:** 2026-07-04
**Stack:** `stacks/gpu-fan/`
**Host docelowy:** gmktec-k11, RTX 3090 Ti, Ubuntu headless
---
## 1. Executive summary
**GPU Fan musi działać na hoście jako usługa systemd (root), nie w Dockerze.**
Aplikacja steruje wentylatorami karty NVIDIA przez zapis do NVML (`nvmlDeviceSetFanSpeed_v2`, `nvmlDeviceSetFanControlPolicy`). Na kartach GeForce wymaga to uprawnień root i bezpośredniego dostępu do sterownika hosta. Repo nie zawiera Dockerfile ani compose dla tego stacku — jedyny wspierany model to `sudo scripts/install.sh``/opt/gpu-fan` + `gpu-fan.service`.
Docker jest teoretycznie możliwy (privileged container, host network, mount `/dev/nvidia*`), ale kruchy, nieutrzymywany i niezgodny z architekturą ubuntu-bare-metal (gpu-fan jako daemon sprzętowy obok workloadów AI w kontenerach).
---
## 2. Co robi aplikacja
| Komponent | Plik | Rola |
|-----------|------|------|
| Pętla sterowania | `fan_controller.py` | Odczyt temp/mocy, interpolacja krzywej, zapis prędkości wentylatorów |
| Web UI + API | `app.py` | FastAPI na porcie **8090**, wątek daemon NVML |
| UI statyczne | `static/index.html` | Wykres krzywej, status live, edycja trybu |
| Krzywa | `/etc/gpu-fan/curve.json` | Mapowanie temp °C → speed % |
### Tryby pracy
| Tryb | Zachowanie |
|------|------------|
| `curve` | Prędkość z krzywej JSON (interpolacja liniowa, 37 punktów) |
| `manual` | Stała prędkość 30100% |
| `auto` | Przywraca politykę drivera NVIDIA (`NVML_FAN_POLICY_TEMPERATURE_CONTINOUS_SW`) |
### API (port 8090)
| Endpoint | Metoda | Uwagi |
|----------|--------|-------|
| `/` | GET | Web UI |
| `/api/status` | GET | Metryki GPU + tryb |
| `/api/curve` | GET/PUT | Odczyt/zapis krzywej |
| `/api/mode` | POST | Zmiana trybu |
| `/api/reload` | POST | Przeładowanie `curve.json` (jak SIGHUP) |
Nagłówek `X-API-Key` wymagany gdy `GPU_FAN_HOST` ≠ localhost (domyślnie LAN bind `0.0.0.0`).
### Shutdown
Przy `SIGTERM` / `SIGINT` kontroler wywołuje `_restore_auto_policy()` przed `nvmlShutdown()` — wentylatory nie zostają w trybie manual po zatrzymaniu usługi.
---
## 3. Zależności sprzętowe i software
| Zależność | Wymagana | Uwagi |
|-----------|----------|-------|
| NVIDIA driver ≥ 520 | Tak | Testowane: 595-server-open |
| `nvidia-ml-py` (pynvml) | Tak | Jedyny interfejs sterowania w kodzie |
| `nvidia-smi` | Nie w kodzie | Tylko weryfikacja w dokumentacji; **nie ustawia** wentylatorów |
| Root (euid 0) | Tak | `app.py` kończy się błędem bez root (chyba że `DRY_RUN=true`) |
| `nvidia-persistenced` | Zalecane | `gpu-fan.service` After=/Wants= |
| IPMI | Nie | Brak referencji w kodzie |
| D-Bus | Nie | Brak referencji |
| X11 / nvidia-settings | Nie | Headless — celowo unikane |
| Python 3 + venv | Tak | FastAPI, uvicorn |
### Ścieżki produkcyjne
| Ścieżka | Zawartość |
|---------|-----------|
| `/opt/gpu-fan/` | Kod aplikacji (rsync z repo przez `install.sh`) |
| `/opt/control-plane/.env` | `API_KEY`, `GPU_FAN_API_*`, `CURVE_PATH`, … |
| `/etc/gpu-fan/curve.json` | Krzywa temp → speed |
| `/etc/systemd/system/gpu-fan.service` | Unit systemd |
**Uwaga:** `stacks/control-plane/.env` w repo ≠ `/opt/control-plane/.env``setup-control-plane-env.sh` migruje i synchronizuje.
---
## 4. Obecny model wdrożenia
```
repo stacks/gpu-fan/
│ sudo scripts/install.sh
/opt/gpu-fan/ ← kod + .venv + .env
/etc/gpu-fan/curve.json
/etc/systemd/system/gpu-fan.service
│ systemctl enable --now gpu-fan
Proces root: python app.py
├── wątek: fan_controller.run_loop() (co POLL_INTERVAL s)
└── uvicorn: 0.0.0.0:8090
```
Skrypty pomocnicze:
| Skrypt | Cel |
|--------|-----|
| `scripts/install.sh` | Instalacja produkcyjna |
| `scripts/enable-lan.sh` | `GPU_FAN_HOST=0.0.0.0`, API_KEY, restart |
| `scripts/start.sh` | Foreground debug (wymaga stop systemd) |
| `scripts/status.sh` | Diagnostyka portu/procesu |
| `scripts/self-test.sh` | Test krzywej, NVML read, API dry-run |
---
## 5. Analiza Docker — dlaczego nie
### Brak artefaktów w repo
- Brak `Dockerfile`, `compose.yaml`, profilu w `server-ui/stacks.yaml`
- Inne stacki GPU (ComfyUI, LocalAI, vLLM) używają Docker; gpu-fan jest wyjątkiem celowym
### Blokery techniczne
| Bloker | Szczegóły |
|--------|-----------|
| NVML write na GeForce | `nvmlDeviceSetFanSpeed_v2` wymaga root; kontenery GPU (`NVIDIA_DRIVER_CAPABILITIES=compute,utility`) nie gwarantują zapisu fan policy |
| Coupling do host driver | Wersja NVML w kontenerze musi pasować do kernel drivera hosta |
| Lifecycle | `docker kill` / crash kontenera może pominąć `_restore_auto_policy()` → wentylatory w manual |
| `nvidia-persistenced` | Daemon na hoście; kontener nie zarządza persystencją GPU |
| Privileged + host network | Minimalny „Docker” wyglądałby jak host install z dodatkową warstwą — bez korzyści |
### Hipotetyczny kontener (nie implementować)
Gdyby ktoś eksperymentował:
```yaml
# NIE WDRAŻAĆ — tylko dokumentacja ryzyka
privileged: true
network_mode: host
user: root
pid: host # opcjonalnie, nadal ryzykowne
volumes:
- /etc/gpu-fan:/etc/gpu-fan
devices:
- /dev/nvidia0
- /dev/nvidiactl
- /dev/nvidia-uvm
```
Nawet wtedy sukces nie jest gwarantowany na RTX 3090 Ti; repo nie będzie tego utrzymywać.
---
## 6. Współistnienie z Docker AI stacks
```
┌─────────────────────────────────────────────────┐
│ Host (gmktec-k11) │
│ │
│ gpu-fan.service (root, :8090) ──NVML──► GPU │
│ │
│ ┌─────────────┐ ┌──────────┐ ┌──────────┐ │
│ │ comfyui │ │ localai │ │ vllm │ │
│ │ :8188 │ │ :8070 │ │ :8000 │ │
│ └─────────────┘ └──────────┘ └──────────┘ │
│ Docker containers (GPU compute) │
└─────────────────────────────────────────────────┘
```
- ComfyUI/LocalAI obciążają GPU → temperatura rośnie → gpu-fan reaguje krzywą
- Zatrzymaj gpu-fan **nie** jest wymagane przed startem kontenerów AI
- Polityka GPU w Server UI (jeden duży workload) dotyczy LLM/SD, nie gpu-fan
- Firewall (NPMPlus): port 8090 nie powinien być publiczny; LAN + API key
Źródło: `manual-tutorial/06-gpu-fan-control.md` — „gpu-fan.service na hoście (NVML, root)”.
---
## 7. Checklist operacyjny (coding-agent)
### Instalacja / upgrade
- [ ] `nvidia-smi` działa
- [ ] `sudo scripts/install.sh` z katalogu `stacks/gpu-fan`
- [ ] `sudo scripts/enable-lan.sh` jeśli dostęp z LAN
- [ ] `sudo systemctl enable --now gpu-fan`
- [ ] `curl -s http://127.0.0.1:18090/api/status -H "X-API-Key: $(grep ^API_KEY= /opt/control-plane/.env | cut -d= -f2)"` → JSON z `temperature_c`
### Po zmianie kodu
```bash
sudo scripts/install.sh && sudo systemctl restart gpu-fan
```
### Diagnostyka
```bash
systemctl status gpu-fan
journalctl -u gpu-fan -f
scripts/status.sh
sudo scripts/status.sh --cleanup # tylko gdy port zajęty przez osierocony proces
```
### Czego nie robić
- Nie uruchamiać `start.sh` i systemd jednocześnie (port 8090)
- Nie pakować gpu-fan do Docker bez nowego ADR i testów na sprzęcie
- Nie edytować tylko `stacks/control-plane/.env` — produkcja czyta `/opt/control-plane/.env`
---
## 8. Rekomendacja
| Decyzja | Uzasadnienie |
|---------|--------------|
| **Zostaw na hoście (systemd)** | Wspierane, przetestowane, bezpieczny shutdown, zgodne z tutorial 06 |
| **Nie dodawaj Docker** | Brak wartości, wysokie ryzyko, duplikacja root access |
| **Dokumentacja użytkownika** | `docs/` — kroki instalacji i troubleshooting |
| **Ten raport** | `coding-agent/DOCKER-VS-HOST-REPORT.md` — odniesienie dla agentów |
---
## 9. Pliki źródłowe (indeks)
| Plik | Kluczowe fragmenty |
|------|-------------------|
| `app.py:161-164` | Wymóg root |
| `fan_controller.py:250-279` | NVML fan policy + speed write |
| `fan_controller.py:335-343` | Shutdown → auto policy |
| `gpu-fan.service` | User=root, After=nvidia-persistenced |
| `scripts/install.sh` | rsync → /opt/gpu-fan |
| `requirements.txt` | fastapi, uvicorn, nvidia-ml-py |
+8
View File
@@ -0,0 +1,8 @@
{
"30": 50,
"40": 65,
"50": 80,
"55": 90,
"60": 100,
"70": 100
}
+8
View File
@@ -0,0 +1,8 @@
{
"30": 50,
"40": 65,
"50": 80,
"55": 90,
"60": 100,
"70": 100
}
+56
View File
@@ -0,0 +1,56 @@
# GPU Fan — START TUTAJ
**Cel:** Wiedzieć od czego zacząć i czy ta aplikacja idzie do Dockera.
**Czas:** 2 minuty czytania
**Wymagania:** Brak (to tylko mapa dokumentacji)
---
## Jednozdaniowa odpowiedź
**GPU Fan NIE działa w Dockerze — instalujesz go na hoście (systemd), raz, i zapominasz.**
Steruje wentylatorami karty graficznej. Reszta (ComfyUI, LocalAI) może być w Dockerze — to osobne programy.
---
## Mapa dokumentacji
| Plik | Kiedy czytać |
|------|----------------|
| [01-INSTALACJA-KROK-PO-KROKU.md](01-INSTALACJA-KROK-PO-KROKU.md) | Pierwsza instalacja na serwerze |
| [02-OTWIERANIE-UI-W-PRZEGLADARCE.md](02-OTWIERANIE-UI-W-PRZEGLADARCE.md) | Chcesz otworzyć panel w przeglądarce |
| [03-KRZYWa-I-TRYBY.md](03-KRZYWa-I-TRYBY.md) | Chcesz zmienić chłodzenie / tryb wentylatorów |
| [04-CZESTE-BLEDY.md](04-CZESTE-BLEDY.md) | Coś nie działa |
| [05-DLACZEGO-NIE-DOCKER.md](05-DLACZEGO-NIE-DOCKER.md) | „A czemu nie w kontenerze?” |
Dla agentów AI / deweloperów: [../coding-agent/DOCKER-VS-HOST-REPORT.md](../coding-agent/DOCKER-VS-HOST-REPORT.md)
---
## Szybka ścieżka (jeśli się spieszysz)
```bash
cd ~/cursor/ubuntu-bare-metal/stacks/gpu-fan
nvidia-smi # musi pokazać kartę — jeśli nie, najpierw napraw driver
sudo scripts/install.sh
sudo scripts/enable-lan.sh
sudo systemctl start gpu-fan
sudo systemctl status gpu-fan
```
Potem: [02-OTWIERANIE-UI-W-PRZEGLADARCE.md](02-OTWIERANIE-UI-W-PRZEGLADARCE.md)
---
## Co zobaczysz gdy działa
- `systemctl status gpu-fan`**active (running)**
- W przeglądarce: panel z temperaturą GPU i wykresem krzywej (port **8090**)
- Wentylatory reagują na temperaturę (w trybie **curve**)
---
## Co zrobić gdy nie działa
→ [04-CZESTE-BLEDY.md](04-CZESTE-BLEDY.md)
@@ -0,0 +1,127 @@
# Instalacja GPU Fan — krok po kroku
**Cel:** Zainstalować sterowanie wentylatorami GPU na serwerze.
**Czas:** ~10 minut
**Wymagania:** `sudo`, działający `nvidia-smi`, internet (pip)
---
## Krok 0 — Sprawdź kartę graficzną
Na serwerze wpisz:
```bash
nvidia-smi
```
**Co zobaczysz gdy OK:** tabela z nazwą karty (np. RTX 3090 Ti), driverem, temperaturą.
**Gdy nie działa:** najpierw napraw sterownik NVIDIA. Bez tego GPU Fan nie ma sensu.
---
## Krok 1 — Wejdź do katalogu stacku
```bash
cd ~/cursor/ubuntu-bare-metal/stacks/gpu-fan
```
(Ścieżka może być inna — ważne żebyś był w folderze z plikiem `app.py`.)
---
## Krok 2 — Zainstaluj na hoście (NIE Docker)
```bash
sudo scripts/install.sh
```
Skrypt:
- kopiuje pliki do `/opt/gpu-fan`
- tworzy `/etc/gpu-fan/curve.json` (domyślna krzywa chłodzenia)
- tworzy `/opt/control-plane/.env` z losowym `API_KEY` (przy pierwszej instalacji)
- instaluje usługę systemd `gpu-fan`
**Co zobaczysz gdy OK:** komunikat „Installed to /opt/gpu-fan” i instrukcja startu.
---
## Krok 3 — Włącz dostęp z sieci lokalnej (LAN)
```bash
sudo scripts/enable-lan.sh
```
Ustawia nasłuch na `0.0.0.0:8090` i upewnia się że jest `API_KEY`.
---
## Krok 4 — Uruchom usługę
```bash
sudo systemctl start gpu-fan
sudo systemctl enable gpu-fan
```
`enable` = start automatyczny po restarcie serwera.
---
## Krok 5 — Sprawdź czy działa
```bash
sudo systemctl status gpu-fan
```
**Co zobaczysz gdy OK:** `Active: active (running)` na zielono.
Logi na żywo:
```bash
journalctl -u gpu-fan -f
```
(Wyjdź: Ctrl+C)
Test API (skopiuj całość):
```bash
API_KEY=$(grep ^API_KEY= /opt/control-plane/.env | cut -d= -f2)
curl -s http://127.0.0.1:8090/api/status -H "X-API-Key: $API_KEY" | head -c 200
```
**Co zobaczysz gdy OK:** JSON z `"temperature_c"` i `"mode"`.
---
## Krok 6 — Otwórz panel w przeglądarce
→ [02-OTWIERANIE-UI-W-PRZEGLADARCE.md](02-OTWIERANIE-UI-W-PRZEGLADARCE.md)
---
## Po aktualizacji kodu w repo
Jeśli zmieniłeś pliki w `stacks/gpu-fan/`:
```bash
cd ~/cursor/ubuntu-bare-metal/stacks/gpu-fan
sudo scripts/install.sh
sudo systemctl restart gpu-fan
```
---
## Czego NIE robić
| Nie rób tego | Dlaczego |
|--------------|----------|
| `sudo scripts/start.sh` + `systemctl start gpu-fan` naraz | Dwa programy na porcie 8090 — błąd |
| Instalacja w Dockerze | Nie wspierane — patrz [05-DLACZEGO-NIE-DOCKER.md](05-DLACZEGO-NIE-DOCKER.md) |
| Edycja tylko `stacks/control-plane/.env` | Produkcja czyta `/opt/control-plane/.env` |
---
## Co zrobić gdy nie działa
→ [04-CZESTE-BLEDY.md](04-CZESTE-BLEDY.md)
@@ -0,0 +1,100 @@
# Otwieranie panelu GPU Fan w przeglądarce
**Cel:** Wejść do panelu sterowania wentylatorami GPU z komputera w sieci LAN.
**Czas:** ~3 minuty
**Wymagania:** Działające usługi `gpu-fan` (agent) i `server-ui` (panel)
> UI gpu-fan **nie** jest już na porcie 8090. Użyj **Server UI** na porcie **8091**, zakładka **GPU Fan**.
---
## Krok 1 — Sprawdź IP serwera
Na serwerze:
```bash
hostname -I | awk '{print $1}'
```
Przykład wyniku: `192.168.100.90` — to Twój adres w LAN.
---
## Krok 2 — Pobierz klucz API
Na serwerze:
```bash
grep ^API_KEY= /opt/control-plane/.env
# lub ten sam klucz z agenta:
grep ^API_KEY= /opt/control-plane/.env
```
Przykład: `API_KEY=a1b2c3d4e5f6...` — skopiuj część **po** znaku `=`.
---
## Krok 3 — Otwórz URL w przeglądarce
Z innego komputera w tej samej sieci WiFi/LAN:
```
http://192.168.100.90:8091/?api_key=WKLEJ_TUTAJ_KLUCZ#gpu-fan
```
Zamień:
- `192.168.100.90` → IP z kroku 1
- `WKLEJ_TUTAJ_KLUCZ` → wartość z kroku 2
Kliknij zakładkę **GPU Fan** (lub użyj `#gpu-fan` w URL).
**Co zobaczysz gdy OK:** wykres krzywej wentylatorów, temperatura GPU, monitoring mocy/VRAM.
Klucz zapisze się w przeglądarce (localStorage `server-ui-api-key`) — przy kolejnych wizytach wystarczy `http://IP:8091/#gpu-fan`.
---
## Dostęp tylko z samego serwera
```
http://127.0.0.1:8091/#gpu-fan
```
(API key może być wymagany gdy `SERVER_UI_HOST=0.0.0.0`.)
---
## Dostęp przez SSH (opcjonalnie)
Na **swoim** laptopie:
```bash
ssh -L 8091:127.0.0.1:8091 TWOJ_USER@192.168.100.90
```
W przeglądarce na laptopie: `http://localhost:8091/#gpu-fan`
---
## Co zrobić gdy nie działa
| Objaw | Co zrobić |
|-------|-----------|
| Strona się nie ładuje | `sudo systemctl status server-ui` — czy **running**? |
| Zakładka GPU Fan pusta / błąd 502 | `sudo systemctl status gpu-fan` — agent na :18090 |
| `401` / brak danych | Zły klucz API — sprawdź `/opt/control-plane/.env` |
| Agent nie odpowiada | `curl -s http://127.0.0.1:18090/api/status -H "X-API-Key: KLUCZ"` |
Test proxy:
```bash
curl -s http://127.0.0.1:8091/api/gpu-fan/health -H "X-API-Key: $(grep ^API_KEY= /opt/control-plane/.env | cut -d= -f2)"
```
---
## Bezpieczeństwo (krótko)
- Panel **nie** wystawiaj na internet bez firewall / VPN.
- W LAN używaj `API_KEY` w Server UI.
- Agent gpu-fan (:18090) nasłuchuje tylko na localhost.
+112
View File
@@ -0,0 +1,112 @@
# Krzywa temperatury i tryby wentylatorów
**Cel:** Zrozumieć jak GPU Fan reguluje wentylatory i kiedy zmienić tryb.
**Czas:** ~5 minut
**Wymagania:** Działający panel (patrz [02-OTWIERANIE-UI-W-PRZEGLADARCE.md](02-OTWIERANIE-UI-W-PRZEGLADARCE.md))
---
## Trzy tryby — który wybrać
| Tryb | Kiedy używać | Co robi |
|------|--------------|---------|
| **curve** | Normalna praca (domyślny) | Im wyższa temp GPU, tym szybsze wentylatory — według krzywej |
| **manual** | Awaryjnie: „na full” lub stała prędkość | Ustawiasz np. 100% ręcznie |
| **auto** | Chcesz oddać sterowanie driverowi NVIDIA | Jak bez GPU Fan — driver sam decyduje |
**Rekomendacja:** zostaw **curve** przy obciążeniu AI (ComfyUI, LocalAI). Użyj **manual 100%** tylko gdy karta się przegrzewa i chcesz na chwilę max chłodzenia.
---
## Jak działa krzywa (curve)
Plik: `/etc/gpu-fan/curve.json`
Przykład (uproszczony):
```json
{
"30": 50,
"40": 65,
"50": 80,
"55": 90,
"60": 100,
"70": 100
}
```
Znaczenie: przy **50°C** wentylatory ~**80%**, przy **60°C** i wyżej → **100%**.
Między punktami program **interpoluje** (płynna zmiana).
### Zasady (ważne)
- **3 do 7** punktów
- Temperatury **rosnąco**, każda **unikalna**
- Prędkość: **0** (= auto w API) albo **30100%** (wartości 129 są niedozwolone)
---
## Domyślna krzywa „max cooling”
Po instalacji masz agresywne chłodzenie (bezpieczne dla RTX 3090 Ti pod obciążeniem):
| Temp GPU | Wentylatory |
|----------|-------------|
| 30°C | 50% |
| 40°C | 65% |
| 50°C | 80% |
| 55°C | 90% |
| 60°C+ | 100% |
Źródło: `curve.default.json` w repo → kopiowane do `/etc/gpu-fan/curve.json`.
---
## Edycja w panelu web
1. Otwórz UI (port 8090)
2. Zmień punkty na wykresie / w tabeli
3. Kliknij **Zapisz** — zapisuje do `/etc/gpu-fan/curve.json` i włącza tryb **curve**
---
## Edycja z terminala
```bash
sudo nano /etc/gpu-fan/curve.json
sudo systemctl reload gpu-fan
```
`reload` przeładowuje plik bez pełnego restartu.
---
## Co zobaczysz w panelu
- **Temperatura** — aktualna temp GPU
- **Fan speeds** — odczyt z karty (%)
- **Target** — co program próbuje ustawić (w trybie curve/manual)
- **Mode** — curve / manual / auto
---
## Po zatrzymaniu usługi
```bash
sudo systemctl stop gpu-fan
```
Wentylatory wracają do trybu **auto** drivera NVIDIA — to zamierzone (bezpieczeństwo).
---
## Co zrobić gdy wentylatory „dziwnie” się zachowują
| Sytuacja | Wyjaśnienie |
|----------|-------------|
| 0% przy niskiej temp w trybie **auto** | Normalne — karta wyłącza wentylatory przy idle |
| Głośno od razu w **curve** | Domyślna krzywa jest agresywna — obniż prędkości w JSON |
| Brak reakcji | Sprawdź tryb — czy na pewno **curve**, nie **auto** |
Więcej: [04-CZESTE-BLEDY.md](04-CZESTE-BLEDY.md)
+163
View File
@@ -0,0 +1,163 @@
# Częste błędy GPU Fan
**Cel:** Naprawić typowe problemy bez zgadywania.
**Czas:** zależy od problemu (215 min)
**Wymagania:** Dostęp SSH do serwera, `sudo`
---
## Szybka diagnostyka (zrób to najpierw)
```bash
sudo systemctl status gpu-fan
nvidia-smi
scripts/status.sh
```
Skopiuj wynik jeśli dalej nie działa.
---
## Błąd: `Insufficient Permissions` w logach
**Przyczyna:** Program nie działa jako root.
**Naprawa:**
```bash
sudo systemctl restart gpu-fan
sudo systemctl status gpu-fan
```
Nie uruchamiaj `python app.py` bez sudo (chyba że `DRY_RUN=true` tylko do testów).
---
## Błąd: `address already in use` / port 8090 zajęty
**Przyczyna:** Dwie kopie programu naraz (najczęściej systemd + `scripts/start.sh`).
**Naprawa:**
```bash
sudo systemctl stop gpu-fan
sudo scripts/status.sh --cleanup
sudo systemctl start gpu-fan
```
**Nie zmieniaj portu na 8091** — to maskuje problem, nie go rozwiązuje.
---
## Błąd: Strona w przeglądarce się nie ładuje
| Sprawdź | Komenda |
|---------|---------|
| Usługa działa? | `sudo systemctl status gpu-fan` |
| Port nasłuchuje? | `ss -tlnp \| grep 8090` |
| LAN włączony? | `grep GPU_FAN_HOST /opt/control-plane/.env` → powinno być `0.0.0.0` |
| Firewall? | Upewnij się że LAN może dojść do 8090 |
Włącz LAN:
```bash
sudo scripts/enable-lan.sh
```
---
## Błąd: `401` / Invalid API key
**Przyczyna:** Zły klucz w URL lub brak nagłówka.
**Naprawa:**
```bash
grep ^API_KEY= /opt/control-plane/.env
```
Użyj w przeglądarce:
```
http://IP_SERWERA:8090/?api_key=KLUCZ_Z_PLIKU
```
Patrz: [02-OTWIERANIE-UI-W-PRZEGLADARCE.md](02-OTWIERANIE-UI-W-PRZEGLADARCE.md)
---
## Błąd: Zmieniam `.env` w repo i nic się nie dzieje
**Przyczyna:** Produkcja czyta **`/opt/control-plane/.env`**, nie `stacks/control-plane/.env`.
**Naprawa:**
```bash
sudo nano /opt/control-plane/.env
sudo systemctl restart gpu-fan
```
Albo pełna reinstalacja kodu (nie nadpisuje istniejącego `.env`):
```bash
sudo scripts/install.sh
```
---
## Błąd: `nvidia-smi` nie działa
**Przyczyna:** Brak / zły sterownik NVIDIA.
GPU Fan **nie naprawi** drivera. Najpierw napraw GPU w systemie, potem wróć do instalacji.
---
## Błąd: Po Ctrl+Z w terminalu port zajęty
**Przyczyna:** Proces zawieszony w tle nadal trzyma port.
**Naprawa:**
```bash
jobs -l # zobacz zawieszone
kill %1 # numer z jobs
# albo:
sudo scripts/status.sh --cleanup
```
---
## Błąd: Krzywa się nie zapisuje
Sprawdź format JSON — 37 punktów, temp unikalne, speed 0 lub 30100.
```bash
sudo cat /etc/gpu-fan/curve.json
journalctl -u gpu-fan -n 30
```
---
## Błąd: Chcę uruchomić w Dockerze
**Odpowiedź:** Nie rób tego. Patrz [05-DLACZEGO-NIE-DOCKER.md](05-DLACZEGO-NIE-DOCKER.md).
---
## Pełny reset (ostateczność)
```bash
sudo systemctl stop gpu-fan
sudo scripts/status.sh --cleanup
cd ~/cursor/ubuntu-bare-metal/stacks/gpu-fan
sudo scripts/install.sh
sudo scripts/enable-lan.sh
sudo systemctl start gpu-fan
```
Logi:
```bash
journalctl -u gpu-fan -f
```
@@ -0,0 +1,78 @@
# Dlaczego GPU Fan NIE jest w Dockerze
**Cel:** Zrozumieć dlaczego instalujemy na hoście, a nie jak ComfyUI.
**Czas:** 3 minuty
**Wymagania:** Brak
---
## Krótka odpowiedź
GPU Fan **musi** siedzieć na hoście obok sterownika NVIDIA. W Dockerze **nie ma** gotowego obrazu w tym repo i **nie planujemy** go dodawać.
ComfyUI / LocalAI = programy w kontenerach (obliczenia AI).
GPU Fan = „termometr + regulator wentylatorów” na poziomie sprzętu.
---
## Analogia
| Co | Gdzie | Dlaczego |
|----|-------|----------|
| Aplikacja w przeglądarce | Docker OK | Nie dotyka wentylatorów bezpośrednio |
| Sterownik wentylatorów karty | Host (root) | Tylko system z uprawnieniami root może pisać do NVML |
To jak termostat w kotłowni — nie wkładasz go do pudełka z aplikacją webową w innym pokoju.
---
## Co by się stało w Dockerze (gdyby ktoś próbował)
1. Kontener musiałby być **privileged** i **root**
2. I tak często dostaniesz **Insufficient Permissions** na kartach GeForce
3. Po awaryjnym `docker kill` wentylatory mogłyby zostać w trybie ręcznym
4. Zero korzyści — i tak potrzebujesz tego samego sterownika na hoście
Dlatego w repo jest tylko:
```bash
sudo scripts/install.sh
sudo systemctl start gpu-fan
```
---
## Jak to wygląda na serwerze (poprawnie)
```
HOST (Ubuntu)
├── gpu-fan.service ← steruje wentylatorami (port 8090)
├── sterownik NVIDIA
└── Docker
├── comfyui :8188
├── localai :8070
└── vllm :8000
```
Wszystko może działać **równocześnie**. GPU Fan nie koliduje z kontenerami AI.
---
## „Ale inne stacki są w Dockerze!”
Tak — bo tam Docker **ma sens** (izolacja aplikacji, GPU do inference).
GPU Fan to **daemon sprzętowy** — jak `nvidia-persistenced`, nie jak aplikacja webowa.
---
## Gdzie jest pełna analiza techniczna
Dla agentów / deweloperów:
[../coding-agent/DOCKER-VS-HOST-REPORT.md](../coding-agent/DOCKER-VS-HOST-REPORT.md)
---
## Co robić zamiast Dockera
→ [01-INSTALACJA-KROK-PO-KROKU.md](01-INSTALACJA-KROK-PO-KROKU.md)
+358
View File
@@ -0,0 +1,358 @@
"""NVML fan control with JSON curve, modes, and graceful shutdown."""
from __future__ import annotations
import json
import logging
import signal
import threading
import time
from pathlib import Path
from typing import Any
import pynvml
log = logging.getLogger(__name__)
MIN_CURVE_POINTS = 3
MAX_CURVE_POINTS = 7
MIN_FAN_SPEED = 30
MAX_FAN_SPEED = 100
class FanControlError(Exception):
pass
def _clamp_speed(speed: int) -> int:
if speed == 0:
return 0
return max(MIN_FAN_SPEED, min(MAX_FAN_SPEED, speed))
def parse_curve(raw: dict[str, Any]) -> list[tuple[int, int]]:
if not raw:
raise FanControlError("Curve is empty")
points: list[tuple[int, int]] = []
for temp_str, speed in raw.items():
try:
temp = int(temp_str)
speed_int = int(speed)
except (TypeError, ValueError) as exc:
raise FanControlError(f"Invalid curve point {temp_str!r}: {speed!r}") from exc
if not 0 <= temp <= 120:
raise FanControlError(f"Temperature {temp} out of range 0-120")
if speed_int != 0 and not MIN_FAN_SPEED <= speed_int <= MAX_FAN_SPEED:
raise FanControlError(
f"Fan speed {speed_int}% invalid (use 0 or {MIN_FAN_SPEED}-{MAX_FAN_SPEED})"
)
points.append((temp, speed_int))
points.sort(key=lambda p: p[0])
temps = [p[0] for p in points]
if len(set(temps)) != len(temps):
raise FanControlError("Temperatures must be unique")
if len(points) < MIN_CURVE_POINTS or len(points) > MAX_CURVE_POINTS:
raise FanControlError(
f"Curve must have {MIN_CURVE_POINTS}-{MAX_CURVE_POINTS} points"
)
return points
def curve_to_dict(points: list[tuple[int, int]]) -> dict[str, int]:
return {str(temp): speed for temp, speed in points}
def interpolate_speed(temp: int, curve: list[tuple[int, int]]) -> int:
if temp <= curve[0][0]:
return curve[0][1]
if temp >= curve[-1][0]:
return curve[-1][1]
for i in range(len(curve) - 1):
t1, s1 = curve[i]
t2, s2 = curve[i + 1]
if t1 <= temp <= t2:
if t2 == t1:
return s2
ratio = (temp - t1) / (t2 - t1)
return int(s1 + ratio * (s2 - s1))
return curve[-1][1]
class FanController:
def __init__(
self,
curve_path: Path,
gpu_index: int = 0,
poll_interval: float = 2.0,
) -> None:
self.curve_path = curve_path
self.gpu_index = gpu_index
self.poll_interval = poll_interval
self._lock = threading.Lock()
self._running = False
self._mode = "curve"
self._manual_speed = 100
self._curve: list[tuple[int, int]] = []
self._handle: Any = None
self._fan_count = 0
self._gpu_name = ""
self._last_metrics: dict[str, Any] = {}
self._auto_active = False
self.dry_run = False
@property
def mode(self) -> str:
with self._lock:
return self._mode
@property
def manual_speed(self) -> int:
with self._lock:
return self._manual_speed
def load_curve_file(self) -> list[tuple[int, int]]:
if not self.curve_path.exists():
raise FanControlError(f"Curve file not found: {self.curve_path}")
raw = json.loads(self.curve_path.read_text(encoding="utf-8"))
return parse_curve(raw)
def save_curve_file(self, points: list[tuple[int, int]]) -> None:
validated = parse_curve(curve_to_dict(points))
self.curve_path.parent.mkdir(parents=True, exist_ok=True)
self.curve_path.write_text(
json.dumps(curve_to_dict(validated), indent=2) + "\n",
encoding="utf-8",
)
with self._lock:
self._curve = validated
log.info("Saved curve: %s", validated)
def reload_curve(self) -> None:
curve = self.load_curve_file()
with self._lock:
self._curve = curve
log.info("Reloaded curve: %s", curve)
def set_mode(self, mode: str, manual_speed: int | None = None) -> None:
if mode not in ("auto", "curve", "manual"):
raise FanControlError(f"Unknown mode: {mode}")
with self._lock:
self._mode = mode
if manual_speed is not None:
self._manual_speed = _clamp_speed(manual_speed)
log.info("Mode set to %s (manual_speed=%s)", mode, manual_speed)
def get_curve(self) -> list[tuple[int, int]]:
with self._lock:
return list(self._curve)
def get_metrics(self) -> dict[str, Any]:
with self._lock:
return dict(self._last_metrics)
def init_nvml(self) -> None:
pynvml.nvmlInit()
count = pynvml.nvmlDeviceGetCount()
if self.gpu_index >= count:
raise FanControlError(
f"GPU index {self.gpu_index} out of range (found {count} GPU(s))"
)
handle = pynvml.nvmlDeviceGetHandleByIndex(self.gpu_index)
name = pynvml.nvmlDeviceGetName(handle)
fan_count = pynvml.nvmlDeviceGetNumFans(handle)
self._handle = handle
self._fan_count = fan_count
self._gpu_name = name if isinstance(name, str) else name.decode()
self.reload_curve()
log.info("GPU %d: %s (%d fan(s))", self.gpu_index, self._gpu_name, fan_count)
def _read_metrics(self) -> dict[str, Any]:
handle = self._handle
assert handle is not None
temp = pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU)
util = pynvml.nvmlDeviceGetUtilizationRates(handle)
power_mw = pynvml.nvmlDeviceGetPowerUsage(handle)
mem_info = pynvml.nvmlDeviceGetMemoryInfo(handle)
memory_used_mb = mem_info.used // (1024**2)
memory_total_mb = mem_info.total // (1024**2)
power_limit_w: float | None = None
clock_graphics_mhz: int | None = None
clock_memory_mhz: int | None = None
try:
power_limit_w = round(
pynvml.nvmlDeviceGetEnforcedPowerLimit(handle) / 1000, 1
)
except pynvml.NVMLError:
pass
try:
clock_graphics_mhz = pynvml.nvmlDeviceGetClockInfo(
handle, pynvml.NVML_CLOCK_GRAPHICS
)
except pynvml.NVMLError:
pass
try:
clock_memory_mhz = pynvml.nvmlDeviceGetClockInfo(
handle, pynvml.NVML_CLOCK_MEM
)
except pynvml.NVMLError:
pass
fan_speeds: list[int] = []
for fan_idx in range(self._fan_count):
try:
fan_speeds.append(pynvml.nvmlDeviceGetFanSpeed_v2(handle, fan_idx))
except pynvml.NVMLError:
fan_speeds.append(-1)
with self._lock:
mode = self._mode
manual_speed = self._manual_speed
curve = list(self._curve)
if mode == "curve":
target = interpolate_speed(temp, curve)
elif mode == "manual":
target = manual_speed
else:
target = None
metrics = {
"gpu_index": self.gpu_index,
"gpu_name": self._gpu_name,
"temperature_c": temp,
"fan_speeds_pct": fan_speeds,
"target_speed_pct": target,
"power_w": round(power_mw / 1000, 1),
"power_limit_w": power_limit_w,
"utilization_pct": util.gpu,
"memory_utilization_pct": util.memory,
"memory_used_mb": memory_used_mb,
"memory_total_mb": memory_total_mb,
"clock_graphics_mhz": clock_graphics_mhz,
"clock_memory_mhz": clock_memory_mhz,
"mode": mode,
"manual_speed_pct": manual_speed,
"curve": curve_to_dict(curve),
}
with self._lock:
self._last_metrics = metrics
return metrics
def _set_manual_policy(self) -> None:
handle = self._handle
assert handle is not None
for fan_idx in range(self._fan_count):
pynvml.nvmlDeviceSetFanControlPolicy(
handle, fan_idx, pynvml.NVML_FAN_POLICY_MANUAL
)
def _restore_auto_policy(self) -> None:
if self._handle is None:
return
log.info("Restoring automatic fan control...")
for fan_idx in range(self._fan_count):
try:
pynvml.nvmlDeviceSetFanControlPolicy(
self._handle,
fan_idx,
pynvml.NVML_FAN_POLICY_TEMPERATURE_CONTINOUS_SW,
)
log.info("Fan %d: restored to auto", fan_idx)
except pynvml.NVMLError as exc:
log.error("Fan %d: could not restore auto: %s", fan_idx, exc)
def _apply_fan_speed(self, speed: int) -> None:
handle = self._handle
assert handle is not None
speed = _clamp_speed(speed)
self._set_manual_policy()
for fan_idx in range(self._fan_count):
pynvml.nvmlDeviceSetFanSpeed_v2(handle, fan_idx, speed)
def update_once(self) -> dict[str, Any]:
metrics = self._read_metrics()
mode = metrics["mode"]
if mode == "auto":
if not self._auto_active and not self.dry_run:
self._restore_auto_policy()
self._auto_active = True
elif self.dry_run:
self._auto_active = True
return metrics
self._auto_active = False
target = metrics["target_speed_pct"]
if target is None:
return metrics
if self.dry_run:
log.info(
"[dry-run] GPU %d: %d°C -> %d%% (fans: %s)",
self.gpu_index,
metrics["temperature_c"],
target,
metrics["fan_speeds_pct"],
)
return metrics
try:
self._apply_fan_speed(target)
log.info(
"GPU %d: %d°C -> %d%% (fans: %s)",
self.gpu_index,
metrics["temperature_c"],
target,
metrics["fan_speeds_pct"],
)
except pynvml.NVMLError as exc:
log.error("Failed to set fan speed: %s", exc)
metrics["error"] = str(exc)
return metrics
def run_loop(self) -> None:
self._running = True
log.info("Fan control loop started (interval=%ss)", self.poll_interval)
while self._running:
try:
self.update_once()
except Exception:
log.exception("Fan control loop error")
time.sleep(self.poll_interval)
def stop(self) -> None:
self._running = False
def shutdown(self) -> None:
self.stop()
if not self.dry_run:
self._restore_auto_policy()
try:
pynvml.nvmlShutdown()
except pynvml.NVMLError:
pass
log.info("Fan controller shut down")
def install_signal_handlers(self) -> None:
def handler(signum: int, _frame: Any) -> None:
log.info("Received signal %s", signum)
if signum == signal.SIGHUP:
try:
self.reload_curve()
except FanControlError as exc:
log.error("Curve reload failed: %s", exc)
return
self.shutdown()
signal.signal(signal.SIGTERM, handler)
signal.signal(signal.SIGINT, handler)
signal.signal(signal.SIGHUP, handler)
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""GPU fan control NVML daemon + API only (no web UI)."""
from __future__ import annotations
import logging
import os
import signal
import sys
import threading
from pathlib import Path
from typing import Any
import uvicorn
from fastapi import Depends, FastAPI, HTTPException, Request
from pydantic import BaseModel, Field
from fan_controller import (
FanControlError,
FanController,
MAX_CURVE_POINTS,
MIN_CURVE_POINTS,
MIN_FAN_SPEED,
MAX_FAN_SPEED,
curve_to_dict,
parse_curve,
)
STACK_DIR = Path(__file__).resolve().parent
# Unified control-plane env (see stacks/control-plane/env_loader.py)
for _cp in ("/opt/control-plane", "/repo/stacks/control-plane", str(STACK_DIR.parent / "control-plane")):
if _cp not in sys.path and Path(_cp).exists():
sys.path.insert(0, _cp)
from env_loader import load_control_plane_env # noqa: E402
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
log = logging.getLogger(__name__)
def _resolve_host_port(env: dict[str, str]) -> tuple[str, int]:
"""Agent API bind — prefer GPU_FAN_API_*; never fall back to legacy LAN :8090."""
if env.get("GPU_FAN_API_HOST") or env.get("GPU_FAN_API_PORT"):
host = env.get("GPU_FAN_API_HOST", "127.0.0.1")
port_raw = env.get("GPU_FAN_API_PORT", "18090")
else:
host = "127.0.0.1"
port_raw = "18090"
return host, int(port_raw)
def _is_localhost_bind(host: str) -> bool:
return host.strip().lower() in ("127.0.0.1", "localhost", "::1")
ENV = load_control_plane_env(STACK_DIR)
HOST, PORT = _resolve_host_port(ENV)
API_KEY = ENV.get("API_KEY", "")
CURVE_PATH = Path(ENV.get("CURVE_PATH", "/etc/gpu-fan/curve.json"))
POLL_INTERVAL = float(ENV.get("POLL_INTERVAL", "2.0"))
GPU_INDEX = int(ENV.get("GPU_INDEX", "0"))
DRY_RUN = ENV.get("DRY_RUN", "").lower() in ("1", "true", "yes")
controller = FanController(
curve_path=CURVE_PATH,
gpu_index=GPU_INDEX,
poll_interval=POLL_INTERVAL,
)
controller.dry_run = DRY_RUN
app = FastAPI(title="GPU Fan Agent", version="1.0.0")
class CurvePoint(BaseModel):
temp: int = Field(ge=0, le=120)
speed: int = Field(ge=0, le=100)
class CurveUpdate(BaseModel):
points: list[CurvePoint] = Field(min_length=MIN_CURVE_POINTS, max_length=MAX_CURVE_POINTS)
class ModeUpdate(BaseModel):
mode: str
speed: int | None = Field(default=None, ge=MIN_FAN_SPEED, le=MAX_FAN_SPEED)
def require_auth(request: Request) -> None:
if not API_KEY:
return
key = request.headers.get("X-API-Key", "")
if key != API_KEY:
raise HTTPException(status_code=401, detail="Invalid or missing API key")
@app.get("/api/status")
def api_status(_: None = Depends(require_auth)) -> dict[str, Any]:
return controller.get_metrics()
@app.get("/api/curve")
def api_get_curve(_: None = Depends(require_auth)) -> dict[str, Any]:
points = controller.get_curve()
return {"points": [{"temp": t, "speed": s} for t, s in points]}
@app.put("/api/curve")
def api_put_curve(body: CurveUpdate, _: None = Depends(require_auth)) -> dict[str, Any]:
points = [(p.temp, p.speed) for p in body.points]
try:
parse_curve(curve_to_dict(points))
controller.save_curve_file(points)
controller.set_mode("curve")
except FanControlError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "curve": curve_to_dict(controller.get_curve())}
@app.post("/api/mode")
def api_set_mode(body: ModeUpdate, _: None = Depends(require_auth)) -> dict[str, Any]:
try:
controller.set_mode(body.mode, body.speed)
if body.mode != "auto":
controller.update_once()
except FanControlError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "mode": controller.mode, "manual_speed": controller.manual_speed}
@app.post("/api/reload")
def api_reload(_: None = Depends(require_auth)) -> dict[str, Any]:
try:
controller.reload_curve()
except FanControlError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "curve": curve_to_dict(controller.get_curve())}
def run_daemon_thread() -> None:
controller.run_loop()
def main() -> None:
if os.geteuid() != 0 and not DRY_RUN:
log.error("GPU fan control requires root (NVML write access). Run with sudo.")
sys.exit(1)
if not _is_localhost_bind(HOST) and not API_KEY:
log.error(
"API_KEY is required when GPU_FAN_API_HOST=%s (LAN/public bind). "
"Set API_KEY in .env",
HOST,
)
sys.exit(1)
controller.init_nvml()
def shutdown_handler(signum: int, _frame: object) -> None:
log.info("Received signal %s", signum)
if signum == signal.SIGHUP:
try:
controller.reload_curve()
except FanControlError as exc:
log.error("Curve reload failed: %s", exc)
return
controller.shutdown()
sys.exit(0)
signal.signal(signal.SIGTERM, shutdown_handler)
signal.signal(signal.SIGINT, shutdown_handler)
signal.signal(signal.SIGHUP, shutdown_handler)
thread = threading.Thread(target=run_daemon_thread, daemon=True)
thread.start()
log.info("GPU fan agent API at http://%s:%d", HOST, PORT)
log.info("Web UI: use Server UI at :8091 (GPU Fan tab)")
uvicorn.run(app, host=HOST, port=PORT, log_level="info")
if __name__ == "__main__":
main()
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=GPU Fan Control (NVML agent API)
After=nvidia-persistenced.service
Wants=nvidia-persistenced.service
[Service]
Type=simple
User=root
WorkingDirectory=/opt/gpu-fan
EnvironmentFile=-/opt/control-plane/.env
ExecStart=/opt/gpu-fan/.venv/bin/python /opt/gpu-fan/fan_daemon.py
Restart=on-failure
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=15
[Install]
WantedBy=multi-user.target
+3
View File
@@ -0,0 +1,3 @@
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
nvidia-ml-py>=12.560.0
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Configure gpu-fan agent for localhost + ensure API key. UI is in Server UI :8091.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
SERVER_UI_DIR="$(cd "${STACK_DIR}/../server-ui" && pwd)"
CONTROL_PLANE_ENV="/opt/control-plane/.env"
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo ${SCRIPT_DIR}/enable-lan.sh"
exit 1
fi
"${SCRIPT_DIR}/install.sh"
set_env_var() {
local file="$1" key="$2" val="$3"
if grep -q "^${key}=" "${file}"; then
sed -i "s|^${key}=.*|${key}=${val}|" "${file}"
else
echo "${key}=${val}" >> "${file}"
fi
}
for pair in "GPU_FAN_API_HOST=127.0.0.1" "GPU_FAN_API_PORT=18090"; do
set_env_var "${CONTROL_PLANE_ENV}" "${pair%%=*}" "${pair#*=}"
done
if ! grep -q '^API_KEY=.\{8,\}' "${CONTROL_PLANE_ENV}"; then
KEY="$(openssl rand -hex 16)"
set_env_var "${CONTROL_PLANE_ENV}" "API_KEY" "${KEY}"
echo "Generated new API_KEY in ${CONTROL_PLANE_ENV}"
else
echo "API_KEY already set in ${CONTROL_PLANE_ENV}"
fi
systemctl restart gpu-fan
sleep 2
LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
echo ""
echo "gpu-fan agent: $(systemctl is-active gpu-fan)"
echo "Agent API: http://127.0.0.1:18090 (localhost only)"
echo ""
echo "GPU Fan panel (LAN):"
echo " http://${LAN_IP:-<server-ip>}:8091 → zakładka GPU Fan"
echo ""
echo "API key (Server UI + gpu-fan):"
echo " grep ^API_KEY= ${CONTROL_PLANE_ENV}"
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
SERVER_UI_DIR="$(cd "${STACK_DIR}/../server-ui" && pwd)"
INSTALL_DIR="/opt/gpu-fan"
CONFIG_DIR="/etc/gpu-fan"
SERVICE_NAME="gpu-fan.service"
AGENT_PORT=18090
CONTROL_PLANE_ENV="/opt/control-plane/.env"
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo ${SCRIPT_DIR}/install.sh"
exit 1
fi
echo "=== GPU Fan Control — install ==="
bash "${SERVER_UI_DIR}/scripts/setup-control-plane-env.sh"
apt-get update -qq
apt-get install -y python3-venv python3-pip
mkdir -p "${INSTALL_DIR}" "${CONFIG_DIR}"
rsync -a --delete \
--exclude '.venv' \
--exclude '.env' \
--exclude '__pycache__' \
"${STACK_DIR}/" "${INSTALL_DIR}/"
if [[ ! -f "${CONFIG_DIR}/curve.json" ]]; then
cp "${INSTALL_DIR}/curve.default.json" "${CONFIG_DIR}/curve.json"
echo "Installed default curve: ${CONFIG_DIR}/curve.json"
fi
python3 -m venv "${INSTALL_DIR}/.venv"
"${INSTALL_DIR}/.venv/bin/pip" install --upgrade pip -q
"${INSTALL_DIR}/.venv/bin/pip" install -r "${INSTALL_DIR}/requirements.txt" -q
install -m 644 "${INSTALL_DIR}/gpu-fan.service" "/etc/systemd/system/${SERVICE_NAME}"
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}"
systemctl restart "${SERVICE_NAME}"
sleep 2
API_KEY_VAL="$(grep '^API_KEY=' "${CONTROL_PLANE_ENV}" | cut -d= -f2- || true)"
LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
echo ""
echo "Installed to ${INSTALL_DIR}"
echo "Service: $(systemctl is-active "${SERVICE_NAME}" 2>/dev/null || echo unknown)"
echo "Env: ${CONTROL_PLANE_ENV}"
echo "Curve config: ${CONFIG_DIR}/curve.json"
echo "Agent API: 127.0.0.1:${AGENT_PORT} (localhost only)"
echo ""
echo "GPU Fan UI (Server UI, LAN):"
echo " http://${LAN_IP:-<server-ip>}:8091/#gpu-fan"
echo ""
echo "API key:"
echo " grep ^API_KEY= ${CONTROL_PLANE_ENV}"
echo ""
if ss -tlnp 2>/dev/null | grep -q ":${AGENT_PORT}"; then
echo "Port ${AGENT_PORT}: listening"
if [[ -n "${API_KEY_VAL}" ]]; then
STATUS="$(curl -sf "http://127.0.0.1:${AGENT_PORT}/api/status" -H "X-API-Key: ${API_KEY_VAL}" 2>/dev/null | head -c 120 || true)"
echo "Agent status: ${STATUS}..."
fi
else
echo "WARN: port ${AGENT_PORT} not listening — check: journalctl -u ${SERVICE_NAME} -n 20"
fi
echo ""
echo "Logs:"
echo " journalctl -u ${SERVICE_NAME} -f"
echo ""
echo "Pełna instalacja (gpu-fan + Server UI):"
echo " sudo ${SERVER_UI_DIR}/scripts/install-control-plane.sh"
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# Self-test: curve logic, NVML read, API (dry-run). Full fan write needs sudo.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${STACK_DIR}"
mkdir -p config
cp -n curve.default.json config/curve.json 2>/dev/null || true
export CURVE_PATH="${STACK_DIR}/config/curve.json"
export DRY_RUN=true
export GPU_FAN_PORT=18090
echo "=== 1. Curve logic ==="
.venv/bin/python -c "
from fan_controller import parse_curve, interpolate_speed
pts = parse_curve({'30':50,'40':65,'50':80,'55':90,'60':100,'70':100})
assert interpolate_speed(45, pts) in (72, 73)
assert interpolate_speed(70, pts) == 100
print('OK')
"
echo "=== 2. NVML read ==="
.venv/bin/python -c "
import pynvml
pynvml.nvmlInit()
h = pynvml.nvmlDeviceGetHandleByIndex(0)
t = pynvml.nvmlDeviceGetTemperature(h, pynvml.NVML_TEMPERATURE_GPU)
print(f'GPU temp: {t}C')
pynvml.nvmlShutdown()
print('OK')
"
echo "=== 3. API dry-run (background) ==="
.venv/bin/python app.py &
APP_PID=$!
trap 'kill $APP_PID 2>/dev/null || true' EXIT
sleep 2
STATUS=$(curl -sf "http://127.0.0.1:${GPU_FAN_PORT}/api/status")
echo "$STATUS" | .venv/bin/python -m json.tool | head -20
TARGET=$(echo "$STATUS" | .venv/bin/python -c "import sys,json; print(json.load(sys.stdin)['target_speed_pct'])")
TEMP=$(echo "$STATUS" | .venv/bin/python -c "import sys,json; print(json.load(sys.stdin)['temperature_c'])")
echo "Temp=${TEMP}C target=${TARGET}%"
curl -sf -X PUT "http://127.0.0.1:${GPU_FAN_PORT}/api/curve" \
-H 'Content-Type: application/json' \
-d '{"points":[{"temp":30,"speed":50},{"temp":40,"speed":65},{"temp":50,"speed":80},{"temp":55,"speed":90},{"temp":60,"speed":100},{"temp":70,"speed":100}]}' \
| .venv/bin/python -m json.tool >/dev/null
echo "Curve PUT: OK"
curl -sf -X POST "http://127.0.0.1:${GPU_FAN_PORT}/api/mode" \
-H 'Content-Type: application/json' \
-d '{"mode":"manual","speed":100}' >/dev/null
echo "Mode manual 100%: OK"
kill $APP_PID 2>/dev/null || true
trap - EXIT
echo ""
if sudo -n true 2>/dev/null; then
echo "=== 4. Fan write (root) ==="
sudo -n env CURVE_PATH="$CURVE_PATH" .venv/bin/python -c "
from pathlib import Path
from fan_controller import FanController
c = FanController(Path('$CURVE_PATH'), 0, 2.0)
c.init_nvml()
c.set_mode('manual', 50)
m = c.update_once()
print(f\"Applied: {m['temperature_c']}C -> {m['target_speed_pct']}%\")
c.set_mode('auto')
c.update_once()
c.shutdown()
print('OK')
"
else
echo "=== 4. Fan write (root) — SKIP (sudo needs password) ==="
echo " Run: sudo scripts/install.sh && sudo systemctl start gpu-fan"
fi
echo ""
echo "All automated checks passed."
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
CONTROL_PLANE_ENV="${STACK_DIR}/../control-plane/.env"
EXAMPLE="${STACK_DIR}/../control-plane/.env.example"
cd "${STACK_DIR}"
if [[ ! -f "${CONTROL_PLANE_ENV}" ]]; then
cp "${EXAMPLE}" "${CONTROL_PLANE_ENV}"
echo "Created ${CONTROL_PLANE_ENV} from example"
fi
set -a
# shellcheck disable=SC1091
source "${CONTROL_PLANE_ENV}"
set +a
export CONTROL_PLANE_ENV="${CONTROL_PLANE_ENV}"
CURVE_PATH="${CURVE_PATH:-/etc/gpu-fan/curve.json}"
export CURVE_PATH
if [[ ! -f "${CURVE_PATH}" ]]; then
if [[ "${EUID}" -eq 0 ]]; then
mkdir -p "$(dirname "${CURVE_PATH}")"
cp curve.default.json "${CURVE_PATH}"
else
mkdir -p "${STACK_DIR}/config"
CURVE_PATH="${STACK_DIR}/config/curve.json"
export CURVE_PATH
[[ -f "${CURVE_PATH}" ]] || cp curve.default.json "${CURVE_PATH}"
echo "Using local curve: ${CURVE_PATH}"
fi
fi
if [[ ! -d .venv ]]; then
python3 -m venv .venv
.venv/bin/pip install -q -r requirements.txt
fi
if [[ "${EUID}" -ne 0 ]]; then
echo "ERROR: Fan control requires root. Use:"
echo " sudo -E ${SCRIPT_DIR}/start.sh"
echo "Or install system-wide:"
echo " sudo ${SCRIPT_DIR}/install.sh && sudo systemctl start gpu-fan"
exit 1
fi
if systemctl is-active --quiet gpu-fan 2>/dev/null; then
echo "ERROR: gpu-fan.service is already running (systemd)."
echo " Use: sudo systemctl stop gpu-fan"
echo " Or: sudo journalctl -u gpu-fan -f"
echo ""
echo "Do not run scripts/start.sh alongside systemd — one instance only."
exit 1
fi
PORT="${GPU_FAN_API_PORT:-18090}"
HOST="${GPU_FAN_API_HOST:-127.0.0.1}"
if ss -tln "sport = :${PORT}" 2>/dev/null | grep -q LISTEN; then
echo "ERROR: Port ${PORT} is already in use."
echo ""
ss -tlnp "sport = :${PORT}" 2>/dev/null || ss -tln "sport = :${PORT}"
echo ""
echo "Common causes:"
echo " - Suspended foreground start (Ctrl+Z) — run: jobs -l && kill %<n>"
echo " - Orphan process — run: sudo ${SCRIPT_DIR}/status.sh"
echo " - systemd still stopping — wait a few seconds"
exit 1
fi
echo "=== GPU Fan Control (foreground) ==="
echo "Agent API: http://${HOST}:${PORT}"
echo "Curve: ${CURVE_PATH}"
echo ""
echo "Stop with Ctrl+C (not Ctrl+Z — suspended process keeps the port)."
echo ""
exec .venv/bin/python app.py
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Diagnose gpu-fan port conflicts and running instances.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
OPT_ENV="/opt/control-plane/.env"
REPO_ENV="${STACK_DIR}/../control-plane/.env"
echo "=== gpu-fan status ==="
echo ""
if systemctl list-unit-files gpu-fan.service &>/dev/null; then
echo "systemd:"
systemctl is-active gpu-fan 2>/dev/null && systemctl status gpu-fan --no-pager -l 2>/dev/null | head -8 || echo " inactive"
else
echo "systemd: gpu-fan.service not installed"
fi
echo ""
echo "Ports 80908099:"
if ss -tlnp 2>/dev/null | grep -E ':809[0-9]' ; then
:
elif ss -tln 2>/dev/null | grep -E ':809[0-9]' ; then
echo " (run as root for process names: sudo ss -tlnp)"
else
echo " (none listening)"
fi
echo ""
echo "gpu-fan processes:"
pgrep -af '/opt/gpu-fan/.venv/bin/python|stacks/gpu-fan/.venv/bin/python|gpu-fan/app.py' 2>/dev/null \
|| echo " (none)"
echo ""
echo "Shell suspended jobs (Ctrl+Z):"
if jobs -l 2>/dev/null | grep -q .; then
jobs -l
echo " Kill with: kill %<job-number>"
else
echo " (none in this shell)"
fi
echo ""
echo "GPU_FAN_API_PORT config:"
[[ -f "${OPT_ENV}" ]] && echo " /opt/control-plane/.env: $(grep '^GPU_FAN_API_PORT=' "${OPT_ENV}" || echo '(not set)')"
[[ -f "${REPO_ENV}" ]] && echo " repo control-plane: $(grep '^GPU_FAN_API_PORT=' "${REPO_ENV}" || echo '(not set)')"
echo " Note: production uses /opt/control-plane/.env (shared with Server UI)"
echo ""
if [[ "${1:-}" == "--cleanup" ]]; then
if [[ "${EUID}" -ne 0 ]]; then
echo "ERROR: --cleanup requires root: sudo ${SCRIPT_DIR}/status.sh --cleanup"
exit 1
fi
echo "=== cleanup ==="
systemctl stop gpu-fan 2>/dev/null || true
pkill -f '/opt/gpu-fan/.venv/bin/python /opt/gpu-fan/app.py' 2>/dev/null || true
pkill -f 'stacks/gpu-fan/.venv/bin/python app.py' 2>/dev/null || true
sleep 0.5
if ss -tln 2>/dev/null | grep -qE ':809[0-9]'; then
echo "WARNING: ports still in use:"
ss -tlnp 2>/dev/null | grep -E ':809[0-9]' || ss -tln | grep -E ':809[0-9]'
echo "Check suspended jobs in other shells: jobs -l"
else
echo "Ports 809x are free."
fi
echo ""
echo "Start production instance:"
echo " sudo systemctl start gpu-fan"
echo "Or foreground debug (systemd stopped):"
echo " sudo ${SCRIPT_DIR}/start.sh"
fi
+755
View File
@@ -0,0 +1,755 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GPU Fan Control — RTX 3090 Ti</title>
<style>
:root {
--bg: #0d1117;
--panel: #161b22;
--border: #30363d;
--text: #e6edf3;
--muted: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--orange: #d29922;
--red: #f85149;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
h1 { margin: 0; font-size: 1.25rem; font-weight: 600; }
.subtitle { color: var(--muted); font-size: 0.875rem; }
main { padding: 1.5rem; max-width: 1100px; margin: 0 auto; }
.grid {
display: grid;
grid-template-columns: 1fr 320px;
gap: 1rem;
}
@media (max-width: 900px) {
.grid { grid-template-columns: 1fr; }
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
}
.card h2 {
margin: 0 0 1rem;
font-size: 0.95rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stats { display: grid; gap: 0.75rem; }
.stat {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.stat-value { font-size: 1.5rem; font-weight: 600; }
.stat-label { color: var(--muted); font-size: 0.85rem; }
.temp-hot { color: var(--red); }
.temp-warm { color: var(--orange); }
.temp-cool { color: var(--green); }
.mode-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
background: #21262d;
border: 1px solid var(--border);
}
#curve-svg {
width: 100%;
height: 360px;
background: #0d1117;
border-radius: 6px;
cursor: crosshair;
touch-action: none;
}
.axis-label { fill: var(--muted); font-size: 11px; }
.curve-line { stroke: var(--accent); stroke-width: 2; fill: none; }
.curve-fill { fill: rgba(88, 166, 255, 0.08); }
.curve-point {
fill: var(--accent);
stroke: #fff;
stroke-width: 2;
cursor: grab;
}
.curve-point:active { cursor: grabbing; }
table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
th, td {
padding: 0.4rem 0.5rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
th { color: var(--muted); font-weight: 500; }
input[type="number"] {
width: 4rem;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: 4px;
padding: 0.25rem 0.4rem;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
button {
background: #21262d;
border: 1px solid var(--border);
color: var(--text);
padding: 0.5rem 0.9rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
}
button:hover { border-color: var(--accent); }
button.primary {
background: #238636;
border-color: #2ea043;
}
button.primary:hover { background: #2ea043; }
button.danger {
background: #da3633;
border-color: #f85149;
}
#toast {
position: fixed;
bottom: 1rem;
right: 1rem;
padding: 0.75rem 1rem;
border-radius: 6px;
background: var(--panel);
border: 1px solid var(--border);
display: none;
z-index: 100;
}
#toast.error { border-color: var(--red); color: var(--red); }
#toast.ok { border-color: var(--green); }
.hint { color: var(--muted); font-size: 0.8rem; margin-top: 0.5rem; }
.monitoring-section { margin-top: 1rem; }
.gauge-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1rem;
}
@media (max-width: 700px) {
.gauge-grid { grid-template-columns: 1fr; }
}
.gauge {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem 1rem;
}
.gauge-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.gauge-label { color: var(--muted); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; }
.gauge-value { font-size: 1.25rem; font-weight: 600; }
.gauge-bar {
height: 10px;
background: #21262d;
border-radius: 5px;
overflow: hidden;
}
.gauge-fill {
height: 100%;
border-radius: 5px;
transition: width 0.4s ease;
}
.gauge-fill.util { background: linear-gradient(90deg, var(--green), var(--orange), var(--red)); }
.gauge-fill.power { background: linear-gradient(90deg, var(--accent), var(--orange), var(--red)); }
.gauge-fill.vram { background: linear-gradient(90deg, #6e40c9, var(--accent)); }
.gauge-sub { color: var(--muted); font-size: 0.75rem; margin-top: 0.35rem; }
.chart-wrap {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.chart-legend {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 0.5rem;
font-size: 0.8rem;
color: var(--muted);
}
.chart-legend span::before {
content: '';
display: inline-block;
width: 10px;
height: 3px;
margin-right: 0.35rem;
vertical-align: middle;
border-radius: 1px;
}
.legend-util::before { background: var(--accent); }
.legend-power::before { background: var(--orange); }
.legend-temp::before { background: var(--red); }
#history-canvas {
width: 100%;
height: 140px;
display: block;
}
.sensor-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
@media (max-width: 700px) {
.sensor-grid { grid-template-columns: repeat(2, 1fr); }
}
.sensor-tile {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem;
text-align: center;
}
.sensor-value {
font-size: 1.35rem;
font-weight: 600;
line-height: 1.2;
}
.sensor-label {
color: var(--muted);
font-size: 0.75rem;
margin-top: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
</style>
</head>
<body>
<header>
<div>
<h1>GPU Fan Control</h1>
<div class="subtitle" id="gpu-name">Ładowanie…</div>
</div>
<span class="mode-badge" id="mode-badge"></span>
</header>
<main>
<div class="grid">
<div class="card">
<h2>Krzywa wentylatorów</h2>
<svg id="curve-svg" viewBox="0 0 600 360">
<defs>
<linearGradient id="tempGrad" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="#3fb950" stop-opacity="0.15"/>
<stop offset="50%" stop-color="#d29922" stop-opacity="0.15"/>
<stop offset="100%" stop-color="#f85149" stop-opacity="0.15"/>
</linearGradient>
</defs>
<rect x="50" y="20" width="520" height="300" fill="url(#tempGrad)" stroke="#30363d"/>
<text x="300" y="355" class="axis-label" text-anchor="middle">Temperatura (°C)</text>
<text x="15" y="170" class="axis-label" transform="rotate(-90 15 170)" text-anchor="middle">Prędkość (%)</text>
<path id="curve-fill" class="curve-fill"/>
<polyline id="curve-line" class="curve-line"/>
<g id="points-layer"></g>
<line id="live-temp" stroke="#f85149" stroke-width="1" stroke-dasharray="4 4" opacity="0.7"/>
<circle id="live-dot" r="5" fill="#f85149" opacity="0.9"/>
</svg>
<p class="hint">Przeciągnij punkty na wykresie. Prędkość: 30100% (API NVIDIA). Min. 3, max. 7 punktów.</p>
<table id="points-table">
<thead>
<tr><th>#</th><th>Temp (°C)</th><th>Speed (%)</th><th></th></tr>
</thead>
<tbody></tbody>
</table>
<div class="actions">
<button type="button" id="btn-add">+ Punkt</button>
<button type="button" class="primary" id="btn-save">Zapisz krzywą</button>
<button type="button" id="btn-reload">Przeładuj z pliku</button>
<button type="button" id="btn-auto">Tryb auto (driver)</button>
<button type="button" class="danger" id="btn-max">Manual 100%</button>
</div>
</div>
<div class="card">
<h2>Status GPU</h2>
<div class="stats">
<div class="stat">
<span class="stat-label">Wentylatory</span>
<span class="stat-value" id="stat-fans"></span>
</div>
<div class="stat">
<span class="stat-label">Cel (krzywa)</span>
<span class="stat-value" id="stat-target"></span>
</div>
</div>
</div>
</div>
<div class="card monitoring-section">
<h2>Monitoring GPU</h2>
<div class="gauge-grid">
<div class="gauge">
<div class="gauge-header">
<span class="gauge-label">Obciążenie GPU</span>
<span class="gauge-value" id="gauge-util-val"></span>
</div>
<div class="gauge-bar"><div class="gauge-fill util" id="gauge-util-bar" style="width:0%"></div></div>
</div>
<div class="gauge">
<div class="gauge-header">
<span class="gauge-label">Pobór mocy</span>
<span class="gauge-value" id="gauge-power-val"></span>
</div>
<div class="gauge-bar"><div class="gauge-fill power" id="gauge-power-bar" style="width:0%"></div></div>
<div class="gauge-sub" id="gauge-power-sub"></div>
</div>
<div class="gauge">
<div class="gauge-header">
<span class="gauge-label">Pamięć VRAM</span>
<span class="gauge-value" id="gauge-vram-val"></span>
</div>
<div class="gauge-bar"><div class="gauge-fill vram" id="gauge-vram-bar" style="width:0%"></div></div>
<div class="gauge-sub" id="gauge-vram-sub"></div>
</div>
</div>
<div class="chart-wrap">
<div class="chart-legend">
<span class="legend-util">Wykorzystanie GPU (%)</span>
<span class="legend-power">Moc (W)</span>
<span class="legend-temp">Temperatura (°C)</span>
</div>
<canvas id="history-canvas"></canvas>
</div>
<div class="sensor-grid">
<div class="sensor-tile">
<div class="sensor-value" id="sensor-temp"></div>
<div class="sensor-label">Temperatura</div>
</div>
<div class="sensor-tile">
<div class="sensor-value" id="sensor-fans"></div>
<div class="sensor-label">Wentylatory</div>
</div>
<div class="sensor-tile">
<div class="sensor-value" id="sensor-gpu-clock"></div>
<div class="sensor-label">Taktowanie GPU</div>
</div>
<div class="sensor-tile">
<div class="sensor-value" id="sensor-mem-clock"></div>
<div class="sensor-label">Taktowanie pamięci</div>
</div>
<div class="sensor-tile">
<div class="sensor-value" id="sensor-mem-util"></div>
<div class="sensor-label">Wykorzystanie pamięci</div>
</div>
<div class="sensor-tile">
<div class="sensor-value" id="sensor-mode"></div>
<div class="sensor-label">Tryb wentylatora</div>
</div>
</div>
</div>
</main>
<div id="toast"></div>
<script>
const PAD = { left: 50, top: 20, width: 520, height: 300 };
const TEMP_MIN = 20;
const TEMP_MAX = 90;
const SPEED_MIN = 30;
const SPEED_MAX = 100;
let points = [];
let liveTemp = null;
let dragging = null;
const history = [];
const HISTORY_MAX = 60;
let apiKey = localStorage.getItem('gpu-fan-api-key') || '';
const urlKey = new URLSearchParams(location.search).get('api_key');
if (urlKey) {
apiKey = urlKey;
localStorage.setItem('gpu-fan-api-key', urlKey);
history.replaceState({}, '', location.pathname);
}
function headers() {
const h = { 'Content-Type': 'application/json' };
if (apiKey) h['X-API-Key'] = apiKey;
return h;
}
function toast(msg, type = 'ok') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = type;
el.style.display = 'block';
setTimeout(() => { el.style.display = 'none'; }, 3500);
}
async function api(path, opts = {}) {
const res = await fetch(path, { ...opts, headers: { ...headers(), ...opts.headers } });
if (res.status === 401) {
const key = prompt('Podaj API key (X-API-Key):');
if (key) {
apiKey = key;
localStorage.setItem('gpu-fan-api-key', key);
return api(path, opts);
}
throw new Error('Brak autoryzacji');
}
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || res.statusText);
return data;
}
function tempToX(t) {
return PAD.left + ((t - TEMP_MIN) / (TEMP_MAX - TEMP_MIN)) * PAD.width;
}
function speedToY(s) {
return PAD.top + PAD.height - ((s - SPEED_MIN) / (SPEED_MAX - SPEED_MIN)) * PAD.height;
}
function xToTemp(x) {
return Math.round(TEMP_MIN + ((x - PAD.left) / PAD.width) * (TEMP_MAX - TEMP_MIN));
}
function yToSpeed(y) {
const raw = SPEED_MIN + ((PAD.top + PAD.height - y) / PAD.height) * (SPEED_MAX - SPEED_MIN);
return Math.round(Math.max(SPEED_MIN, Math.min(SPEED_MAX, raw)));
}
function sortPoints() {
points.sort((a, b) => a.temp - b.temp);
}
function drawCurve() {
sortPoints();
const pts = points.map(p => `${tempToX(p.temp)},${speedToY(p.speed)}`).join(' ');
document.getElementById('curve-line').setAttribute('points', pts);
const fill = points.length
? `${PAD.left},${PAD.top + PAD.height} ` + pts + ` ${PAD.left + PAD.width},${PAD.top + PAD.height}`
: '';
document.getElementById('curve-fill').setAttribute('d', fill ? `M ${fill} Z` : '');
const layer = document.getElementById('points-layer');
layer.innerHTML = '';
points.forEach((p, i) => {
const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
c.setAttribute('cx', tempToX(p.temp));
c.setAttribute('cy', speedToY(p.speed));
c.setAttribute('r', 8);
c.classList.add('curve-point');
c.dataset.index = i;
c.addEventListener('mousedown', e => { dragging = i; e.preventDefault(); });
c.addEventListener('touchstart', e => { dragging = i; e.preventDefault(); }, { passive: false });
layer.appendChild(c);
});
if (liveTemp != null) {
const x = tempToX(liveTemp);
document.getElementById('live-temp').setAttribute('x1', x);
document.getElementById('live-temp').setAttribute('y1', PAD.top);
document.getElementById('live-temp').setAttribute('x2', x);
document.getElementById('live-temp').setAttribute('y2', PAD.top + PAD.height);
document.getElementById('live-dot').setAttribute('cx', x);
document.getElementById('live-dot').setAttribute('cy', speedToY(interpolate(liveTemp)));
document.getElementById('live-dot').style.display = '';
} else {
document.getElementById('live-dot').style.display = 'none';
}
renderTable();
}
function interpolate(temp) {
if (!points.length) return SPEED_MIN;
if (temp <= points[0].temp) return points[0].speed;
if (temp >= points[points.length - 1].temp) return points[points.length - 1].speed;
for (let i = 0; i < points.length - 1; i++) {
const a = points[i], b = points[i + 1];
if (temp >= a.temp && temp <= b.temp) {
const r = (temp - a.temp) / (b.temp - a.temp);
return Math.round(a.speed + r * (b.speed - a.speed));
}
}
return points[points.length - 1].speed;
}
function renderTable() {
const tbody = document.querySelector('#points-table tbody');
tbody.innerHTML = '';
points.forEach((p, i) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${i + 1}</td>
<td><input type="number" min="0" max="120" value="${p.temp}" data-i="${i}" data-field="temp"></td>
<td><input type="number" min="30" max="100" value="${p.speed}" data-i="${i}" data-field="speed"></td>
<td><button type="button" data-del="${i}" ${points.length <= 3 ? 'disabled' : ''}>×</button></td>
`;
tbody.appendChild(tr);
});
tbody.querySelectorAll('input').forEach(inp => {
inp.addEventListener('change', () => {
const i = +inp.dataset.i;
const v = +inp.value;
if (inp.dataset.field === 'temp') points[i].temp = v;
else points[i].speed = Math.max(SPEED_MIN, Math.min(SPEED_MAX, v));
drawCurve();
});
});
tbody.querySelectorAll('button[data-del]').forEach(btn => {
btn.addEventListener('click', () => {
if (points.length <= 3) return;
points.splice(+btn.dataset.del, 1);
drawCurve();
});
});
}
function onPointerMove(clientX, clientY) {
if (dragging === null) return;
const svg = document.getElementById('curve-svg');
const rect = svg.getBoundingClientRect();
const scaleX = 600 / rect.width;
const scaleY = 360 / rect.height;
const x = (clientX - rect.left) * scaleX;
const y = (clientY - rect.top) * scaleY;
let temp = xToTemp(x);
let speed = yToSpeed(y);
temp = Math.max(TEMP_MIN, Math.min(TEMP_MAX, temp));
const prev = dragging > 0 ? points[dragging - 1].temp + 1 : TEMP_MIN;
const next = dragging < points.length - 1 ? points[dragging + 1].temp - 1 : TEMP_MAX;
points[dragging].temp = Math.max(prev, Math.min(next, temp));
points[dragging].speed = speed;
drawCurve();
}
document.addEventListener('mousemove', e => onPointerMove(e.clientX, e.clientY));
document.addEventListener('mouseup', () => { dragging = null; });
document.addEventListener('touchmove', e => {
if (e.touches[0]) onPointerMove(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: false });
document.addEventListener('touchend', () => { dragging = null; });
async function loadCurve() {
const data = await api('/api/curve');
points = data.points;
drawCurve();
}
function fmtMb(mb) {
if (mb == null) return '—';
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
return `${mb} MB`;
}
function tempClass(temp) {
return temp >= 70 ? 'temp-hot' : temp >= 55 ? 'temp-warm' : 'temp-cool';
}
function pushHistory(s) {
history.push({
t: Date.now(),
util: s.utilization_pct ?? 0,
power: s.power_w ?? 0,
temp: s.temperature_c ?? 0,
});
if (history.length > HISTORY_MAX) history.shift();
}
function updateGauges(s) {
const util = s.utilization_pct ?? 0;
document.getElementById('gauge-util-val').textContent = `${util}%`;
document.getElementById('gauge-util-bar').style.width = `${Math.min(100, util)}%`;
const power = s.power_w ?? 0;
const powerLimit = s.power_limit_w;
document.getElementById('gauge-power-val').textContent = `${power} W`;
const powerPct = powerLimit ? Math.min(100, (power / powerLimit) * 100) : Math.min(100, (power / 450) * 100);
document.getElementById('gauge-power-bar').style.width = `${powerPct}%`;
document.getElementById('gauge-power-sub').textContent =
powerLimit != null ? `Limit: ${powerLimit} W` : 'Limit: —';
const used = s.memory_used_mb;
const total = s.memory_total_mb;
if (used != null && total) {
const pct = Math.min(100, (used / total) * 100);
document.getElementById('gauge-vram-val').textContent = fmtMb(used);
document.getElementById('gauge-vram-bar').style.width = `${pct}%`;
document.getElementById('gauge-vram-sub').textContent = `${fmtMb(used)} / ${fmtMb(total)}`;
} else {
document.getElementById('gauge-vram-val').textContent = '—';
document.getElementById('gauge-vram-bar').style.width = '0%';
document.getElementById('gauge-vram-sub').textContent = '—';
}
}
function drawSparklines() {
const canvas = document.getElementById('history-canvas');
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const w = rect.width;
const h = rect.height;
const pad = { top: 8, right: 8, bottom: 8, left: 8 };
const plotW = w - pad.left - pad.right;
const plotH = h - pad.top - pad.bottom;
ctx.fillStyle = '#0d1117';
ctx.fillRect(0, 0, w, h);
if (history.length < 2) {
ctx.fillStyle = '#8b949e';
ctx.font = '12px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Zbieranie danych…', w / 2, h / 2);
return;
}
const maxPower = Math.max(...history.map(p => p.power), 1);
const maxTemp = Math.max(...history.map(p => p.temp), 1);
function drawSeries(key, color, maxVal) {
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
const n = history.length;
history.forEach((p, i) => {
const x = pad.left + (i / Math.max(1, n - 1)) * plotW;
const y = pad.top + plotH - (p[key] / maxVal) * plotH;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
}
drawSeries('util', '#58a6ff', 100);
drawSeries('power', '#d29922', maxPower);
drawSeries('temp', '#f85149', maxTemp);
}
function updateSensorTiles(s) {
const temp = s.temperature_c;
const tempEl = document.getElementById('sensor-temp');
tempEl.textContent = temp != null ? `${temp}°C` : '—';
tempEl.className = 'sensor-value ' + (temp != null ? tempClass(temp) : '');
document.getElementById('sensor-fans').textContent =
(s.fan_speeds_pct || []).map(f => f >= 0 ? `${f}%` : '—').join(' / ') || '—';
document.getElementById('sensor-gpu-clock').textContent =
s.clock_graphics_mhz != null ? `${s.clock_graphics_mhz} MHz` : '—';
document.getElementById('sensor-mem-clock').textContent =
s.clock_memory_mhz != null ? `${s.clock_memory_mhz} MHz` : '—';
document.getElementById('sensor-mem-util').textContent =
s.memory_utilization_pct != null ? `${s.memory_utilization_pct}%` : '—';
document.getElementById('sensor-mode').textContent = s.mode || '—';
}
async function refreshStatus() {
try {
const s = await api('/api/status');
document.getElementById('gpu-name').textContent = s.gpu_name || '—';
document.getElementById('mode-badge').textContent = `Tryb: ${s.mode}`;
liveTemp = s.temperature_c;
document.getElementById('stat-fans').textContent =
(s.fan_speeds_pct || []).map(f => f >= 0 ? `${f}%` : '—').join(' / ') || '—';
document.getElementById('stat-target').textContent =
s.target_speed_pct != null ? `${s.target_speed_pct}%` : 'auto';
pushHistory(s);
updateGauges(s);
updateSensorTiles(s);
drawSparklines();
drawCurve();
} catch (e) {
console.error(e);
}
}
document.getElementById('btn-save').addEventListener('click', async () => {
try {
await api('/api/curve', { method: 'PUT', body: JSON.stringify({ points }) });
toast('Krzywa zapisana');
refreshStatus();
} catch (e) { toast(e.message, 'error'); }
});
document.getElementById('btn-reload').addEventListener('click', async () => {
try {
await api('/api/reload', { method: 'POST' });
await loadCurve();
toast('Przeładowano z pliku');
} catch (e) { toast(e.message, 'error'); }
});
document.getElementById('btn-auto').addEventListener('click', async () => {
try {
await api('/api/mode', { method: 'POST', body: JSON.stringify({ mode: 'auto' }) });
toast('Tryb auto — sterowanie driverem NVIDIA');
refreshStatus();
} catch (e) { toast(e.message, 'error'); }
});
document.getElementById('btn-max').addEventListener('click', async () => {
try {
await api('/api/mode', { method: 'POST', body: JSON.stringify({ mode: 'manual', speed: 100 }) });
toast('Manual 100%');
refreshStatus();
} catch (e) { toast(e.message, 'error'); }
});
document.getElementById('btn-add').addEventListener('click', () => {
if (points.length >= 7) { toast('Max 7 punktów', 'error'); return; }
const last = points[points.length - 1];
const temp = Math.min(TEMP_MAX - 5, (last?.temp ?? 40) + 10);
const speed = Math.min(100, (last?.speed ?? 50) + 10);
points.push({ temp, speed });
drawCurve();
});
loadCurve().catch(e => toast(e.message, 'error'));
refreshStatus();
setInterval(refreshStatus, 2000);
window.addEventListener('resize', () => { if (history.length) drawSparklines(); });
</script>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
# llama.cpp stack (planned)
Placeholder for a future Docker stack serving **GGUF** models from `models.catalog.yaml` (lmstudio-community Q4).
vLLM on port **8000** stays separate; this stack is intended for port **8001**.
## Why a separate host
| Format | Runtime | Notes |
|--------|---------|-------|
| AWQ / safetensors (HF) | vLLM (`stacks/vllm/`) | Interim Q4 equivalent |
| GGUF Q4_K_M / Q4_0 | **llama.cpp** (this stack) | Native K/V cache `q4_0` like LM Studio |
Standard `vllm/vllm-openai` does **not** load `.gguf` files. Links in the catalog point here.
## Disk layout (created by vLLM scripts)
```
/data/apps/gguf/
├── qwen3.6-27b/Qwen3.6-27B-Q4_K_M.gguf
└── gemma-4-12b/
├── gemma-4-12B-it-QAT-Q4_0.gguf
└── mmproj-gemma-4-12B-it-QAT-BF16.gguf
```
Download on demand from vLLM stack:
```bash
cd stacks/vllm
./scripts/download-model.sh qwen3.6-27b-q4-gguf
./scripts/download-model.sh gemma-4-12b-q4-gguf
```
## Planned configuration
- **Binary:** `llama-server` (llama.cpp)
- **Router mode:** `--models-dir /data/apps/gguf` — multiple models on disk, one loaded per request via API `"model"` field
- **K/V cache:** `--cache-type-k q4_0 --cache-type-v q4_0` (LM Studio parity)
- **Port:** `8001` (vLLM remains on `8000`)
- **GPU:** RTX 3090 Ti only (`CUDA_VISIBLE_DEVICES=0`)
Example (not wired yet):
```bash
llama-server \
--host 0.0.0.0 \
--port 8001 \
--models-dir /data/apps/gguf \
--ctx-size 131072 \
--cache-type-k q4_0 \
--cache-type-v q4_0 \
--n-gpu-layers 999
```
## Model switching
Unlike vLLM (one model per container restart), llama.cpp router mode can keep several GGUF files on disk and select by name in the OpenAI-compatible API without restarting the whole service.
See: [Model management in llama.cpp](https://huggingface.co/blog/ggml-org/model-management-in-llamacpp)
## Status
- Catalog entries: `stacks/vllm/models.catalog.yaml` (`runtime: llamacpp`)
- Docker compose: **not implemented** — this README only
- When ready: add `docker-compose.yml`, profile, and `scripts/start.sh` mirroring the vLLM stack pattern
+16
View File
@@ -0,0 +1,16 @@
# Data disk mount point
DATA_ROOT=/data
# LocalAI web UI + OpenAI-compatible API (localhost bind when behind NPMPlus)
LOCALAI_PORT=8070
# Bearer token for /v1/* API
LOCALAI_API_KEY=
# Pinned GPU image for CUDA 13 (RTX 3090 Ti)
LOCALAI_IMAGE=localai/localai:v4.4.3-gpu-nvidia-cuda-13
# Use only the discrete NVIDIA GPU
CUDA_VISIBLE_DEVICES=0
DEBUG=false
+2
View File
@@ -0,0 +1,2 @@
.env
upstream/
+152
View File
@@ -0,0 +1,152 @@
# LocalAI stack
[LocalAI](https://github.com/mudler/LocalAI) — silnik inference z **wbudowanym UI** (chat) i API kompatybilnym z OpenAI. Obsługuje modele skwantyzowane (GGUF, AWQ, …) przez backendy (llama.cpp, vLLM, …).
## Porty
| Serwis | Port | URL |
|--------|------|-----|
| LocalAI UI + API | **8080** | `http://HOST:8080` |
| vLLM (osobny stack) | 8000 | tylko API, bez UI |
Jeden port — UI i API na tym samym endpoincie.
## Jak to działa
```mermaid
flowchart LR
browser["Przeglądarka"]
api["curl / OpenAI SDK"]
localai["LocalAI :8080"]
gpu["RTX 3090 Ti"]
models["/data/apps/localai/models"]
browser --> localai
api --> localai
localai --> gpu
localai --> models
```
| Element | Opis |
|---------|------|
| Obraz | `localai/localai:v4.4.3-gpu-nvidia-cuda-13` |
| Konfiguracja | `.env` + `docker-compose.yml` |
| Modele | `/data/apps/localai/models` (puste na start) |
| Upstream repo | opcjonalnie `upstream/` przez `clone-upstream.sh` |
## Struktura
```
stacks/localai/
├── README.md
├── docker-compose.yml
├── .env.example
├── .gitignore
├── coding-agent/ # notatki dla agenta (KV cache, STATE)
├── profiles/ # szablony YAML (KV q8_0)
├── upstream/ # shallow clone (gitignored)
└── scripts/
├── clone-upstream.sh
├── pull.sh
├── start.sh
└── apply-kv-profile.sh
```
Na dysku `/data`:
```
/data/apps/localai/
├── models/ # GGUF, YAML model configs
├── backends/ # custom backends
├── configuration/ # api_keys.json, runtime settings
├── images/ # generated images
└── data/ # agents, skills, persistent app data
```
## Workflow (bez modelu)
```bash
cd stacks/localai
cp .env.example .env
# opcjonalnie — referencja YAML z GitHub
./scripts/clone-upstream.sh
# tylko obraz Docker
./scripts/pull.sh
# uruchom (pusty katalog models/)
./scripts/start.sh
```
Weryfikacja:
```bash
curl -s http://localhost:8080/readyz
# UI: http://<IP-serwera>:8080
```
## Zmienne `.env`
| Zmienna | Opis | Domyślnie |
|---------|------|-----------|
| `DATA_ROOT` | Mount dysku danych | `/data` |
| `LOCALAI_PORT` | Port na hoście | `8080` |
| `LOCALAI_IMAGE` | Obraz Docker (CUDA 13) | `localai/localai:v4.4.3-gpu-nvidia-cuda-13` |
| `CUDA_VISIBLE_DEVICES` | GPU | `0` |
| `DEBUG` | Verbose logs | `false` |
## VRAM (24 GB)
Compose ustawia `SINGLE_ACTIVE_BACKEND=true` i `PARALLEL_REQUESTS=false` — jeden aktywny backend/model naraz.
**Nie uruchamiaj** dużego modelu w vLLM i LocalAI równocześnie na tej samej karcie:
```bash
cd ../vllm && docker compose --profile vllm down
```
## Modele (później)
- UI → Model Gallery w przeglądarce
- CLI w kontenerze: `docker exec -it localai local-ai models install ...`
- Ręcznie: GGUF + YAML w `/data/apps/localai/models/`
GGUF z [`stacks/vllm/models.catalog.yaml`](../vllm/models.catalog.yaml) można skopiować lub podlinkować do `models/`.
## KV cache (skwantyzowany q8_0)
Domyślnie llama.cpp trzyma KV cache w **f16** — dużo VRAM przy długim kontekście. Ustawienia są **per model** w YAML na `/data`, nie w compose.
| Pole | Rekomendacja |
|------|--------------|
| `cache_type_k` | `q8_0` |
| `cache_type_v` | `q8_0` |
| `flash_attention` | `true` (wymagane przy q8_0 V) |
| `context_size` | `8192` (start; zwiększ po teście VRAM) |
Szablon: [`profiles/gemma-4-12b-q4-kv-q8.yaml.example`](profiles/gemma-4-12b-q4-kv-q8.yaml.example)
Zastosowanie na istniejącym modelu:
```bash
./scripts/apply-kv-profile.sh gemma-4-12b-it-qat-q4_0
docker compose --profile localai restart localai
```
Szczegóły: [`coding-agent/KV-CACHE.md`](coding-agent/KV-CACHE.md)
## Zarządzanie
```bash
docker compose --profile localai ps
docker compose --profile localai logs -f localai
docker compose --profile localai restart localai
docker compose --profile localai down
```
## Dokumentacja
Tutorial: [manual-tutorial/05-localai-stack.md](../../manual-tutorial/05-localai-stack.md)
Upstream: [github.com/mudler/LocalAI](https://github.com/mudler/LocalAI)
+28
View File
@@ -0,0 +1,28 @@
# BACKLOG — LocalAI stack
## P0 — KV cache (bieżąca sesja)
- [x] Audyt konfiguracji KV (compose, YAML, backendy)
- [x] Szablon `profiles/gemma-4-12b-q4-kv-q8.yaml.example`
- [x] Skrypt `scripts/apply-kv-profile.sh`
- [x] Aktualizacja YAML Gemma na `/data` (via `docker exec` — plik root-owned)
- [x] Restart `localai` — health OK
- [ ] **Test VRAM po załadowaniu Gemma GGUF** — uzupełnić STATE.md
## P1 — po pierwszym modelu chat
- [ ] Dostroić `context_size` (8192 → 16384 jeśli VRAM pozwala)
- [ ] Porównanie jakości odpowiedzi f16 vs q8_0 KV (krótki prompt)
- [ ] Profil KV dla Qwen3.6-27B GGUF (gdy dodany do LocalAI)
- [ ] Przekazać `LOCALAI_API_KEY` do `docker-compose.yml` (zsynchronizować z root BACKLOG)
## P2 — opcjonalnie
- [ ] Backend `turboquant` + `turbo3`/`turbo4` (~34× kompresja KV)
- [ ] Skrypt sync GGUF z `stacks/vllm/models.catalog.yaml`
- [ ] Reverse proxy + firewall (root tutorial 07)
## P3 — dokumentacja
- [ ] Zsynchronizować port 8070 vs 8080 w całym repo (root BACKLOG)
- [ ] Przykład `curl /v1/chat/completions` z auth w tutorialu
@@ -0,0 +1,41 @@
# Konwencje — stack LocalAI
Skrót reguł specyficznych dla tego stacku. Pełne konwencje repo: [`../../coding-agent/CONVENTIONS.md`](../../coding-agent/CONVENTIONS.md).
## Ścieżki
| Warstwa | Ścieżka |
|---------|---------|
| Repo stack | `ubuntu-bare-metal/stacks/localai/` |
| `.env` (sekrety) | `stacks/localai/.env`**gitignore** |
| Modele runtime | `/data/apps/localai/models/` |
| YAML modeli | `/data/apps/localai/models/*.yaml`**poza git** |
| Szablony KV | `stacks/localai/profiles/*.yaml.example` |
| Notatki agenta | `stacks/localai/coding-agent/` |
## KV cache
- Ustawienia **tylko** w sekcji `parameters:` pliku YAML modelu.
- Skwantyzowany `cache_type_v` wymaga `flash_attention: true`.
- Dozwolone na `llama-cpp`: `f16`, `f32`, `q8_0`, `q4_0`, `q4_1`, `q5_0`, `q5_1`.
- Startowa rekomendacja: `cache_type_k: q8_0`, `cache_type_v: q8_0`, `context_size: 8192`.
- Modele embedding (np. bge-m3) — **nie** dodawać KV cache.
## Docker
```bash
cd stacks/localai
docker compose --profile localai up -d
docker compose --profile localai restart localai
```
Port wewnątrz kontenera zawsze **8080**; host mapuje `LOCALAI_PORT` (użytkownik: **8070**).
## VRAM
- Jeden duży model chat na GPU naraz.
- Przed loadem dużego modelu: `cd ../vllm && docker compose --profile vllm down`.
## Sekrety
- `LOCALAI_API_KEY` — tylko w `.env` na serwerze, nie w `coding-agent/`.
@@ -0,0 +1,147 @@
# RTX1 — raport naprawy embeddingu (odpowiedź dla ai-lawyer-srvr)
**Typ dokumentu:** raport po stronie **RTX1** (LocalAI na `gmktec-k11`) — odpowiedź na żądanie z hosta dev.
**Data:** 2026-06-30
**Status:** **NAPRAWIONE**`POST /v1/embeddings` zwraca HTTP 200, wektor **1024** wymiarów.
**Adresat:** agent kodujący na hoście dev (`ai-lawyer-srvr`).
---
## 1. Podsumowanie wykonawcze
| Obszar | Stan |
|--------|------|
| Connectivity (`llm.rtx1.mobile.agency-ai.dev`) | OK (wcześniej potwierdzone przez dev) |
| Graph LLM (`gemma-4-12b-it-qat-q4_0`) | OK |
| Embedding (`bge-m3-FP16.gguf`) | **OK** po poprawce YAML |
| Zmiana `.env` dev | **Nie wymagana** |
---
## 2. Root cause
Model `bge-m3-FP16.gguf` został zaimportowany z galerii LocalAI z konfiguracją **chat**, bez flagi embedding:
```yaml
# PRZED (błędne)
known_usecases:
- chat
# brak: embeddings: true
```
Skutek: worker llama-cpp padał przy `POST /v1/embeddings` z błędem:
```
rpc error: code = Unavailable desc = error reading from server: EOF
```
**Nie było to:** zły URL, API key, brak pliku GGUF (1.1 GB OK), ani brak VRAM w stanie idle.
---
## 3. Zastosowana poprawka
Zaktualizowano `/data/apps/localai/models/bge-m3-FP16.gguf.yaml`:
```yaml
name: bge-m3-FP16.gguf
backend: llama-cpp
embeddings: true
description: BGE-M3 embedding model (1024 dims)
known_usecases:
- embedding
parameters:
model: bge-m3-FP16.gguf
context_size: 8192
```
Szablon w repo (na przyszłe reimporty): [`../profiles/bge-m3-FP16-embedding.yaml.example`](../profiles/bge-m3-FP16-embedding.yaml.example)
Restart: `docker compose --profile localai restart localai`
---
## 4. Wyniki testów akceptacyjnych (RTX1)
| Test | Endpoint | HTTP | Wynik |
|------|----------|------|-------|
| Embeddings lokalnie | `POST http://127.0.0.1:8070/v1/embeddings` | **200** | wektor **1024** dims |
| Chat → embed (swap) | gemma chat, potem bge embed | **200** / **200** | `SINGLE_ACTIVE_BACKEND` OK |
| Embeddings przez domenę z RTX1 | `POST https://llm.rtx1.mobile.agency-ai.dev/v1/embeddings` | timeout (000) | hairpin NAT z wnętrza LAN — **nie blokuje dev** |
Test lokalny (na RTX1):
```bash
curl -s http://127.0.0.1:8070/v1/embeddings \
-H "Authorization: Bearer $LOCALAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"input":"test prawny","model":"bge-m3-FP16.gguf"}' \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d['data'][0]['embedding']))"
# Wynik: 1024
```
---
## 5. Wartości dla `.env` dev — bez zmian
```bash
EMBEDDING_URL=https://llm.rtx1.mobile.agency-ai.dev/v1
GRAPH_LLM_URL=https://llm.rtx1.mobile.agency-ai.dev/v1
EMBEDDING_MODEL=bge-m3-FP16.gguf
GRAPH_LLM_MODEL=gemma-4-12b-it-qat-q4_0
EMBEDDING_DIMS=1024
```
`EMBEDDING_API_KEY` / `GRAPH_LLM_API_KEY` — bez zmian (ten sam token co `LOCALAI_API_KEY` na RTX1).
---
## 6. Checklist dla agenta dev (wykonaj teraz)
1. [ ] `docker compose -f docker-compose.dev.yml exec api python scripts/discover_rtx1_models.py`
Oczekiwane: embed test **OK**, chat test **OK**
2. [ ] `docker compose -f docker-compose.dev.yml up -d api` (jeśli zmieniano `.env`)
3. [ ] Smoke hybrid chat — SSE z `"route": "hybrid"` i semantycznym retrieval
4. [ ] Jeśli ES był indeksowany bez wektorów: `python scripts/reindex_embeddings.py`
Test z hosta dev:
```bash
curl -s https://llm.rtx1.mobile.agency-ai.dev/v1/embeddings \
-H "Authorization: Bearer $EMBEDDING_API_KEY" \
-H "Content-Type: application/json" \
-d '{"input":"test prawny","model":"bge-m3-FP16.gguf"}' \
| python3 -c "import sys,json; d=json.load(sys.stdin); print('dims:', len(d['data'][0]['embedding']))"
```
Oczekiwane: `dims: 1024`
---
## 7. Architektura (bez zmian)
- Publiczny URL: `https://llm.rtx1.mobile.agency-ai.dev/v1`
- LocalAI LAN: `http://192.168.100.5:8070` (bind `0.0.0.0`)
- `SINGLE_ACTIVE_BACKEND=true` — gemma i bge przełączają się sekwencyjnie (OK dla hybrid RAG: embed RTX1 → chat RTX2)
---
## 8. Mapa dokumentacji
| Plik | Opis |
|------|------|
| Ten dokument | Status naprawy embeddingu |
| [`STATE.md`](STATE.md) | Runtime LocalAI na RTX1 |
| [`../profiles/bge-m3-FP16-embedding.yaml.example`](../profiles/bge-m3-FP16-embedding.yaml.example) | Szablon YAML embedding |
---
## 9. Odpowiedź na żądanie (tabela)
| Punkt | Odpowiedź |
|-------|-----------|
| 1. Root cause | Błędny YAML — `chat` zamiast `embedding`, brak `embeddings: true` |
| 2. Poprawka | YAML + restart LocalAI |
| 3. curl embed | HTTP **200**, **1024** dims (lokalnie na RTX1) |
| 4. Zmiana `id` / dims | **Nie**`bge-m3-FP16.gguf`, `1024` |
+46
View File
@@ -0,0 +1,46 @@
# HANDOFF — LocalAI KV cache
## Cel sesji
Przeanalizować konfigurację KV cache w LocalAI i włączyć **skwantyzowany KV** (`q8_0`), aby zmieścić większe modele / dłuższy kontekst na RTX 3090 Ti (24 GB).
## Wynik audytu
1. W [`docker-compose.yml`](../docker-compose.yml) i [`.env`](../.env) **brak** ustawień KV — to prawidłowe; LocalAI konfiguruje KV w YAML modelu.
2. YAML `gemma-4-12b-it-qat-q4_0` na `/data` nie miał `cache_type_k`, `cache_type_v`, `flash_attention`, `context_size` → domyślnie **f16** KV (więcej VRAM).
3. Backend: tylko `cuda13-llama-cpp`. TurboQuant **nie** instalowany.
4. Plik GGUF Gemma **jeszcze nie pobrany** — YAML gotowy przed pierwszym loadem.
## Decyzja
| Opcja | Wybór |
|-------|-------|
| Standard `q8_0` + `flash_attention` na `llama-cpp` | **TAK** |
| Backend `turboquant` (turbo3/4) | **NIE** (odłożone) |
Uzasadnienie: ~2× mniej pamięci KV vs f16, bez nowego backendu, minimalny wpływ na jakość.
## Co zrobiono w repo
- Katalog `coding-agent/` (ten handoff + STATE, BACKLOG, KV-CACHE, CONVENTIONS)
- [`profiles/gemma-4-12b-q4-kv-q8.yaml.example`](../profiles/gemma-4-12b-q4-kv-q8.yaml.example)
- [`scripts/apply-kv-profile.sh`](../scripts/apply-kv-profile.sh)
- Sekcja KV w README stacku i tutorialu 05
## Co zrobiono na serwerze
- Zaktualizowano `/data/apps/localai/models/gemma-4-12b-it-qat-q4_0.yaml` (parametry KV)
- Restart kontenera `localai`
## Następne kroki (dla agenta / użytkownika)
1. Dokończyć pobieranie GGUF Gemma 4 12B (galeria UI lub URI z `._gallery_*.yaml`).
2. Po loadzie: `nvidia-smi` + krótki chat — zapisać VRAM w [STATE.md](STATE.md).
3. Jeśli zapas VRAM: podnieść `context_size` do 16384 w YAML.
4. Dla przyszłego Qwen3.6-27B GGUF: skopiować wzorzec KV z `profiles/`.
5. BACKLOG P0 root repo: przekazać `LOCALAI_API_KEY` do compose (osobne zadanie).
## Dokumentacja zewnętrzna
- [LocalAI model configuration — cache_type_k/v](https://localai.io/advanced/model-configuration/)
- [Text generation — llama-cpp backend](https://localai.io/features/text-generation/)
+84
View File
@@ -0,0 +1,84 @@
# KV cache w LocalAI (llama-cpp)
## Problem
Przy inference LLM pamięć KV rośnie z długością kontekstu. Domyślnie LocalAI/llama.cpp używa **f16** dla K i V — pełna precyzja, najwięcej VRAM.
Na RTX 3090 Ti (24 GB) przy modelu Q4 + mmproj (Gemma 4 12B) kwantyzacja KV zwalnia miejsce na dłuższy `context_size` lub większy model.
## Gdzie konfigurować
| Miejsce | KV cache? |
|---------|-----------|
| `docker-compose.yml` | nie |
| `.env` stacku | nie |
| `models/<nazwa>.yaml``parameters:` | **tak** |
## Pola YAML (llama-cpp)
| Pole | Typ | Domyślnie | Opis |
|------|-----|-----------|------|
| `cache_type_k` | string | `f16` | Kwantyzacja cache kluczy (`-ctk` w llama.cpp) |
| `cache_type_v` | string | `f16` | Kwantyzacja cache wartości (`-ctv`) |
| `flash_attention` | bool/string | off | **Wymagane** przy skwantyzowanym `cache_type_v` |
| `context_size` | int | niski / auto | Maks. tokenów kontekstu (wpływa na rozmiar KV) |
### Dozwolone typy (`cuda13-llama-cpp`)
`f16`, `f32`, `q8_0`, `q4_0`, `q4_1`, `q5_0`, `q5_1`
### Rekomendacja dla tego serwera (q8_0)
```yaml
parameters:
cache_type_k: q8_0
cache_type_v: q8_0
flash_attention: true
context_size: 8192
```
Po teście VRAM można podnieść `context_size` do `16384`.
## Szacunek VRAM (Gemma 4 12B Q4_0 + mmproj)
| Składnik | Orientacyjnie |
|----------|---------------|
| Wagi + mmproj | ~810 GB |
| KV @ f16, ctx 8k | ~24 GB |
| KV @ q8_0, ctx 8k | ~12 GB |
## TurboQuant (odłożone)
Backend `turboquant` + typy `turbo2`/`turbo3`/`turbo4` dają większą kompresję (~34×), ale wymagają:
```bash
docker exec localai /local-ai backends install turboquant
```
oraz `backend: turboquant` w YAML. Nie wdrożone w bieżącej sesji.
## Zastosowanie profilu
```bash
cd stacks/localai
./scripts/apply-kv-profile.sh gemma-4-12b-it-qat-q4_0
docker compose --profile localai restart localai
```
## Weryfikacja
```bash
# modele
curl -s http://localhost:8070/v1/models -H "Authorization: Bearer $KEY"
# VRAM
nvidia-smi
# logi backendu
docker compose --profile localai logs localai 2>&1 | grep -iE 'cache|ctk|ctv|flash' | tail -20
```
## Źródła
- https://localai.io/advanced/model-configuration/
- https://localai.io/features/text-generation/
+25
View File
@@ -0,0 +1,25 @@
# coding-agent — notatki dla agenta (stack LocalAI)
Katalog handoff dla sesji Cursor pracujących nad [`stacks/localai/`](../) na serwerze GMKtec K11.
## Kolejność czytania
1. **[HANDOFF.md](HANDOFF.md)** — decyzje (KV q8_0), audyt, następne kroki
2. **[STATE.md](STATE.md)** — stan runtime: kontener, modele, backendy, VRAM
3. **[KV-CACHE.md](KV-CACHE.md)** — referencja techniczna KV cache w YAML
4. **[BACKLOG.md](BACKLOG.md)** — priorytetyzowane zadania
5. **[CONVENTIONS.md](CONVENTIONS.md)** — ścieżki, sekrety, konwencje stacku
Wspólne konwencje repo: [`../../coding-agent/CONVENTIONS.md`](../../coding-agent/CONVENTIONS.md)
## Zasady
- Instrukcje dla użytkownika: **po polsku**. Komendy: **po angielsku**.
- **Nie commituj** ani nie zapisuj tutaj wartości `LOCALAI_API_KEY`, tokenów HF itd.
- YAML modeli na `/data/apps/localai/models/` **nie są w git** — szablony trzymaj w [`../profiles/`](../profiles/).
- Commity i push **tylko na prośbę** użytkownika.
- Nie edytuj plików planu w `.cursor/plans/`.
## Ostatnia aktualizacja
Sesja: audyt KV cache → wdrożenie `cache_type_k/v: q8_0` + `flash_attention` dla modeli chat (llama-cpp).
+62
View File
@@ -0,0 +1,62 @@
# STATE — LocalAI runtime
Ostatnia aktualizacja: po wdrożeniu BGE-Reranker-v2-m3 (2026-07-01).
## Kontener
| Element | Wartość |
|---------|---------|
| Nazwa | `localai` |
| Obraz | `localai/localai:v4.4.3-gpu-nvidia-cuda-13` |
| Status | running (healthy) |
| Port hosta | **8070** → 8080 w kontenerze (`0.0.0.0` — LAN) |
| GPU | `CUDA_VISIBLE_DEVICES=0` (RTX 3090 Ti) |
| API auth | `LOCALAI_API_KEY` w compose — 401 bez Bearer |
## Publiczny endpoint (NPMPlus)
| Element | Wartość |
|---------|---------|
| Domena | `https://llm.rtx1.mobile.agency-ai.dev/v1` |
| Upstream | `http://127.0.0.1:8070` (lub LAN `192.168.100.5:8070`) |
## Backendy
| Backend | Zainstalowany |
|---------|---------------|
| `cuda13-llama-cpp` (alias `llama-cpp`) | tak |
| `turboquant` | **nie** (odłożone) |
## Modele (`/data/apps/localai/models/`)
| Model | GGUF | YAML | Status API |
|-------|------|------|------------|
| `gemma-4-12b-it-qat-q4_0` | tak (~6.5 GB) | KV `q8_0`, `flash_attention`, `context_size: 8192` | chat **OK** |
| `bge-m3-FP16.gguf` | tak (1.1 GB) | `embeddings: true`, `known_usecases: [embedding]` | embed **OK**, 1024 dims |
| `bge-reranker-v2-m3-FP16.gguf` | tak (1.1 GB) | `reranking: true`, `known_usecases: [rerank]`, `backend: llama-cpp` | rerank **OK** |
Szablony w repo:
- Gemma KV: [`../profiles/gemma-4-12b-q4-kv-q8.yaml.example`](../profiles/gemma-4-12b-q4-kv-q8.yaml.example)
- BGE embed: [`../profiles/bge-m3-FP16-embedding.yaml.example`](../profiles/bge-m3-FP16-embedding.yaml.example)
- BGE rerank: [`../profiles/bge-reranker-v2-m3-FP16-rerank.yaml.example`](../profiles/bge-reranker-v2-m3-FP16-rerank.yaml.example)
## Weryfikacja (2026-06-30)
| Test | Wynik |
|------|-------|
| `GET /readyz` | 200 |
| `GET /v1/models` (auth) | 200 — 2 modele |
| `POST /v1/chat/completions` (gemma) | 200 |
| `POST /v1/embeddings` (bge-m3) | **200**, wektor **1024** |
| `POST /v1/rerank` (bge-reranker) | **200**, indeks 2 (panda) na top |
| Chat → embed (SINGLE_ACTIVE_BACKEND) | 200 / 200 |
| Embeddings z RTX1 przez publiczną domenę | timeout (hairpin NAT) — dev powinien testować z zewnątrz |
## Ścieżki
| Co | Gdzie |
|----|-------|
| Repo stack | `/home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal/stacks/localai` |
| Modele runtime | `/data/apps/localai/models` |
| Raport dla dev | [`EMBEDDING-STATUS-REPORT.md`](EMBEDDING-STATUS-REPORT.md) |
+1
View File
@@ -0,0 +1 @@
docker-compose.yml
+31
View File
@@ -0,0 +1,31 @@
services:
localai:
image: ${LOCALAI_IMAGE:-localai/localai:v4.4.3-gpu-nvidia-cuda-13}
container_name: localai
profiles:
- localai
restart: unless-stopped
init: true
ports:
- "${LOCALAI_PORT:-8080}:8080"
environment:
- LOCALAI_API_KEY=${LOCALAI_API_KEY:-}
- CUDA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:-0}
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
- MODELS_PATH=/models
- DEBUG=${DEBUG:-false}
- SINGLE_ACTIVE_BACKEND=true
- PARALLEL_REQUESTS=false
volumes:
- ${DATA_ROOT:-/data}/apps/localai/models:/models
- ${DATA_ROOT:-/data}/apps/localai/backends:/backends
- ${DATA_ROOT:-/data}/apps/localai/configuration:/configuration
- ${DATA_ROOT:-/data}/apps/localai/images:/tmp/generated/images
- ${DATA_ROOT:-/data}/apps/localai/data:/data
gpus: all
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/readyz"]
interval: 1m
timeout: 20m
retries: 5
start_period: 2m
@@ -0,0 +1,16 @@
# BGE-M3 embedding model for LocalAI (llama-cpp backend).
# Copy to /data/apps/localai/models/bge-m3-FP16.gguf.yaml after gallery import.
#
# GGUF: bge-m3-FP16.gguf (~1.1 GB) in same directory as this YAML.
# API id: bge-m3-FP16.gguf
# Vector dimensions: 1024
name: bge-m3-FP16.gguf
backend: llama-cpp
embeddings: true
description: BGE-M3 embedding model (1024 dims)
known_usecases:
- embedding
parameters:
model: bge-m3-FP16.gguf
context_size: 8192
@@ -0,0 +1,27 @@
# BGE-Reranker-v2-m3 for LocalAI (llama-cpp / cuda13-llama-cpp backend).
# Copy to /data/apps/localai/models/bge-reranker-v2-m3-FP16.gguf.yaml after GGUF download.
#
# GGUF: bge-reranker-v2-m3-FP16.gguf (~1.1 GB) in same directory as this YAML.
# API id: bge-reranker-v2-m3-FP16.gguf
# Endpoint: POST /v1/rerank
#
# Do NOT use backend: rerankers for GGUF — that backend is for HuggingFace transformers.
# cuda13-llama-cpp is selected automatically on the cuda-13 image when backend: llama-cpp.
name: bge-reranker-v2-m3-FP16.gguf
backend: llama-cpp
reranking: true
embeddings: false
description: BGE-Reranker-v2-m3 cross-encoder (FP16 GGUF)
known_usecases:
- rerank
parameters:
model: bge-reranker-v2-m3-FP16.gguf
context_size: 8192
template:
use_tokenizer_template: true
function:
grammar:
disable: true
options:
- use_jinja:true
@@ -0,0 +1,32 @@
# Szablon: Gemma 4 12B Q4_0 z kwantyzowanym KV cache (q8_0)
#
# Zastosowanie na serwerze:
# ./scripts/apply-kv-profile.sh gemma-4-12b-it-qat-q4_0
# lub skopiuj sekcję parameters do /data/apps/localai/models/<nazwa>.yaml
#
# Dokumentacja: coding-agent/KV-CACHE.md
name: gemma-4-12b-it-qat-q4_0
backend: llama-cpp
mmproj: llama-cpp/mmproj/gemma-4-12B-it-qat-q4_0-gguf/mmproj-gemma-4-12b-it-qat-q4_0.gguf
known_usecases:
- chat
options:
- use_jinja:true
parameters:
model: llama-cpp/models/gemma-4-12B-it-qat-q4_0-gguf/gemma-4-12b-it-qat-q4_0.gguf
cache_type_k: q8_0
cache_type_v: q8_0
flash_attention: true
context_size: 8192
temperature: 1
top_p: 0.95
top_k: 64
repeat_penalty: 1
min_p: 0
template:
use_tokenizer_template: true
function:
automatic_tool_parsing_fallback: true
grammar:
disable: true
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Merge KV cache settings (q8_0) into a model YAML on /data.
# Usage: ./scripts/apply-kv-profile.sh <model-name-without-.yaml>
# Example: ./scripts/apply-kv-profile.sh gemma-4-12b-it-qat-q4_0
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
if [[ ! -f "${STACK_DIR}/.env" ]]; then
echo "ERROR: ${STACK_DIR}/.env not found"
exit 1
fi
set -a
# shellcheck disable=SC1091
source "${STACK_DIR}/.env"
set +a
DATA_ROOT="${DATA_ROOT:-/data}"
MODELS_DIR="${DATA_ROOT}/apps/localai/models"
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <model-name>"
echo "Example: $0 gemma-4-12b-it-qat-q4_0"
exit 1
fi
MODEL_NAME="$1"
TARGET="${MODELS_DIR}/${MODEL_NAME}.yaml"
if [[ ! -f "${TARGET}" ]]; then
echo "ERROR: ${TARGET} not found"
exit 1
fi
BACKUP="${TARGET}.bak.$(date +%Y%m%d%H%M%S)"
cp "${TARGET}" "${BACKUP}"
echo "Backup: ${BACKUP}"
run_python_merge() {
python3 - "${1}" <<'PY'
import sys
from pathlib import Path
try:
import yaml
except ImportError:
sys.exit("ERROR: python3-yaml required (sudo apt install python3-yaml)")
path = Path(sys.argv[1])
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
params = data.setdefault("parameters", {})
kv = {
"cache_type_k": "q8_0",
"cache_type_v": "q8_0",
"flash_attention": True,
}
if "context_size" not in params:
kv["context_size"] = 8192
params.update(kv)
path.write_text(
yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
print(f"Updated KV settings in {path}")
for k, v in kv.items():
print(f" {k}: {v}")
if "context_size" in params and "context_size" not in kv:
print(f" context_size: {params['context_size']} (unchanged)")
PY
}
if run_python_merge "${TARGET}" 2>/dev/null; then
:
elif docker ps --format '{{.Names}}' 2>/dev/null | grep -qx localai; then
echo "Host write failed — applying via docker exec localai (models volume is root-owned)"
CONTAINER_PATH="/models/${MODEL_NAME}.yaml"
docker exec localai sh -c "grep -q cache_type_k '${CONTAINER_PATH}' || sed -i '/^ model:/a\\
cache_type_k: q8_0\\
cache_type_v: q8_0\\
flash_attention: true\\
context_size: 8192' '${CONTAINER_PATH}'"
grep -E 'cache_type_k|cache_type_v|flash_attention|context_size' "${TARGET}" || true
else
echo "ERROR: cannot write ${TARGET} (permission denied) and container localai not running"
echo "Run: sudo $0 ${MODEL_NAME}"
exit 1
fi
echo ""
echo "Restart LocalAI to apply:"
echo " cd ${STACK_DIR} && docker compose --profile localai restart localai"
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
UPSTREAM_DIR="${STACK_DIR}/upstream"
REPO_URL="https://github.com/mudler/LocalAI.git"
TAG="v4.4.3"
cd "${STACK_DIR}"
if [[ -d "${UPSTREAM_DIR}/.git" ]]; then
echo "Upstream already cloned: ${UPSTREAM_DIR}"
echo "Remove it first to re-clone: rm -rf upstream"
exit 0
fi
echo "=== Cloning LocalAI upstream (reference only) ==="
echo "Repo: ${REPO_URL}"
echo "Tag: ${TAG}"
echo "Dest: ${UPSTREAM_DIR}"
echo ""
git clone --depth 1 --branch "${TAG}" "${REPO_URL}" "${UPSTREAM_DIR}"
echo ""
echo "Done. Use upstream/ for example model YAML and compose reference."
echo "Runtime uses the official Docker image from .env — not a local build."
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Download BGE-Reranker-v2-m3 FP16 GGUF and apply YAML profile.
# Usage: ./scripts/download-reranker.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${STACK_DIR}"
if [[ -f .env ]]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
DATA_ROOT="${DATA_ROOT:-/data}"
MODELS_DIR="${DATA_ROOT}/apps/localai/models"
GGUF_NAME="bge-reranker-v2-m3-FP16.gguf"
GGUF_URL="https://huggingface.co/gpustack/bge-reranker-v2-m3-GGUF/resolve/main/${GGUF_NAME}"
YAML_SRC="${STACK_DIR}/profiles/bge-reranker-v2-m3-FP16-rerank.yaml.example"
YAML_DST="${MODELS_DIR}/${GGUF_NAME}.yaml"
"${SCRIPT_DIR}/ensure-dirs.sh" "${DATA_ROOT}"
echo "=== BGE-Reranker-v2-m3 download ==="
echo "Target: ${MODELS_DIR}/${GGUF_NAME}"
echo ""
if [[ -f "${MODELS_DIR}/${GGUF_NAME}" ]]; then
echo "GGUF already exists — skipping download"
else
if command -v wget &>/dev/null; then
wget -c -O "${MODELS_DIR}/${GGUF_NAME}.partial" "${GGUF_URL}"
mv "${MODELS_DIR}/${GGUF_NAME}.partial" "${MODELS_DIR}/${GGUF_NAME}"
else
curl -fL -C - -o "${MODELS_DIR}/${GGUF_NAME}.partial" "${GGUF_URL}"
mv "${MODELS_DIR}/${GGUF_NAME}.partial" "${MODELS_DIR}/${GGUF_NAME}"
fi
fi
echo ""
echo "=== Applying YAML profile ==="
if cp "${YAML_SRC}" "${YAML_DST}" 2>/dev/null; then
chmod 644 "${YAML_DST}" "${MODELS_DIR}/${GGUF_NAME}" 2>/dev/null || true
elif docker ps --format '{{.Names}}' | grep -qx localai; then
echo "Host copy failed (permissions) — writing via docker exec localai"
docker exec -i localai sh -c "cat > /models/${GGUF_NAME}.yaml" < "${YAML_SRC}"
else
echo "ERROR: cannot write ${YAML_DST} (permission denied) and localai container not running"
exit 1
fi
ls -lh "${MODELS_DIR}/${GGUF_NAME}" "${YAML_DST}"
echo ""
echo "=== Done ==="
echo "Restart LocalAI to load the model:"
echo " cd ${STACK_DIR} && docker compose --profile localai restart localai"
echo ""
echo "Smoke test:"
echo ' curl -s http://127.0.0.1:${LOCALAI_PORT:-8070}/v1/rerank \'
echo ' -H "Authorization: Bearer <LOCALAI_API_KEY>" \'
echo ' -H "Content-Type: application/json" \'
echo ' -d '"'"'{"model":"bge-reranker-v2-m3-FP16.gguf","query":"panda","documents":["hi","it is a bear","The giant panda is a bear species endemic to China."],"top_n":2}'"'"
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Create LocalAI data directories on the data disk.
ensure_localai_dirs() {
local data_root="${1:-/data}"
mkdir -p \
"${data_root}/apps/localai/models" \
"${data_root}/apps/localai/backends" \
"${data_root}/apps/localai/configuration" \
"${data_root}/apps/localai/images" \
"${data_root}/apps/localai/data"
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
ensure_localai_dirs "${1:-/data}"
fi
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${STACK_DIR}"
if [[ ! -f .env ]]; then
echo "ERROR: .env not found. Run: cp .env.example .env"
exit 1
fi
set -a
# shellcheck disable=SC1091
source .env
set +a
if ! docker info &>/dev/null; then
echo "ERROR: Docker is not running"
exit 1
fi
echo "=== LocalAI — pull image only (no start) ==="
echo "Image: ${LOCALAI_IMAGE:-localai/localai:v4.4.3-gpu-nvidia-cuda-13}"
echo ""
docker compose --profile localai pull
echo ""
echo "Done. Start with: ./scripts/start.sh"
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/ensure-dirs.sh"
cd "${STACK_DIR}"
if [[ ! -f .env ]]; then
echo "ERROR: .env not found. Run: cp .env.example .env"
exit 1
fi
set -a
# shellcheck disable=SC1091
source .env
set +a
DATA_ROOT="${DATA_ROOT:-/data}"
if ! mountpoint -q "${DATA_ROOT}" 2>/dev/null; then
echo "ERROR: ${DATA_ROOT} is not mounted"
echo " Complete disk setup (tutorial 04 part A) first"
exit 1
fi
ensure_localai_dirs "${DATA_ROOT}"
if ! docker info &>/dev/null; then
echo "ERROR: Docker is not running"
exit 1
fi
echo "=== LocalAI stack ==="
echo "Image: ${LOCALAI_IMAGE:-localai/localai:v4.4.3-gpu-nvidia-cuda-13}"
echo "Port: ${LOCALAI_PORT:-8080}"
echo "Models: ${DATA_ROOT}/apps/localai/models (empty OK)"
echo "GPU: CUDA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:-0}"
echo ""
docker compose --profile localai pull
docker compose --profile localai up -d
echo ""
echo "Started. Follow logs:"
echo " docker compose --profile localai logs -f localai"
echo ""
echo "Health check:"
echo " curl -s http://localhost:${LOCALAI_PORT:-8080}/readyz"
echo ""
echo "Web UI:"
echo " http://localhost:${LOCALAI_PORT:-8080}"
+21
View File
@@ -0,0 +1,21 @@
# Data disk mount point
DATA_ROOT=/data
# NPMPlus image
NPMPLUS_IMAGE=docker.io/zoeyvid/npmplus:latest
# Required for Let's Encrypt certificates
ACME_EMAIL=admin@example.com
# Timezone (TZ database name)
TZ=Europe/Warsaw
# Optional: set on first start instead of random password in logs
# INITIAL_ADMIN_EMAIL=admin@example.com
# INITIAL_ADMIN_PASSWORD=change-me-strong-password
# Bind admin UI (port 81) to localhost only — use with SSH tunnel
# NPM_LISTEN_LOCALHOST=true
# Disable IPv6 listeners if your network has no IPv6
# DISABLE_IPV6=true
+1
View File
@@ -0,0 +1 @@
.env
+89
View File
@@ -0,0 +1,89 @@
# NPMPlus — reverse proxy + Let's Encrypt
[NPMPlus](https://github.com/ZoeyVid/NPMPlus) (fork nginx-proxy-manager) exposes backend services on **HTTPS** with automatic certificates.
On this host it proxies **LocalAI** at `https://llm.rtx1.mobile.agency-ai.dev``http://127.0.0.1:8070`.
## Ports (host network)
| Port | Protocol | Service |
|------|----------|---------|
| 80 | tcp | HTTP (ACME challenge + redirects) |
| 443 | tcp, udp | HTTPS / HTTP3 |
| 81 | tcp | NPMPlus admin UI (HTTPS) |
## Prerequisites
1. `/data` mounted (tutorial 04)
2. DNS `A` record: `llm.rtx1.mobile.agency-ai.dev` → public IP
3. Router port forward: `80`, `443/tcp`, `443/udp``192.168.100.5`
4. `ACME_EMAIL` in `.env` (Let's Encrypt notifications)
## Quick start
```bash
cd stacks/npmplus
cp .env.example .env
# Edit ACME_EMAIL (and optionally INITIAL_ADMIN_*)
./scripts/start.sh
```
Admin UI: `https://<LAN-IP>:81` (default login `admin@example.org` unless `INITIAL_ADMIN_EMAIL` set).
## Admin access (LAN)
Use **`https://192.168.100.90:81`** (or your LAN IP), **not** `http://`.
| Symptom | Cause |
|---------|-------|
| `http://IP:81` does not load UI | Port 81 serves HTTPS only; HTTP returns 308 redirect |
| Browser shows certificate error | Default cert has no SAN for IP; run cert regeneration (below) |
| `docker ps` shows empty PORTS | Normal with `network_mode: host` — check with `ss -tlnp \| grep ':81'` |
Regenerate admin cert with SAN for your LAN IP:
```bash
sudo ./scripts/regenerate-admin-cert.sh
```
After regeneration, open `https://<LAN-IP>:81`, click through the self-signed cert warning once, then log in.
## Proxy host — LocalAI
In NPMPlus UI → **Hosts****Proxy Hosts** → Add:
| Field | Value |
|-------|-------|
| Domain | `llm.rtx1.mobile.agency-ai.dev` |
| Scheme | `http` |
| Forward hostname | `127.0.0.1` |
| Forward port | `8070` |
| SSL | Request new certificate, Force SSL, HTTP/2 |
| Websockets | Enabled |
Clients send `Authorization: Bearer <LOCALAI_API_KEY>` (same key as in `stacks/localai/.env`).
Test:
```bash
curl -s https://llm.rtx1.mobile.agency-ai.dev/v1/models \
-H "Authorization: Bearer $LOCALAI_API_KEY"
```
## Firewall
```bash
sudo ./scripts/configure-firewall.sh
```
Restricts direct access to LocalAI (8070) and gpu-fan (8090); allows 80/443 for NPMPlus.
## Data
Persistent files: `/data/apps/npmplus/`
## Related
- LocalAI stack: [`../localai/README.md`](../localai/README.md)
- Client handoff: [`../localai/coding-agent/APP-CLIENT-HANDOFF.md`](../localai/coding-agent/APP-CLIENT-HANDOFF.md)
- Tutorial: [`../../manual-tutorial/07-npmplus-reverse-proxy.md`](../../manual-tutorial/07-npmplus-reverse-proxy.md)
+1
View File
@@ -0,0 +1 @@
docker-compose.yml
+27
View File
@@ -0,0 +1,27 @@
name: npmplus
services:
npmplus:
image: ${NPMPLUS_IMAGE:-docker.io/zoeyvid/npmplus:latest}
container_name: npmplus
profiles:
- npmplus
restart: unless-stopped
network_mode: host
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
- SETGID
- DAC_OVERRIDE
security_opt:
- no-new-privileges:true
volumes:
- ${DATA_ROOT:-/data}/apps/npmplus:/data
environment:
- TZ=${TZ:-Europe/Warsaw}
- ACME_EMAIL=${ACME_EMAIL}
- INITIAL_ADMIN_EMAIL=${INITIAL_ADMIN_EMAIL:-}
- INITIAL_ADMIN_PASSWORD=${INITIAL_ADMIN_PASSWORD:-}
- NPM_LISTEN_LOCALHOST=${NPM_LISTEN_LOCALHOST:-false}
- DISABLE_IPV6=${DISABLE_IPV6:-false}
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# UFW rules for NPMPlus + LocalAI hardening. Run: sudo ./scripts/configure-firewall.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo ${SCRIPT_DIR}/configure-firewall.sh"
exit 1
fi
LAN_CIDR="${LAN_CIDR:-192.168.100.0/24}"
echo "=== NPMPlus firewall (ufw) ==="
echo "LAN CIDR for admin port 81: ${LAN_CIDR}"
echo ""
if ! command -v ufw &>/dev/null; then
echo "Installing ufw..."
apt-get update -qq && apt-get install -y ufw
fi
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp comment 'SSH'
ufw allow 80/tcp comment 'NPMPlus HTTP / ACME'
ufw allow 443/tcp comment 'NPMPlus HTTPS'
ufw allow 443/udp comment 'NPMPlus HTTP/3'
ufw allow from "${LAN_CIDR}" to any port 81 proto tcp comment 'NPMPlus admin LAN only'
ufw deny 8070/tcp comment 'LocalAI localhost only'
ufw deny 8090/tcp comment 'gpu-fan not public'
echo ""
echo "Rules to apply:"
ufw show added || true
echo ""
ufw --force enable
ufw status verbose
echo ""
echo "Done. Admin UI: https://<LAN-IP>:81 (from ${LAN_CIDR} only)"
echo "Public HTTPS: ports 80/443"
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# Create NPMPlus proxy host for LocalAI + request Let's Encrypt cert.
# Usage: ./scripts/configure-localai-proxy.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${STACK_DIR}"
if [[ ! -f .env ]]; then
echo "ERROR: .env not found"
exit 1
fi
set -a
# shellcheck disable=SC1091
source .env
set +a
export INITIAL_ADMIN_EMAIL INITIAL_ADMIN_PASSWORD ACME_EMAIL \
LOCALAI_PROXY_DOMAIN LOCALAI_FORWARD_PORT
python3 <<'PY'
import json
import os
import ssl
import sys
import urllib.error
import urllib.request
import http.cookiejar
domain = os.environ.get("LOCALAI_PROXY_DOMAIN", "llm.rtx1.mobile.agency-ai.dev")
forward_port = int(os.environ.get("LOCALAI_FORWARD_PORT", "8070"))
admin_email = os.environ.get("INITIAL_ADMIN_EMAIL", "")
admin_pass = os.environ.get("INITIAL_ADMIN_PASSWORD", "")
acme_email = os.environ.get("ACME_EMAIL", admin_email)
if not admin_email or not admin_pass:
print("ERROR: Set INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD in .env", file=sys.stderr)
sys.exit(1)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(cj),
urllib.request.HTTPSHandler(context=ctx),
)
def api(method: str, path: str, payload=None):
data = None
headers = {}
if payload is not None:
data = json.dumps(payload).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(f"https://127.0.0.1:81{path}", data=data, headers=headers, method=method)
with opener.open(req) as resp:
body = resp.read()
return json.loads(body) if body else {}
try:
api("POST", "/api/tokens", {"identity": admin_email, "secret": admin_pass})
except urllib.error.HTTPError as e:
print(f"ERROR: NPMPlus login failed ({e.code})", file=sys.stderr)
print(e.read().decode()[:300], file=sys.stderr)
sys.exit(1)
if not any(c.name == "__Host-Http-token" for c in cj):
print("ERROR: NPMPlus login did not return session cookie", file=sys.stderr)
sys.exit(1)
hosts = api("GET", "/api/nginx/proxy-hosts")
for host in hosts:
if domain in host.get("domain_names", []):
print(f"Proxy host already exists (id={host['id']}) for {domain}")
sys.exit(0)
payload = {
"domain_names": [domain],
"forward_scheme": "http",
"forward_host": "127.0.0.1",
"forward_port": forward_port,
"access_list_id": 0,
"certificate_id": "new",
"ssl_forced": True,
"http2_support": True,
"block_exploits": True,
"allow_websocket_upgrade": True,
"meta": {
"letsencrypt_email": acme_email,
"letsencrypt_agree": True,
"dns_challenge": False,
},
}
try:
result = api("POST", "/api/nginx/proxy-hosts", payload)
except urllib.error.HTTPError as e:
print(f"ERROR: create proxy host failed ({e.code})", file=sys.stderr)
print(e.read().decode()[:500], file=sys.stderr)
sys.exit(1)
print(json.dumps(result, indent=2))
print(f"\nProxy host created for https://{domain} -> 127.0.0.1:{forward_port}")
print("Certificate issuance may take 1-2 minutes (check NPMPlus logs).")
PY
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Enable HTTP proxy for LocalAI (no SSL) — use when LE cert is pending port-forward.
# After cert works, enable SSL in NPMPlus UI or re-run configure-localai-proxy.sh.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${STACK_DIR}"
set -a
# shellcheck disable=SC1091
source .env
set +a
export INITIAL_ADMIN_EMAIL INITIAL_ADMIN_PASSWORD LOCALAI_PROXY_DOMAIN LOCALAI_FORWARD_PORT
python3 <<'PY'
import json
import os
import ssl
import sys
import urllib.error
import urllib.request
import http.cookiejar
domain = os.environ.get("LOCALAI_PROXY_DOMAIN", "llm.rtx1.mobile.agency-ai.dev")
forward_port = int(os.environ.get("LOCALAI_FORWARD_PORT", "8070"))
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(cj),
urllib.request.HTTPSHandler(context=ctx),
)
def api(method, path, payload=None):
data = json.dumps(payload).encode() if payload is not None else None
headers = {"Content-Type": "application/json"} if payload is not None else {}
req = urllib.request.Request(
f"https://127.0.0.1:81{path}", data=data, headers=headers, method=method
)
with opener.open(req) as resp:
body = resp.read()
return json.loads(body) if body else {}
api("POST", "/api/tokens", {
"identity": os.environ["INITIAL_ADMIN_EMAIL"],
"secret": os.environ["INITIAL_ADMIN_PASSWORD"],
})
hosts = api("GET", "/api/nginx/proxy-hosts")
host = next((h for h in hosts if domain in h.get("domain_names", [])), None)
if not host:
print(f"No proxy host for {domain}", file=sys.stderr)
sys.exit(1)
host_id = host["id"]
payload = {
**{k: host[k] for k in host if k not in ("id", "created_on", "modified_on", "meta")},
"ssl_forced": False,
"certificate_id": 0,
"http2_support": False,
"allow_websocket_upgrade": True,
"forward_scheme": "http",
"forward_host": "127.0.0.1",
"forward_port": forward_port,
}
try:
result = api("PUT", f"/api/nginx/proxy-hosts/{host_id}", payload)
except urllib.error.HTTPError as e:
print(f"ERROR ({e.code}):", e.read().decode()[:500], file=sys.stderr)
sys.exit(1)
print(f"Updated proxy host {host_id}: HTTP only until LE cert is ready")
print(json.dumps({"id": result.get("id"), "domain_names": result.get("domain_names")}, indent=2))
PY
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Create NPMPlus data directory on the data disk.
ensure_npmplus_dirs() {
local data_root="${1:-/data}"
mkdir -p "${data_root}/apps/npmplus"
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
ensure_npmplus_dirs "${1:-/data}"
fi
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Regenerate NPMPlus admin UI TLS cert (port 81) with SAN for LAN IP access.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${STACK_DIR}"
if [[ -f .env ]]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
DATA_ROOT="${DATA_ROOT:-/data}"
TLS_DIR="${DATA_ROOT}/apps/npmplus/tls"
LAN_IP="${LAN_IP:-$(hostname -I 2>/dev/null | awk '{print $1}')}"
HOST_SHORT="${HOST_SHORT:-$(hostname -s 2>/dev/null || hostname)}"
if [[ -z "${LAN_IP}" ]]; then
echo "ERROR: Could not detect LAN IP. Set LAN_IP in environment."
exit 1
fi
if [[ ! -d "${TLS_DIR}" ]]; then
echo "ERROR: ${TLS_DIR} not found. Start NPMPlus once: ./scripts/start.sh"
exit 1
fi
SAN="IP:${LAN_IP},DNS:${HOST_SHORT},DNS:localhost"
TS="$(date +%Y%m%d%H%M%S)"
echo "=== NPMPlus admin cert regeneration ==="
echo "TLS dir: ${TLS_DIR}"
echo "SAN: ${SAN}"
echo ""
if [[ "${EUID}" -ne 0 ]]; then
echo "Re-run as root to write certs owned by container:"
echo " sudo ${SCRIPT_DIR}/regenerate-admin-cert.sh"
exit 1
fi
for f in dummycert.pem dummykey.pem; do
if [[ -f "${TLS_DIR}/${f}" ]]; then
cp -a "${TLS_DIR}/${f}" "${TLS_DIR}/${f}.bak.${TS}"
echo "Backed up ${f} -> ${f}.bak.${TS}"
fi
done
TMP="$(mktemp -d)"
trap 'rm -rf "${TMP}"' EXIT
openssl req -x509 -nodes -days 3650 -newkey rsa:4096 \
-keyout "${TMP}/dummykey.pem" \
-out "${TMP}/dummycert.pem" \
-subj "/CN=${HOST_SHORT}" \
-addext "subjectAltName=${SAN}"
install -m 600 -o root -g root "${TMP}/dummykey.pem" "${TLS_DIR}/dummykey.pem"
install -m 644 -o root -g root "${TMP}/dummycert.pem" "${TLS_DIR}/dummycert.pem"
echo ""
echo "Installed new cert. Verifying SAN:"
openssl x509 -in "${TLS_DIR}/dummycert.pem" -noout -text | grep -A1 'Subject Alternative Name' || true
if docker ps --format '{{.Names}}' | grep -qx npmplus; then
echo ""
echo "Restarting npmplus..."
docker compose --profile npmplus restart npmplus
else
echo ""
echo "Container npmplus not running — start with: ./scripts/start.sh"
fi
echo ""
echo "Admin UI: https://${LAN_IP}:81"
echo "Use HTTPS (not http). Accept the self-signed cert warning once in the browser."
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
# shellcheck disable=SC1091
source "${SCRIPT_DIR}/ensure-dirs.sh"
cd "${STACK_DIR}"
if [[ ! -f .env ]]; then
echo "ERROR: .env not found. Run: cp .env.example .env"
exit 1
fi
set -a
# shellcheck disable=SC1091
source .env
set +a
DATA_ROOT="${DATA_ROOT:-/data}"
if [[ -z "${ACME_EMAIL:-}" || "${ACME_EMAIL}" == "admin@example.com" ]]; then
echo "ERROR: Set ACME_EMAIL in .env (required for Let's Encrypt)"
exit 1
fi
if ! mountpoint -q "${DATA_ROOT}" 2>/dev/null; then
echo "ERROR: ${DATA_ROOT} is not mounted"
exit 1
fi
ensure_npmplus_dirs "${DATA_ROOT}"
if ! docker info &>/dev/null; then
echo "ERROR: Docker is not running"
exit 1
fi
if ss -tlnp 2>/dev/null | grep -qE ':80 |:443 '; then
echo "WARNING: Port 80 or 443 already in use — NPMPlus needs both for Let's Encrypt"
ss -tlnp 2>/dev/null | grep -E ':80 |:443 ' || true
fi
echo "=== NPMPlus stack ==="
echo "Image: ${NPMPLUS_IMAGE:-docker.io/zoeyvid/npmplus:latest}"
echo "Data: ${DATA_ROOT}/apps/npmplus"
echo "Ports: 80, 443 (tcp+udp), 81 (admin HTTPS)"
echo "ACME: ${ACME_EMAIL}"
echo ""
docker compose --profile npmplus pull
docker compose --profile npmplus up -d
echo ""
echo "Started. Admin UI (HTTPS only — http:// redirects and may fail in browser):"
echo " https://$(hostname -I 2>/dev/null | awk '{print $1}'):81"
echo ""
echo "If the browser blocks the cert when opening by IP, run:"
echo " sudo ./scripts/regenerate-admin-cert.sh"
echo ""
echo "First login — check logs for random password if INITIAL_ADMIN_PASSWORD unset:"
echo " docker compose --profile npmplus logs npmplus | grep -i password"
echo ""
echo "Prerequisites before proxy host + SSL:"
echo " DNS A record → this host's public IP"
echo " Router port forward: 80/tcp, 443/tcp, 443/udp → this host"
+2
View File
@@ -0,0 +1,2 @@
# DEPRECATED — use stacks/control-plane/.env.example
# Copy: cp ../control-plane/.env.example ../control-plane/.env
+1
View File
@@ -0,0 +1 @@
.env
+29
View File
@@ -0,0 +1,29 @@
FROM python:3.12-slim-bookworm
RUN apt-get update -qq \
&& apt-get install -y --no-install-recommends \
docker.io \
curl \
ca-certificates \
&& mkdir -p /usr/local/lib/docker/cli-plugins \
&& curl -fsSL "https://github.com/docker/compose/releases/download/v2.32.4/docker-compose-linux-$(uname -m)" \
-o /usr/local/lib/docker/cli-plugins/docker-compose \
&& chmod +x /usr/local/lib/docker/cli-plugins/docker-compose \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY server-ui/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY control-plane/env_loader.py ./env_loader.py
COPY server-ui/app.py server-ui/compose_runner.py server-ui/gpu_info.py server-ui/gpu_fan_proxy.py server-ui/file_explorer.py server-ui/stacks.yaml ./
COPY server-ui/static ./static
ENV SERVER_UI_HOST=0.0.0.0
ENV SERVER_UI_PORT=8091
ENV REPO_ROOT=/repo
EXPOSE 8091
CMD ["python", "app.py"]
+111
View File
@@ -0,0 +1,111 @@
# Server UI — Docker stack manager
Własny panel do zarządzania znanymi stackami compose na **gmktec-k11**. Zastępuje Dockge i Portainer. Zawiera **przeglądarkę plików**, panel **GPU Fan** (proxy do agenta na hoście) i zarządzanie stackami.
## Dlaczego nie ma Server UI w `docker ps`?
`docker ps` pokazuje tylko **kontenery Docker**. Domyślna instalacja Server UI to **systemd na hoście** (`server-ui.service` na porcie **8091**) — nie pojawi się na liście kontenerów. To normalne.
| Usługa | Runtime | W `docker ps` |
|--------|---------|---------------|
| ComfyUI, LocalAI, vLLM, NPMPlus | Docker compose | tak |
| **Server UI** (native) | systemd → `/opt/server-ui` | **nie** |
| **gpu-fan** agent | systemd → `/opt/gpu-fan` | **nie** |
Sprawdzenie: `systemctl status server-ui` lub `docker compose --profile server-ui ps` (tryb Docker).
## Port
| Serwis | Port | URL |
|--------|------|-----|
| Server UI | **8091** | `http://HOST:8091` |
| CLI (zakładka) | — | ten sam URL, zakładka **CLI** lub `#cli` |
| Pliki (zakładka) | — | ten sam URL, zakładka **Pliki** lub `#files` |
| GPU Fan (zakładka) | — | ten sam URL, zakładka **GPU Fan** |
| gpu-fan agent API | **18090** | tylko localhost (proxy z Server UI) |
## Instalacja (zalecana — jeden skrypt)
```bash
cd ~/cursor/ubuntu-bare-metal/stacks/server-ui
sudo ./scripts/install-control-plane.sh
```
Menu:
1. **gpu-fan** — tylko native (NVML na hoście; Docker nieobsługiwany)
2. **Server UI** — native (systemd) lub Docker (kontener)
Flagi nieinteraktywne:
```bash
sudo ./scripts/install-control-plane.sh -y
sudo ./scripts/install-control-plane.sh --gpu-fan=yes --server-ui=docker
```
### Tylko Server UI
| Tryb | Komenda |
|------|---------|
| Native (systemd) | `sudo ./scripts/install.sh` |
| Docker | `sudo ./scripts/install-docker.sh` |
Klucze API po instalacji:
```bash
bash stacks/server-ui/scripts/show-api-key.sh
```
Tutorial klucza: [`../../manual-tutorial/04a-api-key.md`](../../manual-tutorial/04a-api-key.md)
Dev: `stacks/control-plane/.env` (szablon: `stacks/control-plane/.env.example`)
Tutorial: [`../../manual-tutorial/08-server-ui-install.md`](../../manual-tutorial/08-server-ui-install.md) · CLI: [`10-server-ui-cli.md`](../../manual-tutorial/10-server-ui-cli.md) · Pliki: [`09-file-explorer.md`](../../manual-tutorial/09-file-explorer.md)
## Stacki (whitelist)
| Stack | Profil | Port UI | GPU |
|-------|--------|---------|-----|
| LocalAI | `localai` | 8070 | tak |
| ComfyUI | `comfyui` | 8188 | tak |
| vLLM | `vllm` | 8000 | tak |
| NPMPlus | `npmplus` | 81 (HTTPS) | nie |
Porty edytowalne w UI (zapis do `stacks/<name>/.env`). Szczegóły: [`../../coding-agent/SERVER-UI-PORT-CONFIG.md`](../../coding-agent/SERVER-UI-PORT-CONFIG.md)
## Bezpieczeństwo
- Odczyt statusu stacków/logów/GPU — bez klucza (LAN).
- **Start / Stop / Restart** stacków, **GPU Fan**, **Pliki** (CRUD) i **CLI** — nagłówek `X-API-Key` (CLI: klucz w query WebSocket `?api_key=`).
- Przy `SERVER_UI_HOST=0.0.0.0` klucz jest **wymagany** przy starcie usługi.
## Tryb dev (z repo)
```bash
cp ../control-plane/.env.example ../control-plane/.env
./scripts/start.sh
```
## Struktura
```
stacks/server-ui/
├── app.py
├── Dockerfile
├── docker-compose.yml
├── compose_runner.py
├── stacks.yaml
├── server-ui.service
├── static/index.html
├── static/vendor/xterm/ # xterm.js dla zakładki CLI
├── cli_pty.py
└── scripts/
├── install-control-plane.sh # gpu-fan + server-ui (menu)
├── install.sh # native only
├── install-docker.sh # docker only
└── start.sh
```
## Dokumentacja
- [`../../manual-tutorial/08-server-ui-install.md`](../../manual-tutorial/08-server-ui-install.md)
- [`../../coding-agent/SERVER-UI-INSTALL-OPTIONS.md`](../../coding-agent/SERVER-UI-INSTALL-OPTIONS.md)
- [`../../coding-agent/DOCKER-UI-DEPLOYMENT.md`](../../coding-agent/DOCKER-UI-DEPLOYMENT.md)
- [`../../coding-agent/ADR-001-host-agent-control-plane.md`](../../coding-agent/ADR-001-host-agent-control-plane.md)
+453
View File
@@ -0,0 +1,453 @@
#!/usr/bin/env python3
"""GMKtec K11 — Docker stack management UI."""
from __future__ import annotations
import logging
import os
import sys
from pathlib import Path
from typing import Any
import uvicorn
from fastapi import Depends, FastAPI, HTTPException, Query, Request, WebSocket
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from compose_runner import (
ComposeError,
PortConfigError,
StackConfig,
find_running_gpu_stacks,
get_container_state,
get_logs,
load_stacks,
set_stack_port,
stack_restart,
stack_start,
stack_stop,
)
from cli_pty import CliSessionLimitError, run_pty_session, sessions_available
from file_explorer import FileExplorer, FileExplorerError
from gpu_info import GpuInfoError, query_gpu
from gpu_fan_proxy import AgentProxyError, check_agent_health, forward_request, resolve_agent_url
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 api_key_source, load_control_plane_env # noqa: E402
STATIC_DIR = STACK_DIR / "static"
CONFIG_PATH = STACK_DIR / "stacks.yaml"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
log = logging.getLogger(__name__)
ENV = load_control_plane_env(STACK_DIR)
HOST = ENV.get("SERVER_UI_HOST", "0.0.0.0")
PORT = int(ENV.get("SERVER_UI_PORT", "8091"))
API_KEY = ENV.get("API_KEY", "")
GPU_FAN_AGENT_URL = resolve_agent_url(
ENV.get("GPU_FAN_AGENT_URL", "http://127.0.0.1:18090"),
API_KEY,
)
REPO_ROOT = Path(
ENV.get("REPO_ROOT", str(STACK_DIR.parent.parent))
).resolve()
STACKS_BASE = REPO_ROOT / "stacks"
STACKS: list[StackConfig] = load_stacks(CONFIG_PATH, STACKS_BASE)
STACK_BY_ID: dict[str, StackConfig] = {s.id: s for s in STACKS}
FILE_EXPLORER_ROOT = ENV.get("FILE_EXPLORER_ROOT", "/")
FILE_EXPLORER_MAX_BYTES = int(ENV.get("FILE_EXPLORER_MAX_BYTES", "2097152"))
file_explorer = FileExplorer(root=FILE_EXPLORER_ROOT, max_bytes=FILE_EXPLORER_MAX_BYTES)
CLI_ENABLED = ENV.get("CLI_ENABLED", "1").strip().lower() in ("1", "true", "yes")
CLI_SHELL = ENV.get("CLI_SHELL", "/bin/bash")
CLI_DEFAULT_CWD = ENV.get("CLI_DEFAULT_CWD", os.path.expanduser("~"))
CLI_MAX_SESSIONS = int(ENV.get("CLI_MAX_SESSIONS", "5"))
app = FastAPI(title="Server UI", version="1.0.0")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
class PortUpdate(BaseModel):
port: int = Field(ge=1024, le=65535)
recreate: bool = True
class FileWriteBody(BaseModel):
path: str
content: str = ""
class FilePathBody(BaseModel):
path: str
class FileRenameBody(BaseModel):
old_path: str
new_path: str
class AuthVerifyBody(BaseModel):
api_key: str = ""
def get_stack(stack_id: str) -> StackConfig:
stack = STACK_BY_ID.get(stack_id)
if not stack:
raise HTTPException(status_code=404, detail=f"Unknown stack: {stack_id}")
return stack
def require_mutation_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")
def _is_lan_bind() -> bool:
return HOST.strip().lower() not in ("127.0.0.1", "localhost", "::1")
def require_gpu_fan_read_auth(request: Request) -> None:
if not _is_lan_bind() or not API_KEY:
return
require_mutation_auth(request)
def require_file_auth(request: Request) -> None:
if not _is_lan_bind() or not API_KEY:
return
require_mutation_auth(request)
def _cli_ws_auth_ok(websocket: WebSocket) -> bool:
if not _is_lan_bind() or not API_KEY:
return True
return websocket.query_params.get("api_key", "") == API_KEY
def _file_exc_to_http(exc: FileExplorerError) -> HTTPException:
msg = str(exc)
if "za duż" in msg.lower() or "za duża" in msg.lower():
return HTTPException(status_code=413, detail=msg)
if "poza dozwolonym" in msg.lower():
return HTTPException(status_code=403, detail=msg)
if "Brak uprawnień" in msg:
return HTTPException(status_code=403, detail=msg)
if "nie jest pusty" in msg.lower() or "już istnieje" in msg.lower():
return HTTPException(status_code=409, detail=msg)
if "Nie znaleziono" in msg:
return HTTPException(status_code=404, detail=msg)
if "nie jest katalogiem" in msg.lower() or "To jest katalog" in msg:
return HTTPException(status_code=400, detail=msg)
return HTTPException(status_code=400, detail=msg)
def check_gpu_policy(stack: StackConfig) -> None:
if not stack.gpu:
return
conflicts = find_running_gpu_stacks(STACKS, exclude_id=stack.id)
if conflicts:
names = ", ".join(conflicts)
raise HTTPException(
status_code=409,
detail=f"GPU conflict: stop running GPU stack(s) first: {names}",
)
@app.get("/")
def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
@app.get("/api/health")
def api_health() -> dict[str, bool]:
return {"ok": True}
@app.post("/api/auth/verify")
def api_auth_verify(body: AuthVerifyBody) -> dict[str, bool]:
if not API_KEY:
return {"ok": True}
if body.api_key == API_KEY:
return {"ok": True}
raise HTTPException(status_code=401, detail="Invalid or missing API key")
@app.get("/api/gpu")
def api_gpu() -> dict[str, Any]:
try:
return query_gpu()
except GpuInfoError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@app.get("/api/stacks")
def api_stacks() -> dict[str, Any]:
items = [get_container_state(s) for s in STACKS]
gpu_running = [i["id"] for i in items if i["gpu"] and i["running"]]
return {
"stacks": items,
"gpu_running": gpu_running,
"repo_root": str(REPO_ROOT),
}
@app.get("/api/stacks/{stack_id}/logs")
def api_logs(stack_id: str, tail: int = Query(default=100, ge=1, le=500)) -> dict[str, str]:
stack = get_stack(stack_id)
try:
text = get_logs(stack, tail=tail)
except ComposeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"stack_id": stack_id, "logs": text}
@app.post("/api/stacks/{stack_id}/start")
def api_start(stack_id: str, _: None = Depends(require_mutation_auth)) -> dict[str, Any]:
stack = get_stack(stack_id)
check_gpu_policy(stack)
try:
output = stack_start(stack)
except ComposeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"ok": True, "action": "start", "stack_id": stack_id, "output": output}
@app.post("/api/stacks/{stack_id}/stop")
def api_stop(stack_id: str, _: None = Depends(require_mutation_auth)) -> dict[str, Any]:
stack = get_stack(stack_id)
try:
output = stack_stop(stack)
except ComposeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"ok": True, "action": "stop", "stack_id": stack_id, "output": output}
@app.post("/api/stacks/{stack_id}/restart")
def api_restart(stack_id: str, _: None = Depends(require_mutation_auth)) -> dict[str, Any]:
stack = get_stack(stack_id)
if stack.gpu:
conflicts = find_running_gpu_stacks(STACKS, exclude_id=stack.id)
if conflicts:
check_gpu_policy(stack)
try:
output = stack_restart(stack)
except ComposeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"ok": True, "action": "restart", "stack_id": stack_id, "output": output}
@app.patch("/api/stacks/{stack_id}/port")
def api_set_port(
stack_id: str,
body: PortUpdate,
_: None = Depends(require_mutation_auth),
) -> dict[str, Any]:
stack = get_stack(stack_id)
if not stack.port_editable or not stack.port_env:
raise HTTPException(
status_code=400,
detail=f"Port is not editable for stack: {stack_id}",
)
try:
return set_stack_port(
stack,
body.port,
STACKS,
PORT,
recreate=body.recreate,
)
except PortConfigError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ComposeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.get("/api/gpu-fan/health")
def api_gpu_fan_health(_: None = Depends(require_gpu_fan_read_auth)) -> dict[str, Any]:
return check_agent_health(GPU_FAN_AGENT_URL, API_KEY)
@app.api_route("/api/gpu-fan/{path:path}", methods=["GET", "PUT", "POST"])
async def api_gpu_fan_proxy(
path: str,
request: Request,
_: None = Depends(require_gpu_fan_read_auth),
) -> Response:
if request.method in ("PUT", "POST"):
require_mutation_auth(request)
body = await request.body()
try:
status, resp_headers, payload = forward_request(
GPU_FAN_AGENT_URL,
request.method,
path,
body=body if body else None,
agent_api_key=API_KEY,
content_type=request.headers.get("content-type", "application/json"),
)
except AgentProxyError as exc:
raise HTTPException(
status_code=502,
detail=f"GPU fan agent unavailable at {GPU_FAN_AGENT_URL}: {exc}",
) from exc
media_type = resp_headers.get("Content-Type", "application/json")
return Response(content=payload, status_code=status, media_type=media_type)
@app.get("/api/files")
def api_files_list(
path: str = Query(default="/"),
_: None = Depends(require_file_auth),
) -> dict[str, Any]:
try:
return file_explorer.list_directory(path)
except FileExplorerError as exc:
raise _file_exc_to_http(exc) from exc
@app.get("/api/files/read")
def api_files_read(
path: str = Query(...),
_: None = Depends(require_file_auth),
) -> dict[str, Any]:
try:
return file_explorer.read_file(path)
except FileExplorerError as exc:
raise _file_exc_to_http(exc) from exc
@app.put("/api/files/write")
def api_files_write(
body: FileWriteBody,
_: None = Depends(require_file_auth),
) -> dict[str, Any]:
target = file_explorer.resolve_path(body.path)
if target.exists() and target.is_file():
try:
info = file_explorer.read_file(body.path)
if info.get("binary"):
raise HTTPException(
status_code=415,
detail="Nie można zapisać pliku binarnego jako tekst",
)
except FileExplorerError as exc:
raise _file_exc_to_http(exc) from exc
try:
return file_explorer.write_file(body.path, body.content)
except FileExplorerError as exc:
raise _file_exc_to_http(exc) from exc
@app.post("/api/files/mkdir")
def api_files_mkdir(
body: FilePathBody,
_: None = Depends(require_file_auth),
) -> dict[str, Any]:
try:
return file_explorer.mkdir(body.path)
except FileExplorerError as exc:
raise _file_exc_to_http(exc) from exc
@app.post("/api/files/rename")
def api_files_rename(
body: FileRenameBody,
_: None = Depends(require_file_auth),
) -> dict[str, Any]:
try:
return file_explorer.rename_path(body.old_path, body.new_path)
except FileExplorerError as exc:
raise _file_exc_to_http(exc) from exc
@app.delete("/api/files")
def api_files_delete(
path: str = Query(...),
_: None = Depends(require_file_auth),
) -> dict[str, Any]:
try:
return file_explorer.delete_path(path)
except FileExplorerError as exc:
raise _file_exc_to_http(exc) from exc
@app.websocket("/api/cli/ws")
async def api_cli_ws(websocket: WebSocket) -> None:
if not _cli_ws_auth_ok(websocket):
await websocket.close(code=1008, reason="Invalid or missing API key")
return
if not CLI_ENABLED:
await websocket.close(code=1008, reason="CLI disabled")
return
if not sessions_available(CLI_MAX_SESSIONS):
await websocket.close(
code=1008,
reason=f"Limit sesji CLI ({CLI_MAX_SESSIONS}) — spróbuj później",
)
return
await websocket.accept()
try:
await run_pty_session(
websocket,
shell=CLI_SHELL,
cwd=CLI_DEFAULT_CWD,
env=os.environ.copy(),
max_sessions=CLI_MAX_SESSIONS,
)
except CliSessionLimitError:
await websocket.close(code=1008, reason="Session limit")
def main() -> None:
if HOST.strip() not in ("127.0.0.1", "localhost", "::1") and not API_KEY:
log.error(
"API_KEY is required when SERVER_UI_HOST=%s (LAN bind). Set API_KEY in .env",
HOST,
)
sys.exit(1)
if not STACKS:
log.error("No stacks loaded from %s", CONFIG_PATH)
sys.exit(1)
log.info("Repo root: %s", REPO_ROOT)
log.info("Stacks: %s", ", ".join(s.id for s in STACKS))
log.info("GPU fan agent: %s", GPU_FAN_AGENT_URL)
if API_KEY:
src = api_key_source(STACK_DIR, ENV)
log.info("API key: configured (source: %s)", src)
else:
log.warning("API_KEY not set (proxy may fail agent auth on LAN)")
if CLI_ENABLED:
log.info("CLI: enabled (shell=%s, cwd=%s, max_sessions=%d)", CLI_SHELL, CLI_DEFAULT_CWD, CLI_MAX_SESSIONS)
else:
log.info("CLI: disabled (CLI_ENABLED=0)")
log.info("Web UI at http://%s:%d", HOST if HOST != "0.0.0.0" else "0.0.0.0", PORT)
uvicorn.run(app, host=HOST, port=PORT, log_level="info")
if __name__ == "__main__":
main()
+174
View File
@@ -0,0 +1,174 @@
"""PTY shell sessions for browser CLI (WebSocket bridge)."""
from __future__ import annotations
import asyncio
import fcntl
import json
import logging
import os
import pty
import shlex
import signal
import struct
import termios
from typing import Any
from starlette.websockets import WebSocket, WebSocketDisconnect
log = logging.getLogger(__name__)
TIOCSWINSZ = termios.TIOCSWINSZ
_active_sessions = 0
_session_lock = asyncio.Lock()
class CliSessionLimitError(Exception):
pass
def _set_winsize(fd: int, rows: int, cols: int) -> None:
winsize = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(fd, TIOCSWINSZ, winsize)
def _parse_shell(shell: str) -> list[str]:
parts = shlex.split(shell.strip())
return parts if parts else ["/bin/bash"]
async def _read_pty_loop(
master_fd: int,
websocket: WebSocket,
running: asyncio.Event,
) -> None:
loop = asyncio.get_running_loop()
while running.is_set():
try:
data = await loop.run_in_executor(None, os.read, master_fd, 4096)
except OSError:
break
if not data:
break
try:
await websocket.send_bytes(data)
except Exception:
break
async def _kill_process(process: asyncio.subprocess.Process) -> None:
if process.returncode is not None:
return
try:
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
return
try:
await asyncio.wait_for(process.wait(), timeout=2.0)
except asyncio.TimeoutError:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
await process.wait()
def sessions_available(max_sessions: int) -> bool:
return _active_sessions < max_sessions
async def run_pty_session(
websocket: WebSocket,
*,
shell: str,
cwd: str,
env: dict[str, str],
max_sessions: int,
) -> None:
global _active_sessions
async with _session_lock:
if _active_sessions >= max_sessions:
raise CliSessionLimitError(
f"Limit sesji CLI ({max_sessions}) — spróbuj później"
)
_active_sessions += 1
master_fd: int | None = None
process: asyncio.subprocess.Process | None = None
read_task: asyncio.Task[None] | None = None
running = asyncio.Event()
running.set()
try:
master_fd, slave_fd = pty.openpty()
shell_cmd = _parse_shell(shell)
proc_env = {**env, "TERM": "xterm-256color"}
process = await asyncio.create_subprocess_exec(
*shell_cmd,
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
cwd=cwd,
env=proc_env,
preexec_fn=os.setsid,
)
os.close(slave_fd)
slave_fd = -1
read_task = asyncio.create_task(_read_pty_loop(master_fd, websocket, running))
while True:
message = await websocket.receive()
msg_type = message.get("type")
if msg_type == "websocket.disconnect":
break
payload: bytes | None = None
if message.get("bytes"):
payload = message["bytes"]
elif message.get("text"):
text = message["text"]
if text.startswith("{"):
try:
obj: dict[str, Any] = json.loads(text)
if obj.get("type") == "resize":
rows = int(obj.get("rows", 24))
cols = int(obj.get("cols", 80))
if master_fd is not None:
_set_winsize(master_fd, rows, cols)
continue
except (json.JSONDecodeError, TypeError, ValueError):
pass
payload = text.encode("utf-8")
if payload and master_fd is not None:
try:
os.write(master_fd, payload)
except OSError:
break
if process.returncode is not None:
break
except WebSocketDisconnect:
pass
finally:
running.clear()
if read_task is not None:
read_task.cancel()
try:
await read_task
except asyncio.CancelledError:
pass
if process is not None:
await _kill_process(process)
if master_fd is not None:
try:
os.close(master_fd)
except OSError:
pass
async with _session_lock:
_active_sessions = max(0, _active_sessions - 1)
+433
View File
@@ -0,0 +1,433 @@
"""Docker Compose helpers for whitelisted stacks."""
from __future__ import annotations
import json
import os
import re
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
RESERVED_PORTS = frozenset({80, 443, 8090, 18090})
PORT_MIN = 1024
PORT_MAX = 65535
class ComposeError(RuntimeError):
def __init__(self, message: str, returncode: int = 1, stderr: str = "") -> None:
super().__init__(message)
self.returncode = returncode
self.stderr = stderr
class PortConfigError(ValueError):
pass
@dataclass(frozen=True)
class StackConfig:
id: str
name: str
compose_dir: str
profile: str
container: str
ui_port: int | None
ui_scheme: str
gpu: bool
port_env: str | None = None
port_default: int | None = None
port_editable: bool = True
@property
def path(self) -> Path:
return Path(self.compose_dir)
@property
def env_path(self) -> Path:
return self.path / ".env"
def read_env_value(env_path: Path, key: str) -> str | None:
if not env_path.exists():
return None
prefix = f"{key}="
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if line.startswith(prefix):
val = line[len(prefix) :].strip()
if val.startswith('"') and val.endswith('"') and len(val) >= 2:
val = val[1:-1]
elif val.startswith("'") and val.endswith("'") and len(val) >= 2:
val = val[1:-1]
return val
return None
def write_env_value(env_path: Path, key: str, value: str) -> None:
env_path.parent.mkdir(parents=True, exist_ok=True)
prefix = f"{key}="
new_line = f"{key}={value}"
lines: list[str] = []
found = False
if env_path.exists():
for raw in env_path.read_text(encoding="utf-8").splitlines():
stripped = raw.strip()
if stripped and not stripped.startswith("#") and stripped.startswith(prefix):
lines.append(new_line)
found = True
else:
lines.append(raw)
if not found:
if lines and lines[-1].strip():
lines.append("")
lines.append(new_line)
fd, tmp_path = tempfile.mkstemp(
dir=env_path.parent,
prefix=".env.",
suffix=".tmp",
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write("\n".join(lines))
if lines:
fh.write("\n")
os.replace(tmp_path, env_path)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
def resolve_stack_port(stack: StackConfig) -> int | None:
if stack.port_env:
raw = read_env_value(stack.env_path, stack.port_env)
if raw and raw.isdigit():
return int(raw)
if stack.ui_port is not None:
return stack.ui_port
return stack.port_default
def collect_used_ports(
stacks: list[StackConfig],
server_ui_port: int,
exclude_stack_id: str | None = None,
) -> set[int]:
used = set(RESERVED_PORTS) | {server_ui_port}
for s in stacks:
if s.id == exclude_stack_id:
continue
port = resolve_stack_port(s)
if port is not None:
used.add(port)
return used
def validate_port(
port: int,
stack: StackConfig,
stacks: list[StackConfig],
server_ui_port: int,
) -> None:
if not PORT_MIN <= port <= PORT_MAX:
raise PortConfigError(f"Port must be between {PORT_MIN} and {PORT_MAX}")
if not stack.port_editable or not stack.port_env:
raise PortConfigError(f"Port is not editable for stack: {stack.id}")
conflicts = collect_used_ports(stacks, server_ui_port, exclude_stack_id=stack.id)
if port in conflicts:
raise PortConfigError(f"Port {port} is already in use or reserved")
def get_published_host_port(stack: StackConfig) -> int | None:
proc = subprocess.run(
[
"docker",
"port",
stack.container,
],
capture_output=True,
text=True,
timeout=15,
check=False,
)
if proc.returncode != 0 or not proc.stdout.strip():
return None
for line in proc.stdout.strip().splitlines():
# 0.0.0.0:8070/tcp or [::]:8070/tcp
m = re.search(r":(\d+)/", line)
if m:
return int(m.group(1))
return None
def load_stacks(config_path: Path, stacks_base: Path) -> list[StackConfig]:
import yaml # PyYAML optional — parse minimal yaml ourselves if missing
text = config_path.read_text(encoding="utf-8")
try:
data = yaml.safe_load(text)
except Exception:
data = _parse_stacks_yaml_simple(text)
items = data.get("stacks", []) if isinstance(data, dict) else []
stacks: list[StackConfig] = []
for item in items:
compose_rel = item["compose_dir"]
full = stacks_base / compose_rel
stacks.append(
StackConfig(
id=item["id"],
name=item["name"],
compose_dir=str(full),
profile=item["profile"],
container=item.get("container", item["id"]),
ui_port=item.get("ui_port"),
ui_scheme=item.get("ui_scheme") or "http",
gpu=bool(item.get("gpu", False)),
port_env=item.get("port_env"),
port_default=item.get("port_default"),
port_editable=bool(item.get("port_editable", item.get("port_env") is not None)),
)
)
return stacks
def _parse_stacks_yaml_simple(text: str) -> dict[str, Any]:
"""Minimal parser fallback when PyYAML is not installed."""
import re
stacks: list[dict[str, Any]] = []
current: dict[str, Any] | None = None
for line in text.splitlines():
if re.match(r"^\s*-\s+id:\s*", line):
if current:
stacks.append(current)
current = {"id": line.split(":", 1)[1].strip()}
elif current is not None and ":" in line:
key, _, val = line.strip().partition(":")
key = key.strip()
val = val.strip()
if val == "null":
val = None
elif val == "true":
val = True
elif val == "false":
val = False
elif val.isdigit():
val = int(val)
current[key] = val
if current:
stacks.append(current)
return {"stacks": stacks}
def _run_compose(
stack: StackConfig,
*args: str,
timeout: int = 120,
) -> subprocess.CompletedProcess[str]:
cmd = [
"docker",
"compose",
"--project-directory",
stack.compose_dir,
"--profile",
stack.profile,
*args,
]
return subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
def get_container_state(stack: StackConfig) -> dict[str, Any]:
proc = _run_compose(
stack,
"ps",
"--format",
"json",
timeout=30,
)
running = False
status = "stopped"
health = None
if proc.returncode == 0 and proc.stdout.strip():
for line in proc.stdout.strip().splitlines():
try:
row = json.loads(line)
except json.JSONDecodeError:
continue
name = row.get("Name", "")
if stack.container in name or name == stack.container:
state = row.get("State", "").lower()
health = row.get("Health")
if state == "running":
running = True
status = "running"
elif state:
status = state
break
else:
ps = subprocess.run(
[
"docker",
"ps",
"-a",
"--filter",
f"name=^{stack.container}$",
"--format",
"{{.State}}",
],
capture_output=True,
text=True,
timeout=15,
check=False,
)
if ps.stdout.strip():
status = ps.stdout.strip().lower()
running = status == "running"
ui_port = resolve_stack_port(stack)
published = get_published_host_port(stack) if running else None
port_pending_restart = bool(
running and ui_port is not None and published is not None and published != ui_port
)
return {
"id": stack.id,
"name": stack.name,
"profile": stack.profile,
"container": stack.container,
"ui_port": ui_port,
"ui_scheme": stack.ui_scheme,
"gpu": stack.gpu,
"running": running,
"status": status,
"health": health,
"compose_dir": stack.compose_dir,
"port_env": stack.port_env,
"port_editable": stack.port_editable and bool(stack.port_env),
"port_default": stack.port_default,
"published_port": published,
"port_pending_restart": port_pending_restart,
}
def get_logs(stack: StackConfig, tail: int = 100) -> str:
proc = _run_compose(
stack,
"logs",
"--tail",
str(tail),
"--no-color",
timeout=60,
)
if proc.returncode != 0:
raise ComposeError(
proc.stderr.strip() or "Failed to fetch logs",
proc.returncode,
proc.stderr,
)
return proc.stdout
def stack_start(stack: StackConfig) -> str:
proc = _run_compose(stack, "up", "-d", timeout=300)
if proc.returncode != 0:
raise ComposeError(
proc.stderr.strip() or proc.stdout.strip() or "start failed",
proc.returncode,
proc.stderr,
)
return (proc.stdout + proc.stderr).strip()
def stack_stop(stack: StackConfig) -> str:
proc = _run_compose(stack, "stop", timeout=120)
if proc.returncode != 0:
raise ComposeError(
proc.stderr.strip() or "stop failed",
proc.returncode,
proc.stderr,
)
return (proc.stdout + proc.stderr).strip()
def stack_restart(stack: StackConfig) -> str:
proc = _run_compose(stack, "restart", timeout=180)
if proc.returncode != 0:
raise ComposeError(
proc.stderr.strip() or "restart failed",
proc.returncode,
proc.stderr,
)
return (proc.stdout + proc.stderr).strip()
def stack_recreate(stack: StackConfig) -> str:
proc = _run_compose(stack, "up", "-d", "--force-recreate", timeout=300)
if proc.returncode != 0:
raise ComposeError(
proc.stderr.strip() or proc.stdout.strip() or "recreate failed",
proc.returncode,
proc.stderr,
)
return (proc.stdout + proc.stderr).strip()
def set_stack_port(
stack: StackConfig,
port: int,
stacks: list[StackConfig],
server_ui_port: int,
*,
recreate: bool = False,
) -> dict[str, Any]:
validate_port(port, stack, stacks, server_ui_port)
assert stack.port_env is not None
write_env_value(stack.env_path, stack.port_env, str(port))
state = get_container_state(stack)
recreated = False
requires_restart = state["running"]
if recreate and state["running"]:
stack_recreate(stack)
recreated = True
requires_restart = False
state = get_container_state(stack)
return {
"ok": True,
"stack_id": stack.id,
"port": port,
"port_env": stack.port_env,
"requires_restart": requires_restart and not recreated,
"recreated": recreated,
"running": state["running"],
}
def find_running_gpu_stacks(stacks: list[StackConfig], exclude_id: str | None = None) -> list[str]:
running: list[str] = []
for s in stacks:
if not s.gpu or s.id == exclude_id:
continue
state = get_container_state(s)
if state["running"]:
running.append(s.id)
return running
+28
View File
@@ -0,0 +1,28 @@
name: server-ui
services:
server-ui:
image: server-ui:local
build:
context: ..
dockerfile: server-ui/Dockerfile
container_name: server-ui
profiles:
- server-ui
restart: unless-stopped
ports:
- "${SERVER_UI_PORT:-8091}:8091"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${REPO_ROOT:?set REPO_ROOT in .env}:/repo:ro
env_file:
- ../control-plane/.env
environment:
REPO_ROOT: /repo
SERVER_UI_HOST: "0.0.0.0"
SERVER_UI_PORT: "8091"
GPU_FAN_AGENT_URL: ${GPU_FAN_AGENT_URL:-http://host.docker.internal:18090}
extra_hosts:
- "host.docker.internal:host-gateway"
group_add:
- ${DOCKER_GID:-999}
+248
View File
@@ -0,0 +1,248 @@
"""Server filesystem browser — list, read, write, CRUD."""
from __future__ import annotations
import base64
import os
import stat
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
class FileExplorerError(Exception):
"""Raised for filesystem operations; map to HTTP in app.py."""
def _posix_error(exc: OSError, action: str) -> FileExplorerError:
if exc.errno in (13, 1):
return FileExplorerError(f"Brak uprawnień: {action}")
if exc.errno == 2:
return FileExplorerError("Nie znaleziono pliku lub katalogu")
if exc.errno == 17:
return FileExplorerError("Plik lub katalog już istnieje")
if exc.errno == 39:
return FileExplorerError("Katalog nie jest pusty")
if exc.errno == 20:
return FileExplorerError("To nie jest katalog")
return FileExplorerError(f"{action}: {exc.strerror or exc}")
class FileExplorer:
def __init__(self, root: str = "/", max_bytes: int = 2_097_152) -> None:
self.root = Path(root).resolve()
self.max_bytes = max_bytes
def resolve_path(self, raw: str) -> Path:
raw = (raw or "/").strip()
if not raw:
raw = "/"
if raw.startswith("/"):
target = Path(raw).resolve()
else:
target = (self.root / raw).resolve()
if self.root == Path("/"):
return target
root_s = str(self.root)
target_s = str(target)
if target != self.root and not target_s.startswith(root_s + os.sep):
raise FileExplorerError(f"Ścieżka poza dozwolonym katalogiem: {self.root}")
return target
def _access_flags(self, path: Path) -> tuple[bool, bool]:
readable = os.access(path, os.R_OK)
writable = os.access(path, os.W_OK)
return readable, writable
def list_directory(self, raw_path: str) -> dict[str, Any]:
path = self.resolve_path(raw_path)
if not path.exists():
raise FileExplorerError("Nie znaleziono pliku lub katalogu")
if not path.is_dir():
raise FileExplorerError("To nie jest katalog")
readable, writable = self._access_flags(path)
if not readable:
raise FileExplorerError("Brak uprawnień: odczyt katalogu")
entries: list[dict[str, Any]] = []
try:
names = sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
except OSError as exc:
raise _posix_error(exc, "odczyt katalogu") from exc
for child in names:
try:
st = child.stat()
except OSError:
continue
is_dir = stat.S_ISDIR(st.st_mode)
c_readable, c_writable = self._access_flags(child)
entries.append(
{
"name": child.name,
"path": str(child),
"is_dir": is_dir,
"size": st.st_size if not is_dir else None,
"mtime": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
"readable": c_readable,
"writable": c_writable,
}
)
parent = str(path.parent) if path != self.root else None
return {
"path": str(path),
"parent": parent,
"readable": readable,
"writable": writable,
"entries": entries,
}
def read_file(self, raw_path: str) -> dict[str, Any]:
path = self.resolve_path(raw_path)
if not path.exists():
raise FileExplorerError("Nie znaleziono pliku lub katalogu")
if path.is_dir():
raise FileExplorerError("To jest katalog, nie plik")
readable, writable = self._access_flags(path)
if not readable:
raise FileExplorerError("Brak uprawnień: odczyt pliku")
try:
size = path.stat().st_size
except OSError as exc:
raise _posix_error(exc, "odczyt pliku") from exc
if size > self.max_bytes:
raise FileExplorerError(
f"Plik za duży ({size} B, limit {self.max_bytes} B)"
)
try:
data = path.read_bytes()
except OSError as exc:
raise _posix_error(exc, "odczyt pliku") from exc
if b"\x00" in data:
return {
"path": str(path),
"binary": True,
"size": size,
"content_base64": base64.b64encode(data).decode("ascii"),
"readable": readable,
"writable": writable,
}
try:
content = data.decode("utf-8")
except UnicodeDecodeError:
return {
"path": str(path),
"binary": True,
"size": size,
"content_base64": base64.b64encode(data).decode("ascii"),
"readable": readable,
"writable": writable,
}
return {
"path": str(path),
"binary": False,
"size": size,
"content": content,
"readable": readable,
"writable": writable,
}
def write_file(self, raw_path: str, content: str) -> dict[str, Any]:
path = self.resolve_path(raw_path)
encoded = content.encode("utf-8")
if len(encoded) > self.max_bytes:
raise FileExplorerError(
f"Zawartość za duża ({len(encoded)} B, limit {self.max_bytes} B)"
)
parent = path.parent
if not parent.exists():
raise FileExplorerError("Katalog nadrzędny nie istnieje")
if not os.access(parent, os.W_OK):
raise FileExplorerError("Brak uprawnień: zapis pliku")
if path.exists() and path.is_dir():
raise FileExplorerError("To jest katalog, nie plik")
if path.exists() and not os.access(path, os.W_OK):
raise FileExplorerError("Brak uprawnień: zapis pliku")
try:
path.write_text(content, encoding="utf-8")
except OSError as exc:
raise _posix_error(exc, "zapis pliku") from exc
return {"ok": True, "path": str(path), "size": len(encoded)}
def mkdir(self, raw_path: str) -> dict[str, Any]:
path = self.resolve_path(raw_path)
parent = path.parent
if not parent.exists():
raise FileExplorerError("Katalog nadrzędny nie istnieje")
if not os.access(parent, os.W_OK):
raise FileExplorerError("Brak uprawnień: tworzenie katalogu")
if path.exists():
raise FileExplorerError("Plik lub katalog już istnieje")
try:
path.mkdir(parents=False, exist_ok=False)
except OSError as exc:
raise _posix_error(exc, "tworzenie katalogu") from exc
return {"ok": True, "path": str(path)}
def delete_path(self, raw_path: str) -> dict[str, Any]:
path = self.resolve_path(raw_path)
if path == self.root:
raise FileExplorerError("Nie można usunąć katalogu głównego")
if not path.exists():
raise FileExplorerError("Nie znaleziono pliku lub katalogu")
if not os.access(path, os.W_OK):
raise FileExplorerError("Brak uprawnień: usuwanie")
if path.is_dir():
try:
if any(path.iterdir()):
raise FileExplorerError("Katalog nie jest pusty")
except OSError as exc:
raise _posix_error(exc, "odczyt katalogu") from exc
try:
if path.is_dir():
path.rmdir()
else:
path.unlink()
except OSError as exc:
raise _posix_error(exc, "usuwanie") from exc
return {"ok": True, "path": str(path)}
def rename_path(self, raw_from: str, raw_to: str) -> dict[str, Any]:
src = self.resolve_path(raw_from)
dst = self.resolve_path(raw_to)
if not src.exists():
raise FileExplorerError("Nie znaleziono pliku lub katalogu")
if dst.exists():
raise FileExplorerError("Docelowa ścieżka już istnieje")
if not os.access(src, os.W_OK):
raise FileExplorerError("Brak uprawnień: zmiana nazwy")
parent = dst.parent
if not parent.exists() or not os.access(parent, os.W_OK):
raise FileExplorerError("Brak uprawnień: zapis w katalogu docelowym")
try:
src.rename(dst)
except OSError as exc:
raise _posix_error(exc, "zmiana nazwy") from exc
return {"ok": True, "from": str(src), "to": str(dst)}
+75
View File
@@ -0,0 +1,75 @@
"""Proxy requests to the gpu-fan host agent."""
from __future__ import annotations
import json
import logging
import urllib.error
import urllib.request
from typing import Any
log = logging.getLogger(__name__)
class AgentProxyError(Exception):
"""Raised when the gpu-fan agent request fails."""
def check_agent_health(agent_url: str, agent_api_key: str) -> dict[str, Any]:
"""Return health info for the gpu-fan agent."""
try:
forward_request(agent_url, "GET", "status", agent_api_key=agent_api_key)
return {"ok": True, "agent_url": agent_url}
except AgentProxyError as exc:
return {"ok": False, "agent_url": agent_url, "error": str(exc)}
def resolve_agent_url(configured_url: str, agent_api_key: str) -> str:
"""Pick first reachable agent URL (18090 preferred, 8090 legacy fallback)."""
candidates: list[str] = []
if configured_url:
candidates.append(configured_url.rstrip("/"))
for url in ("http://127.0.0.1:18090", "http://127.0.0.1:8090"):
if url not in candidates:
candidates.append(url)
for url in candidates:
if check_agent_health(url, agent_api_key).get("ok"):
log.info("GPU fan agent reachable at %s", url)
return url
return candidates[0] if candidates else "http://127.0.0.1:18090"
def forward_request(
agent_url: str,
method: str,
path: str,
*,
body: bytes | None = None,
agent_api_key: str,
content_type: str = "application/json",
) -> tuple[int, dict[str, str], bytes]:
"""Forward a request to the gpu-fan agent API."""
path = path.lstrip("/")
url = f"{agent_url.rstrip('/')}/api/{path}"
headers = {"Accept": "application/json"}
if agent_api_key:
headers["X-API-Key"] = agent_api_key
if body is not None:
headers["Content-Type"] = content_type
req = urllib.request.Request(url, data=body, headers=headers, method=method.upper())
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.status, dict(resp.headers), resp.read()
except urllib.error.HTTPError as exc:
payload = exc.read()
detail = payload.decode("utf-8", errors="replace")
try:
parsed = json.loads(detail)
if isinstance(parsed, dict) and "detail" in parsed:
detail = str(parsed["detail"])
except json.JSONDecodeError:
pass
raise AgentProxyError(detail or f"Agent HTTP {exc.code}") from exc
except urllib.error.URLError as exc:
raise AgentProxyError(f"Agent unreachable at {agent_url}: {exc.reason}") from exc
+69
View File
@@ -0,0 +1,69 @@
"""Query GPU stats via nvidia-smi."""
from __future__ import annotations
import json
import shutil
import subprocess
from typing import Any
class GpuInfoError(RuntimeError):
pass
def query_gpu() -> dict[str, Any]:
if not shutil.which("nvidia-smi"):
return {
"available": False,
"error": "nvidia-smi not found",
"gpus": [],
}
try:
result = subprocess.run(
[
"nvidia-smi",
"--query-gpu=index,name,memory.used,memory.total,temperature.gpu,utilization.gpu",
"--format=csv,noheader,nounits",
],
capture_output=True,
text=True,
timeout=15,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise GpuInfoError("nvidia-smi timed out") from exc
if result.returncode != 0:
return {
"available": False,
"error": (result.stderr or result.stdout or "nvidia-smi failed").strip(),
"gpus": [],
}
gpus: list[dict[str, Any]] = []
for line in result.stdout.strip().splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) < 6:
continue
idx, name, mem_used, mem_total, temp, util = parts[:6]
try:
used_mb = int(float(mem_used))
total_mb = int(float(mem_total))
except ValueError:
used_mb = 0
total_mb = 0
gpus.append(
{
"index": int(idx),
"name": name,
"memory_used_mb": used_mb,
"memory_total_mb": total_mb,
"memory_used_pct": round(100 * used_mb / total_mb, 1) if total_mb else 0,
"temperature_c": int(float(temp)) if temp not in ("N/A", "") else None,
"utilization_pct": int(float(util)) if util not in ("N/A", "") else None,
}
)
return {"available": True, "error": None, "gpus": gpus}
+4
View File
@@ -0,0 +1,4 @@
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
pydantic>=2.0.0
PyYAML>=6.0
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# One-shot deploy: gpu-fan agent :18090 + server-ui proxy. Run: sudo scripts/deploy-gpu-fan-fix.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACKS_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo ${SCRIPT_DIR}/deploy-gpu-fan-fix.sh"
exit 1
fi
echo "=== GPU Fan integration deploy ==="
pkill -f "stacks/server-ui/.venv/bin/python app.py" 2>/dev/null || true
pkill -f "SERVER_UI_PORT=18091" 2>/dev/null || true
pkill -f "SERVER_UI_PORT=8092" 2>/dev/null || true
"${STACKS_DIR}/gpu-fan/scripts/install.sh"
"${SCRIPT_DIR}/install.sh"
echo ""
echo "=== Verification ==="
UI_KEY="$(grep '^API_KEY=' /opt/control-plane/.env | cut -d= -f2-)"
curl -sf "http://127.0.0.1:8091/api/gpu-fan/health" -H "X-API-Key: ${UI_KEY}" | head -c 200
echo ""
echo ""
echo "Panel: http://$(hostname -I | awk '{print $1}'):8091/#gpu-fan"
echo "API key: grep ^API_KEY= /opt/control-plane/.env"
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env bash
# Unified installer: gpu-fan (native only) + Server UI (native or Docker).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
GPU_FAN_DIR="$(cd "${STACK_DIR}/../gpu-fan" && pwd)"
GPU_FAN_CHOICE=""
SERVER_UI_CHOICE=""
NON_INTERACTIVE=false
usage() {
cat <<'EOF'
Usage: install-control-plane.sh [options]
Options:
--gpu-fan=yes|no|skip Install gpu-fan host agent (native only; no Docker)
--server-ui=native|docker|skip
-y, --non-interactive Use defaults: gpu-fan=yes, server-ui=native
-h, --help
Recommended: run without flags for interactive menu.
gpu-fan cannot run in Docker (NVML requires root on host). See ADR-001.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--gpu-fan=*) GPU_FAN_CHOICE="${1#*=}"; NON_INTERACTIVE=true; shift ;;
--server-ui=*) SERVER_UI_CHOICE="${1#*=}"; NON_INTERACTIVE=true; shift ;;
-y|--non-interactive) NON_INTERACTIVE=true; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1"; usage; exit 1 ;;
esac
done
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo ${SCRIPT_DIR}/install-control-plane.sh"
exit 1
fi
prompt_gpu_fan() {
if [[ -n "${GPU_FAN_CHOICE}" ]]; then
return
fi
echo ""
echo "=== 1) gpu-fan (agent NVML na hoście) ==="
echo " Sterowanie wentylatorami GPU wymaga root + NVML na hoście."
echo " Docker NIE jest obsługiwany (ADR-001)."
echo ""
read -r -p "Zainstalować gpu-fan native? [Y/n]: " ans
ans="${ans:-Y}"
case "${ans^^}" in
Y|YES|TAK) GPU_FAN_CHOICE="yes" ;;
*) GPU_FAN_CHOICE="no" ;;
esac
}
prompt_server_ui() {
if [[ -n "${SERVER_UI_CHOICE}" ]]; then
return
fi
echo ""
echo "=== 2) Server UI (panel stacków Docker + GPU Fan) ==="
echo " [1] Native — systemd na hoście (zalecane)"
echo " [2] Docker — kontener + docker.sock"
echo " [3] Pomiń"
echo ""
read -r -p "Wybór [1]: " ans
ans="${ans:-1}"
case "${ans}" in
1) SERVER_UI_CHOICE="native" ;;
2) SERVER_UI_CHOICE="docker" ;;
3) SERVER_UI_CHOICE="skip" ;;
*) echo "Nieprawidłowy wybór"; exit 1 ;;
esac
}
if [[ "${NON_INTERACTIVE}" == true && -z "${GPU_FAN_CHOICE}" ]]; then
GPU_FAN_CHOICE="yes"
fi
if [[ "${NON_INTERACTIVE}" == true && -z "${SERVER_UI_CHOICE}" ]]; then
SERVER_UI_CHOICE="native"
fi
echo "=== GMKtec Control Plane — instalacja ==="
bash "${SCRIPT_DIR}/setup-control-plane-env.sh"
prompt_gpu_fan
prompt_server_ui
echo ""
echo "Plan:"
echo " gpu-fan: ${GPU_FAN_CHOICE}"
echo " server-ui: ${SERVER_UI_CHOICE}"
echo ""
case "${GPU_FAN_CHOICE,,}" in
yes|y|true|1)
if [[ ! -x "${GPU_FAN_DIR}/scripts/install.sh" ]]; then
echo "ERROR: ${GPU_FAN_DIR}/scripts/install.sh not found"
exit 1
fi
echo "--- Installing gpu-fan (native) ---"
bash "${GPU_FAN_DIR}/scripts/install.sh"
;;
skip|no|n|false|0)
echo "Skipping gpu-fan."
;;
*)
echo "ERROR: invalid --gpu-fan value: ${GPU_FAN_CHOICE}"
exit 1
;;
esac
case "${SERVER_UI_CHOICE,,}" in
native|systemd|1)
echo "--- Installing Server UI (native / systemd) ---"
# Stop Docker deployment if running
if [[ -f "${STACK_DIR}/docker-compose.yml" ]]; then
(cd "${STACK_DIR}" && docker compose --profile server-ui down 2>/dev/null) || true
fi
bash "${SCRIPT_DIR}/install.sh"
;;
docker|container|2)
echo "--- Installing Server UI (Docker) ---"
bash "${SCRIPT_DIR}/install-docker.sh"
;;
skip|no|3)
echo "Skipping Server UI."
;;
*)
echo "ERROR: invalid --server-ui value: ${SERVER_UI_CHOICE}"
exit 1
;;
esac
LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
UI_PORT=8091
echo ""
echo "=============================================="
echo " Instalacja zakończona"
echo "=============================================="
echo ""
echo "Server UI i gpu-fan NIE pojawią się w 'docker ps' gdy działają jako systemd."
echo "W docker ps widać tylko workloady (ComfyUI, LocalAI, NPMPlus, …)."
echo ""
echo "Sprawdzenie:"
echo " systemctl status gpu-fan server-ui # native"
echo " docker compose --profile server-ui ps # docker"
echo " ss -tln | grep -E '8091|18090'"
echo ""
if [[ "${SERVER_UI_CHOICE,,}" != "skip" ]]; then
# shellcheck source=print-api-key-instructions.sh
source "${SCRIPT_DIR}/print-api-key-instructions.sh"
print_api_key_instructions "/opt/control-plane/.env" "${UI_PORT}"
fi
echo "Tutorial: manual-tutorial/08-server-ui-install.md"
echo "Klucz API (szczegóły): manual-tutorial/04a-api-key.md"
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Install Server UI as Docker container (profile server-ui).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
CONTROL_PLANE_STACK="$(cd "${STACK_DIR}/../control-plane" && pwd)"
SERVICE_NAME="server-ui.service"
UI_PORT="${SERVER_UI_PORT:-8091}"
ENV_FILE="${CONTROL_PLANE_STACK}/.env"
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo ${SCRIPT_DIR}/install-docker.sh"
exit 1
fi
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
}
detect_repo_root() {
if [[ -n "${REPO_ROOT:-}" ]]; then
echo "${REPO_ROOT}"
return
fi
local candidate
candidate="$(cd "${STACK_DIR}/../.." && pwd)"
if [[ -d "${candidate}/stacks/localai" ]]; then
echo "${candidate}"
return
fi
echo "${candidate}"
}
echo "=== Server UI — install (Docker) ==="
if ! command -v docker >/dev/null 2>&1; then
echo "ERROR: docker not found. Install Docker CE first."
exit 1
fi
bash "${SCRIPT_DIR}/setup-control-plane-env.sh"
REPO_ROOT_VAL="$(detect_repo_root)"
DOCKER_GID="$(getent group docker | cut -d: -f3 || echo 999)"
if [[ ! -f "${ENV_FILE}" ]]; then
cp "${CONTROL_PLANE_STACK}/.env.example" "${ENV_FILE}"
fi
set_env_var "${ENV_FILE}" "REPO_ROOT" "${REPO_ROOT_VAL}"
set_env_var "${ENV_FILE}" "SERVER_UI_HOST" "0.0.0.0"
set_env_var "${ENV_FILE}" "SERVER_UI_PORT" "${UI_PORT}"
set_env_var "${ENV_FILE}" "GPU_FAN_AGENT_URL" "http://host.docker.internal:18090"
set_env_var "${ENV_FILE}" "DOCKER_GID" "${DOCKER_GID}"
# Disable native systemd if present
if systemctl is-enabled "${SERVICE_NAME}" &>/dev/null; then
echo "Disabling native ${SERVICE_NAME}..."
systemctl disable --now "${SERVICE_NAME}" 2>/dev/null || true
fi
cd "${STACK_DIR}"
export REPO_ROOT="${REPO_ROOT_VAL}"
export DOCKER_GID
echo "Building server-ui image..."
docker compose --profile server-ui build
echo "Starting server-ui container..."
docker compose --profile server-ui up -d
sleep 2
LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
API_KEY_VAL="$(grep '^API_KEY=' "${ENV_FILE}" | cut -d= -f2- || true)"
echo ""
echo "Runtime: Docker (profile server-ui)"
echo "Repo mount: ${REPO_ROOT_VAL} → /repo"
echo "Env: ${ENV_FILE}"
echo "Container: $(docker compose --profile server-ui ps --format '{{.Name}} {{.Status}}' 2>/dev/null || docker ps --filter name=server-ui --format '{{.Names}} {{.Status}}')"
echo ""
echo "Web UI (LAN):"
echo " http://${LAN_IP:-<server-ip>}:${UI_PORT}/"
echo ""
echo "API key:"
echo " grep ^API_KEY= ${ENV_FILE}"
echo ""
if [[ -n "${API_KEY_VAL}" ]]; then
HEALTH="$(curl -sf "http://127.0.0.1:${UI_PORT}/api/health" 2>/dev/null || echo '{"ok":false}')"
echo "Health: ${HEALTH}"
GF_HEALTH="$(curl -sf "http://127.0.0.1:${UI_PORT}/api/gpu-fan/health" -H "X-API-Key: ${API_KEY_VAL}" 2>/dev/null || echo '{"ok":false}')"
echo "GPU Fan proxy: ${GF_HEALTH}"
fi
echo ""
echo "Logs:"
echo " docker compose --profile server-ui logs -f"
echo "Stop:"
echo " docker compose --profile server-ui down"
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
INSTALL_DIR="/opt/server-ui"
SERVICE_NAME="server-ui.service"
UI_PORT=8091
CONTROL_PLANE_ENV="/opt/control-plane/.env"
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo ${SCRIPT_DIR}/install.sh"
exit 1
fi
free_ui_port() {
pkill -f "stacks/server-ui/.venv/bin/python app.py" 2>/dev/null || true
pkill -f "SERVER_UI_PORT=18091" 2>/dev/null || true
local pids
pids="$(ss -tlnp "sport = :${UI_PORT}" 2>/dev/null | grep -oP 'pid=\K[0-9]+' | sort -u || true)"
for pid in ${pids}; do
local cmd
cmd="$(ps -p "${pid}" -o cmd= 2>/dev/null || true)"
if [[ "${cmd}" == *"stacks/server-ui"* ]]; then
echo "Stopping stale dev server-ui on :${UI_PORT} (pid ${pid})"
kill "${pid}" 2>/dev/null || true
fi
done
sleep 1
}
# Disable Docker deployment if switching to native
if [[ -f "${STACK_DIR}/docker-compose.yml" ]]; then
(cd "${STACK_DIR}" && docker compose --profile server-ui down 2>/dev/null) || true
fi
echo "=== Server UI — install (native / systemd) ==="
bash "${SCRIPT_DIR}/setup-control-plane-env.sh"
apt-get update -qq
apt-get install -y python3-venv python3-pip
mkdir -p "${INSTALL_DIR}"
rsync -a --delete \
--exclude '.venv' \
--exclude '.env' \
--exclude '__pycache__' \
"${STACK_DIR}/" "${INSTALL_DIR}/"
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}/server-ui.service" "/etc/systemd/system/${SERVICE_NAME}"
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}"
free_ui_port
systemctl restart "${SERVICE_NAME}"
sleep 2
LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
API_KEY_VAL="$(grep '^API_KEY=' "${CONTROL_PLANE_ENV}" | cut -d= -f2- || true)"
echo ""
echo "Installed to ${INSTALL_DIR}"
echo "Service: $(systemctl is-active "${SERVICE_NAME}" 2>/dev/null || echo unknown)"
echo "Env: ${CONTROL_PLANE_ENV}"
# shellcheck source=print-api-key-instructions.sh
source "${SCRIPT_DIR}/print-api-key-instructions.sh"
print_api_key_instructions "${CONTROL_PLANE_ENV}" "${UI_PORT}"
if [[ -n "${API_KEY_VAL}" ]]; then
HEALTH="$(curl -sf "http://127.0.0.1:${UI_PORT}/api/gpu-fan/health" -H "X-API-Key: ${API_KEY_VAL}" 2>/dev/null || echo '{"ok":false}')"
echo "GPU Fan proxy health: ${HEALTH}"
if ! echo "${HEALTH}" | grep -q '"ok":true'; then
echo "WARN: gpu-fan agent may be down — run: sudo ${STACK_DIR%/server-ui}/gpu-fan/scripts/install.sh"
fi
fi
echo ""
echo "Logs:"
echo " journalctl -u ${SERVICE_NAME} -f"
echo ""
echo "Unified installer (gpu-fan + server-ui):"
echo " sudo ${SCRIPT_DIR}/install-control-plane.sh"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Print API key + browser instructions (shared by install scripts and show-api-key.sh).
set -euo pipefail
print_api_key_instructions() {
local env_file="${1:-/opt/control-plane/.env}"
local ui_port="${2:-8091}"
local api_key=""
local lan_ip=""
if [[ -r "${env_file}" ]]; then
api_key="$(grep '^API_KEY=' "${env_file}" 2>/dev/null | cut -d= -f2- || true)"
elif [[ -f "${env_file}" ]]; then
api_key="$(sudo grep '^API_KEY=' "${env_file}" 2>/dev/null | cut -d= -f2- || true)"
fi
lan_ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
[[ -n "${lan_ip}" ]] || lan_ip="<IP-serwera>"
echo ""
echo "══════════════════════════════════════════════"
echo " SERVER UI — KLUCZ API"
echo "══════════════════════════════════════════════"
echo ""
if [[ -z "${api_key}" ]]; then
echo "Brak API_KEY w ${env_file}"
echo "Uruchom: sudo bash stacks/server-ui/scripts/setup-control-plane-env.sh"
echo ""
return 1
fi
echo "1. Twój klucz API (skopiuj):"
echo ""
echo " ${api_key}"
echo ""
echo "2. Otwórz panel (klucz zapisze się w przeglądarce):"
echo ""
echo " http://${lan_ip}:${ui_port}/?api_key=${api_key}"
echo ""
echo "3. Albo ręcznie:"
echo " a) Wejdź na http://${lan_ip}:${ui_port}/"
echo " b) Wklej klucz w pole „API Key”"
echo " c) Kliknij „Zapisz”"
echo " d) Kliknij „Sprawdź klucz” (powinno być: Klucz poprawny)"
echo " e) Dopiero potem Start/Stop, CLI, Pliki, GPU Fan"
echo ""
echo "Plik klucza (na przyszłość):"
echo " sudo grep ^API_KEY= ${env_file}"
echo ""
echo "══════════════════════════════════════════════"
echo ""
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
print_api_key_instructions "${1:-/opt/control-plane/.env}" "${2:-8091}"
fi
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Restart gpu-fan + server-ui after code sync. Requires: sudo scripts/restart-stack.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ "${EUID}" -ne 0 ]]; then
echo "Run: sudo ${SCRIPT_DIR}/restart-stack.sh"
exit 1
fi
systemctl restart gpu-fan
sleep 2
systemctl restart server-ui
sleep 2
echo "gpu-fan: $(systemctl is-active gpu-fan)"
echo "server-ui: $(systemctl is-active server-ui)"
ss -tlnp | grep -E '18090|8091' || true
UI_KEY="$(grep '^API_KEY=' /opt/control-plane/.env | cut -d= -f2-)"
curl -sf "http://127.0.0.1:8091/api/gpu-fan/health" -H "X-API-Key: ${UI_KEY}" || echo "health check failed"
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env bash
# Create /opt/control-plane/.env and migrate from legacy per-service .env files.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
REPO_ROOT="$(cd "${STACK_DIR}/../.." && pwd)"
CONTROL_PLANE_DIR="/opt/control-plane"
ENV_FILE="${CONTROL_PLANE_DIR}/.env"
EXAMPLE="${REPO_ROOT}/stacks/control-plane/.env.example"
LEGACY_SUI="/opt/server-ui/.env"
LEGACY_GF="/opt/gpu-fan/.env"
LEGACY_REPO_SUI="${REPO_ROOT}/stacks/server-ui/.env"
DEV_ENV="${REPO_ROOT}/stacks/control-plane/.env"
TIMESTAMP="$(date +%Y%m%d%H%M%S)"
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo ${SCRIPT_DIR}/setup-control-plane-env.sh"
exit 1
fi
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
}
get_env_var() {
local file="$1" key="$2"
grep "^${key}=" "${file}" 2>/dev/null | cut -d= -f2- || true
}
remove_deprecated_keys() {
local file="$1"
[[ -f "${file}" ]] || return 0
cp -a "${file}" "${file}.bak.${TIMESTAMP}"
grep -v -E '^(API_KEY|GPU_FAN_AGENT_KEY)=' "${file}" > "${file}.tmp.${TIMESTAMP}" || true
{
echo "# API_KEY: use /opt/control-plane/.env (prod) or stacks/control-plane/.env (dev)"
echo "# Run: sudo bash stacks/server-ui/scripts/setup-control-plane-env.sh"
cat "${file}.tmp.${TIMESTAMP}"
} > "${file}"
rm -f "${file}.tmp.${TIMESTAMP}"
echo "Removed API_KEY / GPU_FAN_AGENT_KEY from ${file} (backup: ${file}.bak.${TIMESTAMP})"
}
sync_dev_env_from_prod() {
local prod_key repo_root_val
prod_key="$(get_env_var "${ENV_FILE}" API_KEY)"
repo_root_val="$(get_env_var "${ENV_FILE}" REPO_ROOT)"
[[ -n "${repo_root_val}" ]] || repo_root_val="${REPO_ROOT}"
if [[ ! -f "${DEV_ENV}" && -f "${EXAMPLE}" ]]; then
cp "${EXAMPLE}" "${DEV_ENV}"
echo "Created dev env: ${DEV_ENV}"
fi
if [[ ! -f "${DEV_ENV}" ]]; then
echo "WARN: dev env missing: ${DEV_ENV}"
return 0
fi
if [[ -n "${prod_key}" ]]; then
set_env_var "${DEV_ENV}" "API_KEY" "${prod_key}"
fi
set_env_var "${DEV_ENV}" "REPO_ROOT" "${repo_root_val}"
local ui_port
ui_port="$(get_env_var "${ENV_FILE}" SERVER_UI_PORT)"
[[ -n "${ui_port}" ]] && set_env_var "${DEV_ENV}" "SERVER_UI_PORT" "${ui_port}"
echo "Synced dev env: ${DEV_ENV} (API_KEY matches ${ENV_FILE})"
}
merge_env_file() {
local src="$1"
[[ -f "${src}" ]] || return 0
while IFS= read -r line || [[ -n "${line}" ]]; do
line="${line%%#*}"
line="$(echo "${line}" | xargs)"
[[ -n "${line}" && "${line}" == *=* ]] || continue
local key="${line%%=*}"
local val="${line#*=}"
key="$(echo "${key}" | xargs)"
[[ -n "${val}" ]] || continue
# Skip deprecated duplicate key
[[ "${key}" == "GPU_FAN_AGENT_KEY" ]] && continue
set_env_var "${ENV_FILE}" "${key}" "${val}"
done < "${src}"
}
echo "=== Control plane env setup ==="
mkdir -p "${CONTROL_PLANE_DIR}"
# Deploy env_loader for production Python imports
if [[ -f "${REPO_ROOT}/stacks/control-plane/env_loader.py" ]]; then
install -m 644 "${REPO_ROOT}/stacks/control-plane/env_loader.py" "${CONTROL_PLANE_DIR}/env_loader.py"
fi
if [[ ! -f "${ENV_FILE}" ]]; then
if [[ -f "${EXAMPLE}" ]]; then
cp "${EXAMPLE}" "${ENV_FILE}"
echo "Created ${ENV_FILE} from example"
else
touch "${ENV_FILE}"
echo "Created empty ${ENV_FILE}"
fi
fi
# Migrate legacy files (non-destructive: backup first)
SUI_KEY=""
GF_KEY=""
[[ -f "${LEGACY_SUI}" ]] && SUI_KEY="$(get_env_var "${LEGACY_SUI}" API_KEY)"
[[ -f "${LEGACY_GF}" ]] && GF_KEY="$(get_env_var "${LEGACY_GF}" API_KEY)"
for legacy in "${LEGACY_SUI}" "${LEGACY_GF}"; do
if [[ -f "${legacy}" ]]; then
cp -a "${legacy}" "${legacy}.bak.${TIMESTAMP}"
echo "Backed up ${legacy}${legacy}.bak.${TIMESTAMP}"
merge_env_file "${legacy}"
fi
done
if [[ -n "${SUI_KEY}" && -n "${GF_KEY}" && "${SUI_KEY}" != "${GF_KEY}" ]]; then
echo "WARN: API_KEY differed in legacy files — merged value: $(get_env_var "${ENV_FILE}" API_KEY)"
echo " After install, use one key everywhere: grep ^API_KEY= ${ENV_FILE}"
fi
# Defaults
set_env_var "${ENV_FILE}" "GPU_FAN_AGENT_URL" "$(get_env_var "${ENV_FILE}" GPU_FAN_AGENT_URL || echo http://127.0.0.1:18090)"
[[ -n "$(get_env_var "${ENV_FILE}" GPU_FAN_AGENT_URL)" ]] || set_env_var "${ENV_FILE}" "GPU_FAN_AGENT_URL" "http://127.0.0.1:18090"
set_env_var "${ENV_FILE}" "GPU_FAN_API_HOST" "$(get_env_var "${ENV_FILE}" GPU_FAN_API_HOST || echo 127.0.0.1)"
[[ -n "$(get_env_var "${ENV_FILE}" GPU_FAN_API_HOST)" ]] || set_env_var "${ENV_FILE}" "GPU_FAN_API_HOST" "127.0.0.1"
set_env_var "${ENV_FILE}" "GPU_FAN_API_PORT" "$(get_env_var "${ENV_FILE}" GPU_FAN_API_PORT || echo 18090)"
[[ -n "$(get_env_var "${ENV_FILE}" GPU_FAN_API_PORT)" ]] || set_env_var "${ENV_FILE}" "GPU_FAN_API_PORT" "18090"
set_env_var "${ENV_FILE}" "CURVE_PATH" "$(get_env_var "${ENV_FILE}" CURVE_PATH || echo /etc/gpu-fan/curve.json)"
[[ -n "$(get_env_var "${ENV_FILE}" CURVE_PATH)" ]] || set_env_var "${ENV_FILE}" "CURVE_PATH" "/etc/gpu-fan/curve.json"
# REPO_ROOT default
if [[ -z "$(get_env_var "${ENV_FILE}" REPO_ROOT)" ]]; then
set_env_var "${ENV_FILE}" "REPO_ROOT" "${REPO_ROOT}"
fi
# Generate API_KEY if missing or placeholder
API_KEY_VAL="$(get_env_var "${ENV_FILE}" API_KEY)"
if [[ -z "${API_KEY_VAL}" || "${API_KEY_VAL}" == change-me* ]]; then
KEY="$(openssl rand -hex 16)"
set_env_var "${ENV_FILE}" "API_KEY" "${KEY}"
echo "Generated API_KEY in ${ENV_FILE}"
fi
chmod 600 "${ENV_FILE}"
# Always sync dev copy to production key
sync_dev_env_from_prod
# Remove duplicate API keys from legacy per-service env files
for legacy in "${LEGACY_SUI}" "${LEGACY_GF}" "${LEGACY_REPO_SUI}"; do
if [[ -f "${legacy}" ]] && grep -qE '^(API_KEY|GPU_FAN_AGENT_KEY)=' "${legacy}" 2>/dev/null; then
remove_deprecated_keys "${legacy}"
fi
done
echo ""
echo "Control plane env: ${ENV_FILE}"
echo "Dev copy: ${DEV_ENV}"
echo ""
# shellcheck source=print-api-key-instructions.sh
source "${SCRIPT_DIR}/print-api-key-instructions.sh"
print_api_key_instructions "${ENV_FILE}" "$(get_env_var "${ENV_FILE}" SERVER_UI_PORT || echo 8091)"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Show API key and step-by-step browser instructions for Server UI.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
REPO_ROOT="$(cd "${STACK_DIR}/../.." && pwd)"
PROD_ENV="/opt/control-plane/.env"
DEV_ENV="${REPO_ROOT}/stacks/control-plane/.env"
UI_PORT=8091
if [[ -f "${DEV_ENV}" ]]; then
UI_PORT="$(grep '^SERVER_UI_PORT=' "${DEV_ENV}" 2>/dev/null | cut -d= -f2- || echo 8091)"
fi
# Prefer dev copy when readable (same key after sync); else production.
ENV_FILE="${DEV_ENV}"
if [[ ! -r "${ENV_FILE}" ]]; then
ENV_FILE="${PROD_ENV}"
fi
# shellcheck source=print-api-key-instructions.sh
source "${SCRIPT_DIR}/print-api-key-instructions.sh"
print_api_key_instructions "${ENV_FILE}" "${UI_PORT}"
+29
View File
@@ -0,0 +1,29 @@
#!/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"
cd "${STACK_DIR}"
if [[ ! -f "${CONTROL_PLANE_ENV}" ]]; then
echo "ERROR: ${CONTROL_PLANE_ENV} not found."
echo "Run: cp ${STACK_DIR}/../control-plane/.env.example ${CONTROL_PLANE_ENV}"
exit 1
fi
if [[ ! -d .venv ]]; then
python3 -m venv .venv
.venv/bin/pip install --upgrade pip -q
.venv/bin/pip install -r requirements.txt -q
fi
set -a
# shellcheck disable=SC1091
source "${CONTROL_PLANE_ENV}"
set +a
export CONTROL_PLANE_ENV="${CONTROL_PLANE_ENV}"
exec .venv/bin/python app.py
+20
View File
@@ -0,0 +1,20 @@
[Unit]
Description=GMKtec K11 Server UI (Docker stack manager)
After=docker.service network-online.target
Wants=docker.service
[Service]
Type=simple
User=tomasz-syn-grzegorza
Group=tomasz-syn-grzegorza
SupplementaryGroups=docker
WorkingDirectory=/opt/server-ui
EnvironmentFile=-/opt/control-plane/.env
ExecStart=/opt/server-ui/.venv/bin/python /opt/server-ui/app.py
Restart=on-failure
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=15
[Install]
WantedBy=multi-user.target
+44
View File
@@ -0,0 +1,44 @@
stacks:
- id: localai
name: LocalAI
compose_dir: localai
profile: localai
container: localai
ui_port: 8070
port_env: LOCALAI_PORT
port_default: 8080
port_editable: true
gpu: true
- id: comfyui
name: ComfyUI
compose_dir: comfyui
profile: comfyui
container: comfyui
ui_port: 8188
port_env: COMFYUI_PORT
port_default: 8188
port_editable: true
gpu: true
- id: vllm
name: vLLM
compose_dir: vllm
profile: vllm
container: vllm
ui_port: 8000
port_env: VLLM_PORT
port_default: 8000
port_editable: true
gpu: true
- id: npmplus
name: NPMPlus
compose_dir: npmplus
profile: npmplus
container: npmplus
ui_port: 81
ui_scheme: https
port_default: 81
port_editable: false
gpu: false
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
/**
* Skipped minification because the original files appears to be already minified.
* Original file: /npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=addon-fit.js.map
+218
View File
@@ -0,0 +1,218 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility:not(.debug),
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
color: transparent;
}
.xterm .xterm-accessibility-tree {
user-select: text;
white-space: pre;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
File diff suppressed because one or more lines are too long
+23
View File
@@ -0,0 +1,23 @@
# Data disk mount point
DATA_ROOT=/data
# --- REQUIRED before start (no default model) ---
VLLM_MODEL=
SERVED_MODEL_NAME=qwen3.6-27b
# --- Context and VRAM ---
MAX_MODEL_LEN=131072
MAX_NUM_SEQS=1
GPU_MEMORY_UTILIZATION=0.95
KV_CACHE_DTYPE=fp8
# --- Model quantization (leave empty for full-precision models) ---
QUANTIZATION=awq
# Model-specific vLLM flags (space-separated)
VLLM_EXTRA_ARGS=--language-model-only --enforce-eager --reasoning-parser qwen3
# Hugging Face token — only for gated models
HF_TOKEN=
VLLM_PORT=8000
+182
View File
@@ -0,0 +1,182 @@
# vLLM stack
Serwer inference vLLM z API kompatybilnym z OpenAI. **Brak panelu UI** — konfiguracja przez plik `.env`, profile i katalog modeli.
## Jak to działa
```mermaid
flowchart LR
client["Klient curl / OpenAI SDK"]
api["vLLM :8000 /v1/*"]
gpu["RTX 3090 Ti"]
data["/data/apps/vllm/huggingface"]
client --> api
api --> gpu
api --> data
```
| Element | Opis |
|---------|------|
| Obraz | `vllm/vllm-openai` |
| Port | `8000` (OpenAI-compatible) |
| Konfiguracja | `.env` + profile + `models.catalog.yaml` |
| Modele vLLM | Hugging Face AWQ → `/data/apps/vllm/huggingface` |
| Modele GGUF | Katalog + `/data/apps/gguf/` → przyszły [`stacks/llamacpp/`](../llamacpp/) |
| UI | **Brak** — opcjonalnie Open WebUI w przyszłości |
## GGUF vs AWQ (ważne)
| Źródło | Format | Runtime |
|--------|--------|---------|
| [lmstudio-community GGUF](https://huggingface.co/lmstudio-community) | `.gguf` Q4 | **llama.cpp** (planowany) |
| Hugging Face AWQ | safetensors INT4 | **vLLM** (teraz) |
Standardowy `vllm/vllm-openai` **nie ładuje plików `.gguf`**. Linki GGUF z katalogu są pod przyszły host llama.cpp. Na vLLM używamy `Qwen/Qwen3.6-27B-Instruct-AWQ` jako odpowiednika Q4.
vLLM = **jeden model w VRAM** na kontener. Kilka modeli może leżeć na dysku — przełączanie = zmiana profilu + restart.
## Mapowanie z LM Studio / Ollama
| LM Studio / Ollama | vLLM |
|--------------------|------|
| Model GGUF Q4 (lmstudio) | AWQ z HF + `QUANTIZATION=awq` (interim) |
| K Cache Q4_0 | `KV_CACHE_DTYPE=fp8` |
| V Cache Q4_0 | j.w. — vLLM nie ma flagi `Q4_0` |
| Context 128K | `MAX_MODEL_LEN=131072` |
| 1 wątek / 1 request | `MAX_NUM_SEQS=1` |
| GPU layers max | `GPU_MEMORY_UTILIZATION=0.95` |
Docelowo GGUF + natywne K/V `q4_0`: [`stacks/llamacpp/README.md`](../llamacpp/README.md).
## Struktura katalogów
```
stacks/vllm/
├── README.md
├── models.catalog.yaml # lista modeli (bez auto-pobierania)
├── docker-compose.yml
├── .env.example
├── profiles/
│ ├── _template.env
│ └── qwen3.6-27b-awq-128k.env
└── scripts/
├── catalog-lib.sh
├── list-models.sh
├── download-model.sh
├── switch-model.sh
├── start.sh
└── vllm-entrypoint.sh
```
Na dysku `/data`:
```
/data/apps/
├── vllm/huggingface/ # cache HF (AWQ)
└── gguf/ # przyszłe GGUF (puste katalogi tworzone przez skrypty)
├── qwen3.6-27b/
└── gemma-4-12b/
```
## Model catalog
Plik `models.catalog.yaml` zawiera modele docelowe (GGUF) i interim (vLLM AWQ). **Nic nie pobiera się przy instalacji.**
```bash
./scripts/list-models.sh
```
| ID | Runtime | Opis |
|----|---------|------|
| `qwen3.6-27b-q4-gguf` | llamacpp | Qwen3.6-27B Q4_K_M z lmstudio-community |
| `gemma-4-12b-q4-gguf` | llamacpp | Gemma 4 12B Q4_0 (+ mmproj) |
| `qwen3.6-27b-awq-vllm` | vllm | AWQ interim — użyj teraz |
## Workflow
### 1. Przygotuj stack (bez modelu)
```bash
cd /home/tomasz-syn-grzegorza/cursor/ubuntu-bare-metal/stacks/vllm
cp .env.example .env
```
### 2. Zobacz katalog
```bash
./scripts/list-models.sh
```
### 3. Pobierz model na żądanie
```bash
# vLLM interim (AWQ → cache HF)
./scripts/download-model.sh qwen3.6-27b-awq-vllm
# później — GGUF do /data/apps/gguf (dla llama.cpp)
# ./scripts/download-model.sh qwen3.6-27b-q4-gguf
```
### 4. Przełącz profil i uruchom
```bash
./scripts/switch-model.sh qwen3.6-27b-awq-128k
# lub pierwszy start:
./scripts/start.sh qwen3.6-27b-awq-128k
```
`start.sh` odrzuca `.gguf` w `VLLM_MODEL` i wskazuje katalog.
### 5. Logi i test
```bash
docker compose --profile vllm logs -f vllm
curl -s http://localhost:8000/v1/models | jq .
```
## Switching models (A / B na dysku)
1. Model B może już być pobrany (`download-model.sh`) — leży na dysku, nie w VRAM.
2. Przełącz profil: `./scripts/switch-model.sh <profile>` — kopiuje profil → `.env`, restartuje kontener.
3. Tylko **jeden** model aktywny w VRAM naraz.
Nowy profil vLLM: skopiuj `profiles/_template.env`, dostosuj `VLLM_EXTRA_ARGS` i `QUANTIZATION`.
## Zmienne `.env`
| Zmienna | Opis | Domyślnie |
|---------|------|-----------|
| `VLLM_MODEL` | **Wymagane** — ID modelu Hugging Face | *(pusty)* |
| `SERVED_MODEL_NAME` | Nazwa w API | `qwen3.6-27b` |
| `MAX_MODEL_LEN` | Okno kontekstu (tokeny) | `131072` (128K) |
| `MAX_NUM_SEQS` | Równoległe sekwencje | `1` |
| `GPU_MEMORY_UTILIZATION` | % VRAM dla vLLM | `0.95` |
| `KV_CACHE_DTYPE` | Kwantyzacja KV cache | `fp8` |
| `QUANTIZATION` | Typ kwantyzacji wag (`awq` lub pusty) | `awq` |
| `VLLM_EXTRA_ARGS` | Dodatkowe flagi vLLM (spacje) | `--language-model-only --enforce-eager --reasoning-parser qwen3` |
| `DATA_ROOT` | Mount dysku danych | `/data` |
| `HF_TOKEN` | Token Hugging Face (gated) | *(pusty)* |
`QUANTIZATION` puste = model pełnej precyzji (bez `--quantization`). Flagi buduje `scripts/vllm-entrypoint.sh`.
## Tuning po OOM
1. `MAX_MODEL_LEN=98304` lub `65536`
2. `GPU_MEMORY_UTILIZATION=0.90`
3. `KV_CACHE_DTYPE=turboquant_k8v4`
## Zarządzanie
```bash
docker compose --profile vllm ps
docker compose --profile vllm logs -f vllm
docker compose --profile vllm restart vllm
docker compose --profile vllm down
```
## Dokumentacja
Pełny tutorial: [manual-tutorial/04-vllm-stack.md](../../manual-tutorial/04-vllm-stack.md) (część B).
GGUF (planowany): [stacks/llamacpp/README.md](../llamacpp/README.md).
+1
View File
@@ -0,0 +1 @@
docker-compose.yml
+26
View File
@@ -0,0 +1,26 @@
services:
vllm:
image: vllm/vllm-openai:latest
container_name: vllm
profiles:
- vllm
restart: unless-stopped
ipc: host
ports:
- "${VLLM_PORT:-8000}:8000"
environment:
- HF_TOKEN=${HF_TOKEN:-}
- CUDA_VISIBLE_DEVICES=0
- VLLM_MODEL=${VLLM_MODEL}
- SERVED_MODEL_NAME=${SERVED_MODEL_NAME:-qwen3.6-27b}
- MAX_MODEL_LEN=${MAX_MODEL_LEN:-131072}
- MAX_NUM_SEQS=${MAX_NUM_SEQS:-1}
- GPU_MEMORY_UTILIZATION=${GPU_MEMORY_UTILIZATION:-0.95}
- KV_CACHE_DTYPE=${KV_CACHE_DTYPE:-fp8}
- QUANTIZATION=${QUANTIZATION:-}
- VLLM_EXTRA_ARGS=${VLLM_EXTRA_ARGS:---language-model-only --enforce-eager --reasoning-parser qwen3}
volumes:
- ${DATA_ROOT:-/data}/apps/vllm/huggingface:/root/.cache/huggingface
- ./scripts/vllm-entrypoint.sh:/usr/local/bin/vllm-entrypoint.sh:ro
gpus: all
entrypoint: ["/bin/bash", "/usr/local/bin/vllm-entrypoint.sh"]
+32
View File
@@ -0,0 +1,32 @@
# Model catalog — no automatic download on stack install.
# Use: ./scripts/list-models.sh
# ./scripts/download-model.sh <id>
models:
- id: qwen3.6-27b-q4-gguf
name: Qwen3.6-27B Q4_K_M
runtime: llamacpp
hf_repo: lmstudio-community/Qwen3.6-27B-GGUF
gguf_file: Qwen3.6-27B-Q4_K_M.gguf
download_url: https://huggingface.co/lmstudio-community/Qwen3.6-27B-GGUF/resolve/main/Qwen3.6-27B-Q4_K_M.gguf
size_gb: 17
local_path: /data/apps/gguf/qwen3.6-27b/Qwen3.6-27B-Q4_K_M.gguf
- id: gemma-4-12b-q4-gguf
name: Gemma 4 12B Q4_0
runtime: llamacpp
hf_repo: lmstudio-community/gemma-4-12B-it-QAT-GGUF
gguf_file: gemma-4-12B-it-QAT-Q4_0.gguf
mmproj_file: mmproj-gemma-4-12B-it-QAT-BF16.gguf
download_url: https://huggingface.co/lmstudio-community/gemma-4-12B-it-QAT-GGUF/resolve/main/gemma-4-12B-it-QAT-Q4_0.gguf
mmproj_url: https://huggingface.co/lmstudio-community/gemma-4-12B-it-QAT-GGUF/resolve/main/mmproj-gemma-4-12B-it-QAT-BF16.gguf
size_gb: 7
local_dir: /data/apps/gguf/gemma-4-12b
- id: qwen3.6-27b-awq-vllm
name: Qwen3.6-27B AWQ (vLLM interim)
runtime: vllm
hf_model: Qwen/Qwen3.6-27B-Instruct-AWQ
profile: qwen3.6-27b-awq-128k
size_gb: 15
note: AWQ on vLLM — Q4 equivalent until llama.cpp stack is ready
+18
View File
@@ -0,0 +1,18 @@
# Template for a new vLLM profile
# Copy: cp profiles/_template.env profiles/my-model.env
# GGUF files from lmstudio-community require stacks/llamacpp (future), not vLLM.
DATA_ROOT=/data
VLLM_MODEL=
SERVED_MODEL_NAME=my-model
QUANTIZATION=awq
VLLM_EXTRA_ARGS=--language-model-only --enforce-eager
MAX_MODEL_LEN=131072
MAX_NUM_SEQS=1
GPU_MEMORY_UTILIZATION=0.95
KV_CACHE_DTYPE=fp8
HF_TOKEN=
VLLM_PORT=8000

Some files were not shown because too many files have changed in this diff Show More