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