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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
tomasz-syn-grzegorza
2026-07-05 12:02:04 +00:00
commit 359afb3a59
153 changed files with 18169 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# DEPRECATED — use stacks/control-plane/.env.example
# Copy: cp ../control-plane/.env.example ../control-plane/.env
+1
View File
@@ -0,0 +1 @@
.env
+29
View File
@@ -0,0 +1,29 @@
FROM python:3.12-slim-bookworm
RUN apt-get update -qq \
&& apt-get install -y --no-install-recommends \
docker.io \
curl \
ca-certificates \
&& mkdir -p /usr/local/lib/docker/cli-plugins \
&& curl -fsSL "https://github.com/docker/compose/releases/download/v2.32.4/docker-compose-linux-$(uname -m)" \
-o /usr/local/lib/docker/cli-plugins/docker-compose \
&& chmod +x /usr/local/lib/docker/cli-plugins/docker-compose \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY server-ui/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY control-plane/env_loader.py ./env_loader.py
COPY server-ui/app.py server-ui/compose_runner.py server-ui/gpu_info.py server-ui/gpu_fan_proxy.py server-ui/file_explorer.py server-ui/stacks.yaml ./
COPY server-ui/static ./static
ENV SERVER_UI_HOST=0.0.0.0
ENV SERVER_UI_PORT=8091
ENV REPO_ROOT=/repo
EXPOSE 8091
CMD ["python", "app.py"]
+111
View File
@@ -0,0 +1,111 @@
# Server UI — Docker stack manager
Własny panel do zarządzania znanymi stackami compose na **gmktec-k11**. Zastępuje Dockge i Portainer. Zawiera **przeglądarkę plików**, panel **GPU Fan** (proxy do agenta na hoście) i zarządzanie stackami.
## Dlaczego nie ma Server UI w `docker ps`?
`docker ps` pokazuje tylko **kontenery Docker**. Domyślna instalacja Server UI to **systemd na hoście** (`server-ui.service` na porcie **8091**) — nie pojawi się na liście kontenerów. To normalne.
| Usługa | Runtime | W `docker ps` |
|--------|---------|---------------|
| ComfyUI, LocalAI, vLLM, NPMPlus | Docker compose | tak |
| **Server UI** (native) | systemd → `/opt/server-ui` | **nie** |
| **gpu-fan** agent | systemd → `/opt/gpu-fan` | **nie** |
Sprawdzenie: `systemctl status server-ui` lub `docker compose --profile server-ui ps` (tryb Docker).
## Port
| Serwis | Port | URL |
|--------|------|-----|
| Server UI | **8091** | `http://HOST:8091` |
| CLI (zakładka) | — | ten sam URL, zakładka **CLI** lub `#cli` |
| Pliki (zakładka) | — | ten sam URL, zakładka **Pliki** lub `#files` |
| GPU Fan (zakładka) | — | ten sam URL, zakładka **GPU Fan** |
| gpu-fan agent API | **18090** | tylko localhost (proxy z Server UI) |
## Instalacja (zalecana — jeden skrypt)
```bash
cd ~/cursor/ubuntu-bare-metal/stacks/server-ui
sudo ./scripts/install-control-plane.sh
```
Menu:
1. **gpu-fan** — tylko native (NVML na hoście; Docker nieobsługiwany)
2. **Server UI** — native (systemd) lub Docker (kontener)
Flagi nieinteraktywne:
```bash
sudo ./scripts/install-control-plane.sh -y
sudo ./scripts/install-control-plane.sh --gpu-fan=yes --server-ui=docker
```
### Tylko Server UI
| Tryb | Komenda |
|------|---------|
| Native (systemd) | `sudo ./scripts/install.sh` |
| Docker | `sudo ./scripts/install-docker.sh` |
Klucze API po instalacji:
```bash
bash stacks/server-ui/scripts/show-api-key.sh
```
Tutorial klucza: [`../../manual-tutorial/04a-api-key.md`](../../manual-tutorial/04a-api-key.md)
Dev: `stacks/control-plane/.env` (szablon: `stacks/control-plane/.env.example`)
Tutorial: [`../../manual-tutorial/08-server-ui-install.md`](../../manual-tutorial/08-server-ui-install.md) · CLI: [`10-server-ui-cli.md`](../../manual-tutorial/10-server-ui-cli.md) · Pliki: [`09-file-explorer.md`](../../manual-tutorial/09-file-explorer.md)
## Stacki (whitelist)
| Stack | Profil | Port UI | GPU |
|-------|--------|---------|-----|
| LocalAI | `localai` | 8070 | tak |
| ComfyUI | `comfyui` | 8188 | tak |
| vLLM | `vllm` | 8000 | tak |
| NPMPlus | `npmplus` | 81 (HTTPS) | nie |
Porty edytowalne w UI (zapis do `stacks/<name>/.env`). Szczegóły: [`../../coding-agent/SERVER-UI-PORT-CONFIG.md`](../../coding-agent/SERVER-UI-PORT-CONFIG.md)
## Bezpieczeństwo
- Odczyt statusu stacków/logów/GPU — bez klucza (LAN).
- **Start / Stop / Restart** stacków, **GPU Fan**, **Pliki** (CRUD) i **CLI** — nagłówek `X-API-Key` (CLI: klucz w query WebSocket `?api_key=`).
- Przy `SERVER_UI_HOST=0.0.0.0` klucz jest **wymagany** przy starcie usługi.
## Tryb dev (z repo)
```bash
cp ../control-plane/.env.example ../control-plane/.env
./scripts/start.sh
```
## Struktura
```
stacks/server-ui/
├── app.py
├── Dockerfile
├── docker-compose.yml
├── compose_runner.py
├── stacks.yaml
├── server-ui.service
├── static/index.html
├── static/vendor/xterm/ # xterm.js dla zakładki CLI
├── cli_pty.py
└── scripts/
├── install-control-plane.sh # gpu-fan + server-ui (menu)
├── install.sh # native only
├── install-docker.sh # docker only
└── start.sh
```
## Dokumentacja
- [`../../manual-tutorial/08-server-ui-install.md`](../../manual-tutorial/08-server-ui-install.md)
- [`../../coding-agent/SERVER-UI-INSTALL-OPTIONS.md`](../../coding-agent/SERVER-UI-INSTALL-OPTIONS.md)
- [`../../coding-agent/DOCKER-UI-DEPLOYMENT.md`](../../coding-agent/DOCKER-UI-DEPLOYMENT.md)
- [`../../coding-agent/ADR-001-host-agent-control-plane.md`](../../coding-agent/ADR-001-host-agent-control-plane.md)
+453
View File
@@ -0,0 +1,453 @@
#!/usr/bin/env python3
"""GMKtec K11 — Docker stack management UI."""
from __future__ import annotations
import logging
import os
import sys
from pathlib import Path
from typing import Any
import uvicorn
from fastapi import Depends, FastAPI, HTTPException, Query, Request, WebSocket
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from compose_runner import (
ComposeError,
PortConfigError,
StackConfig,
find_running_gpu_stacks,
get_container_state,
get_logs,
load_stacks,
set_stack_port,
stack_restart,
stack_start,
stack_stop,
)
from cli_pty import CliSessionLimitError, run_pty_session, sessions_available
from file_explorer import FileExplorer, FileExplorerError
from gpu_info import GpuInfoError, query_gpu
from gpu_fan_proxy import AgentProxyError, check_agent_health, forward_request, resolve_agent_url
STACK_DIR = Path(__file__).resolve().parent
# Unified control-plane env (see stacks/control-plane/env_loader.py)
for _cp in ("/opt/control-plane", "/repo/stacks/control-plane", str(STACK_DIR.parent / "control-plane")):
if _cp not in sys.path and Path(_cp).exists():
sys.path.insert(0, _cp)
from env_loader import api_key_source, load_control_plane_env # noqa: E402
STATIC_DIR = STACK_DIR / "static"
CONFIG_PATH = STACK_DIR / "stacks.yaml"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
log = logging.getLogger(__name__)
ENV = load_control_plane_env(STACK_DIR)
HOST = ENV.get("SERVER_UI_HOST", "0.0.0.0")
PORT = int(ENV.get("SERVER_UI_PORT", "8091"))
API_KEY = ENV.get("API_KEY", "")
GPU_FAN_AGENT_URL = resolve_agent_url(
ENV.get("GPU_FAN_AGENT_URL", "http://127.0.0.1:18090"),
API_KEY,
)
REPO_ROOT = Path(
ENV.get("REPO_ROOT", str(STACK_DIR.parent.parent))
).resolve()
STACKS_BASE = REPO_ROOT / "stacks"
STACKS: list[StackConfig] = load_stacks(CONFIG_PATH, STACKS_BASE)
STACK_BY_ID: dict[str, StackConfig] = {s.id: s for s in STACKS}
FILE_EXPLORER_ROOT = ENV.get("FILE_EXPLORER_ROOT", "/")
FILE_EXPLORER_MAX_BYTES = int(ENV.get("FILE_EXPLORER_MAX_BYTES", "2097152"))
file_explorer = FileExplorer(root=FILE_EXPLORER_ROOT, max_bytes=FILE_EXPLORER_MAX_BYTES)
CLI_ENABLED = ENV.get("CLI_ENABLED", "1").strip().lower() in ("1", "true", "yes")
CLI_SHELL = ENV.get("CLI_SHELL", "/bin/bash")
CLI_DEFAULT_CWD = ENV.get("CLI_DEFAULT_CWD", os.path.expanduser("~"))
CLI_MAX_SESSIONS = int(ENV.get("CLI_MAX_SESSIONS", "5"))
app = FastAPI(title="Server UI", version="1.0.0")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
class PortUpdate(BaseModel):
port: int = Field(ge=1024, le=65535)
recreate: bool = True
class FileWriteBody(BaseModel):
path: str
content: str = ""
class FilePathBody(BaseModel):
path: str
class FileRenameBody(BaseModel):
old_path: str
new_path: str
class AuthVerifyBody(BaseModel):
api_key: str = ""
def get_stack(stack_id: str) -> StackConfig:
stack = STACK_BY_ID.get(stack_id)
if not stack:
raise HTTPException(status_code=404, detail=f"Unknown stack: {stack_id}")
return stack
def require_mutation_auth(request: Request) -> None:
if not API_KEY:
return
key = request.headers.get("X-API-Key", "")
if key != API_KEY:
raise HTTPException(status_code=401, detail="Invalid or missing API key")
def _is_lan_bind() -> bool:
return HOST.strip().lower() not in ("127.0.0.1", "localhost", "::1")
def require_gpu_fan_read_auth(request: Request) -> None:
if not _is_lan_bind() or not API_KEY:
return
require_mutation_auth(request)
def require_file_auth(request: Request) -> None:
if not _is_lan_bind() or not API_KEY:
return
require_mutation_auth(request)
def _cli_ws_auth_ok(websocket: WebSocket) -> bool:
if not _is_lan_bind() or not API_KEY:
return True
return websocket.query_params.get("api_key", "") == API_KEY
def _file_exc_to_http(exc: FileExplorerError) -> HTTPException:
msg = str(exc)
if "za duż" in msg.lower() or "za duża" in msg.lower():
return HTTPException(status_code=413, detail=msg)
if "poza dozwolonym" in msg.lower():
return HTTPException(status_code=403, detail=msg)
if "Brak uprawnień" in msg:
return HTTPException(status_code=403, detail=msg)
if "nie jest pusty" in msg.lower() or "już istnieje" in msg.lower():
return HTTPException(status_code=409, detail=msg)
if "Nie znaleziono" in msg:
return HTTPException(status_code=404, detail=msg)
if "nie jest katalogiem" in msg.lower() or "To jest katalog" in msg:
return HTTPException(status_code=400, detail=msg)
return HTTPException(status_code=400, detail=msg)
def check_gpu_policy(stack: StackConfig) -> None:
if not stack.gpu:
return
conflicts = find_running_gpu_stacks(STACKS, exclude_id=stack.id)
if conflicts:
names = ", ".join(conflicts)
raise HTTPException(
status_code=409,
detail=f"GPU conflict: stop running GPU stack(s) first: {names}",
)
@app.get("/")
def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
@app.get("/api/health")
def api_health() -> dict[str, bool]:
return {"ok": True}
@app.post("/api/auth/verify")
def api_auth_verify(body: AuthVerifyBody) -> dict[str, bool]:
if not API_KEY:
return {"ok": True}
if body.api_key == API_KEY:
return {"ok": True}
raise HTTPException(status_code=401, detail="Invalid or missing API key")
@app.get("/api/gpu")
def api_gpu() -> dict[str, Any]:
try:
return query_gpu()
except GpuInfoError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@app.get("/api/stacks")
def api_stacks() -> dict[str, Any]:
items = [get_container_state(s) for s in STACKS]
gpu_running = [i["id"] for i in items if i["gpu"] and i["running"]]
return {
"stacks": items,
"gpu_running": gpu_running,
"repo_root": str(REPO_ROOT),
}
@app.get("/api/stacks/{stack_id}/logs")
def api_logs(stack_id: str, tail: int = Query(default=100, ge=1, le=500)) -> dict[str, str]:
stack = get_stack(stack_id)
try:
text = get_logs(stack, tail=tail)
except ComposeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"stack_id": stack_id, "logs": text}
@app.post("/api/stacks/{stack_id}/start")
def api_start(stack_id: str, _: None = Depends(require_mutation_auth)) -> dict[str, Any]:
stack = get_stack(stack_id)
check_gpu_policy(stack)
try:
output = stack_start(stack)
except ComposeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"ok": True, "action": "start", "stack_id": stack_id, "output": output}
@app.post("/api/stacks/{stack_id}/stop")
def api_stop(stack_id: str, _: None = Depends(require_mutation_auth)) -> dict[str, Any]:
stack = get_stack(stack_id)
try:
output = stack_stop(stack)
except ComposeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"ok": True, "action": "stop", "stack_id": stack_id, "output": output}
@app.post("/api/stacks/{stack_id}/restart")
def api_restart(stack_id: str, _: None = Depends(require_mutation_auth)) -> dict[str, Any]:
stack = get_stack(stack_id)
if stack.gpu:
conflicts = find_running_gpu_stacks(STACKS, exclude_id=stack.id)
if conflicts:
check_gpu_policy(stack)
try:
output = stack_restart(stack)
except ComposeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"ok": True, "action": "restart", "stack_id": stack_id, "output": output}
@app.patch("/api/stacks/{stack_id}/port")
def api_set_port(
stack_id: str,
body: PortUpdate,
_: None = Depends(require_mutation_auth),
) -> dict[str, Any]:
stack = get_stack(stack_id)
if not stack.port_editable or not stack.port_env:
raise HTTPException(
status_code=400,
detail=f"Port is not editable for stack: {stack_id}",
)
try:
return set_stack_port(
stack,
body.port,
STACKS,
PORT,
recreate=body.recreate,
)
except PortConfigError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ComposeError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.get("/api/gpu-fan/health")
def api_gpu_fan_health(_: None = Depends(require_gpu_fan_read_auth)) -> dict[str, Any]:
return check_agent_health(GPU_FAN_AGENT_URL, API_KEY)
@app.api_route("/api/gpu-fan/{path:path}", methods=["GET", "PUT", "POST"])
async def api_gpu_fan_proxy(
path: str,
request: Request,
_: None = Depends(require_gpu_fan_read_auth),
) -> Response:
if request.method in ("PUT", "POST"):
require_mutation_auth(request)
body = await request.body()
try:
status, resp_headers, payload = forward_request(
GPU_FAN_AGENT_URL,
request.method,
path,
body=body if body else None,
agent_api_key=API_KEY,
content_type=request.headers.get("content-type", "application/json"),
)
except AgentProxyError as exc:
raise HTTPException(
status_code=502,
detail=f"GPU fan agent unavailable at {GPU_FAN_AGENT_URL}: {exc}",
) from exc
media_type = resp_headers.get("Content-Type", "application/json")
return Response(content=payload, status_code=status, media_type=media_type)
@app.get("/api/files")
def api_files_list(
path: str = Query(default="/"),
_: None = Depends(require_file_auth),
) -> dict[str, Any]:
try:
return file_explorer.list_directory(path)
except FileExplorerError as exc:
raise _file_exc_to_http(exc) from exc
@app.get("/api/files/read")
def api_files_read(
path: str = Query(...),
_: None = Depends(require_file_auth),
) -> dict[str, Any]:
try:
return file_explorer.read_file(path)
except FileExplorerError as exc:
raise _file_exc_to_http(exc) from exc
@app.put("/api/files/write")
def api_files_write(
body: FileWriteBody,
_: None = Depends(require_file_auth),
) -> dict[str, Any]:
target = file_explorer.resolve_path(body.path)
if target.exists() and target.is_file():
try:
info = file_explorer.read_file(body.path)
if info.get("binary"):
raise HTTPException(
status_code=415,
detail="Nie można zapisać pliku binarnego jako tekst",
)
except FileExplorerError as exc:
raise _file_exc_to_http(exc) from exc
try:
return file_explorer.write_file(body.path, body.content)
except FileExplorerError as exc:
raise _file_exc_to_http(exc) from exc
@app.post("/api/files/mkdir")
def api_files_mkdir(
body: FilePathBody,
_: None = Depends(require_file_auth),
) -> dict[str, Any]:
try:
return file_explorer.mkdir(body.path)
except FileExplorerError as exc:
raise _file_exc_to_http(exc) from exc
@app.post("/api/files/rename")
def api_files_rename(
body: FileRenameBody,
_: None = Depends(require_file_auth),
) -> dict[str, Any]:
try:
return file_explorer.rename_path(body.old_path, body.new_path)
except FileExplorerError as exc:
raise _file_exc_to_http(exc) from exc
@app.delete("/api/files")
def api_files_delete(
path: str = Query(...),
_: None = Depends(require_file_auth),
) -> dict[str, Any]:
try:
return file_explorer.delete_path(path)
except FileExplorerError as exc:
raise _file_exc_to_http(exc) from exc
@app.websocket("/api/cli/ws")
async def api_cli_ws(websocket: WebSocket) -> None:
if not _cli_ws_auth_ok(websocket):
await websocket.close(code=1008, reason="Invalid or missing API key")
return
if not CLI_ENABLED:
await websocket.close(code=1008, reason="CLI disabled")
return
if not sessions_available(CLI_MAX_SESSIONS):
await websocket.close(
code=1008,
reason=f"Limit sesji CLI ({CLI_MAX_SESSIONS}) — spróbuj później",
)
return
await websocket.accept()
try:
await run_pty_session(
websocket,
shell=CLI_SHELL,
cwd=CLI_DEFAULT_CWD,
env=os.environ.copy(),
max_sessions=CLI_MAX_SESSIONS,
)
except CliSessionLimitError:
await websocket.close(code=1008, reason="Session limit")
def main() -> None:
if HOST.strip() not in ("127.0.0.1", "localhost", "::1") and not API_KEY:
log.error(
"API_KEY is required when SERVER_UI_HOST=%s (LAN bind). Set API_KEY in .env",
HOST,
)
sys.exit(1)
if not STACKS:
log.error("No stacks loaded from %s", CONFIG_PATH)
sys.exit(1)
log.info("Repo root: %s", REPO_ROOT)
log.info("Stacks: %s", ", ".join(s.id for s in STACKS))
log.info("GPU fan agent: %s", GPU_FAN_AGENT_URL)
if API_KEY:
src = api_key_source(STACK_DIR, ENV)
log.info("API key: configured (source: %s)", src)
else:
log.warning("API_KEY not set (proxy may fail agent auth on LAN)")
if CLI_ENABLED:
log.info("CLI: enabled (shell=%s, cwd=%s, max_sessions=%d)", CLI_SHELL, CLI_DEFAULT_CWD, CLI_MAX_SESSIONS)
else:
log.info("CLI: disabled (CLI_ENABLED=0)")
log.info("Web UI at http://%s:%d", HOST if HOST != "0.0.0.0" else "0.0.0.0", PORT)
uvicorn.run(app, host=HOST, port=PORT, log_level="info")
if __name__ == "__main__":
main()
+174
View File
@@ -0,0 +1,174 @@
"""PTY shell sessions for browser CLI (WebSocket bridge)."""
from __future__ import annotations
import asyncio
import fcntl
import json
import logging
import os
import pty
import shlex
import signal
import struct
import termios
from typing import Any
from starlette.websockets import WebSocket, WebSocketDisconnect
log = logging.getLogger(__name__)
TIOCSWINSZ = termios.TIOCSWINSZ
_active_sessions = 0
_session_lock = asyncio.Lock()
class CliSessionLimitError(Exception):
pass
def _set_winsize(fd: int, rows: int, cols: int) -> None:
winsize = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(fd, TIOCSWINSZ, winsize)
def _parse_shell(shell: str) -> list[str]:
parts = shlex.split(shell.strip())
return parts if parts else ["/bin/bash"]
async def _read_pty_loop(
master_fd: int,
websocket: WebSocket,
running: asyncio.Event,
) -> None:
loop = asyncio.get_running_loop()
while running.is_set():
try:
data = await loop.run_in_executor(None, os.read, master_fd, 4096)
except OSError:
break
if not data:
break
try:
await websocket.send_bytes(data)
except Exception:
break
async def _kill_process(process: asyncio.subprocess.Process) -> None:
if process.returncode is not None:
return
try:
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
return
try:
await asyncio.wait_for(process.wait(), timeout=2.0)
except asyncio.TimeoutError:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
await process.wait()
def sessions_available(max_sessions: int) -> bool:
return _active_sessions < max_sessions
async def run_pty_session(
websocket: WebSocket,
*,
shell: str,
cwd: str,
env: dict[str, str],
max_sessions: int,
) -> None:
global _active_sessions
async with _session_lock:
if _active_sessions >= max_sessions:
raise CliSessionLimitError(
f"Limit sesji CLI ({max_sessions}) — spróbuj później"
)
_active_sessions += 1
master_fd: int | None = None
process: asyncio.subprocess.Process | None = None
read_task: asyncio.Task[None] | None = None
running = asyncio.Event()
running.set()
try:
master_fd, slave_fd = pty.openpty()
shell_cmd = _parse_shell(shell)
proc_env = {**env, "TERM": "xterm-256color"}
process = await asyncio.create_subprocess_exec(
*shell_cmd,
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
cwd=cwd,
env=proc_env,
preexec_fn=os.setsid,
)
os.close(slave_fd)
slave_fd = -1
read_task = asyncio.create_task(_read_pty_loop(master_fd, websocket, running))
while True:
message = await websocket.receive()
msg_type = message.get("type")
if msg_type == "websocket.disconnect":
break
payload: bytes | None = None
if message.get("bytes"):
payload = message["bytes"]
elif message.get("text"):
text = message["text"]
if text.startswith("{"):
try:
obj: dict[str, Any] = json.loads(text)
if obj.get("type") == "resize":
rows = int(obj.get("rows", 24))
cols = int(obj.get("cols", 80))
if master_fd is not None:
_set_winsize(master_fd, rows, cols)
continue
except (json.JSONDecodeError, TypeError, ValueError):
pass
payload = text.encode("utf-8")
if payload and master_fd is not None:
try:
os.write(master_fd, payload)
except OSError:
break
if process.returncode is not None:
break
except WebSocketDisconnect:
pass
finally:
running.clear()
if read_task is not None:
read_task.cancel()
try:
await read_task
except asyncio.CancelledError:
pass
if process is not None:
await _kill_process(process)
if master_fd is not None:
try:
os.close(master_fd)
except OSError:
pass
async with _session_lock:
_active_sessions = max(0, _active_sessions - 1)
+433
View File
@@ -0,0 +1,433 @@
"""Docker Compose helpers for whitelisted stacks."""
from __future__ import annotations
import json
import os
import re
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
RESERVED_PORTS = frozenset({80, 443, 8090, 18090})
PORT_MIN = 1024
PORT_MAX = 65535
class ComposeError(RuntimeError):
def __init__(self, message: str, returncode: int = 1, stderr: str = "") -> None:
super().__init__(message)
self.returncode = returncode
self.stderr = stderr
class PortConfigError(ValueError):
pass
@dataclass(frozen=True)
class StackConfig:
id: str
name: str
compose_dir: str
profile: str
container: str
ui_port: int | None
ui_scheme: str
gpu: bool
port_env: str | None = None
port_default: int | None = None
port_editable: bool = True
@property
def path(self) -> Path:
return Path(self.compose_dir)
@property
def env_path(self) -> Path:
return self.path / ".env"
def read_env_value(env_path: Path, key: str) -> str | None:
if not env_path.exists():
return None
prefix = f"{key}="
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if line.startswith(prefix):
val = line[len(prefix) :].strip()
if val.startswith('"') and val.endswith('"') and len(val) >= 2:
val = val[1:-1]
elif val.startswith("'") and val.endswith("'") and len(val) >= 2:
val = val[1:-1]
return val
return None
def write_env_value(env_path: Path, key: str, value: str) -> None:
env_path.parent.mkdir(parents=True, exist_ok=True)
prefix = f"{key}="
new_line = f"{key}={value}"
lines: list[str] = []
found = False
if env_path.exists():
for raw in env_path.read_text(encoding="utf-8").splitlines():
stripped = raw.strip()
if stripped and not stripped.startswith("#") and stripped.startswith(prefix):
lines.append(new_line)
found = True
else:
lines.append(raw)
if not found:
if lines and lines[-1].strip():
lines.append("")
lines.append(new_line)
fd, tmp_path = tempfile.mkstemp(
dir=env_path.parent,
prefix=".env.",
suffix=".tmp",
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write("\n".join(lines))
if lines:
fh.write("\n")
os.replace(tmp_path, env_path)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
def resolve_stack_port(stack: StackConfig) -> int | None:
if stack.port_env:
raw = read_env_value(stack.env_path, stack.port_env)
if raw and raw.isdigit():
return int(raw)
if stack.ui_port is not None:
return stack.ui_port
return stack.port_default
def collect_used_ports(
stacks: list[StackConfig],
server_ui_port: int,
exclude_stack_id: str | None = None,
) -> set[int]:
used = set(RESERVED_PORTS) | {server_ui_port}
for s in stacks:
if s.id == exclude_stack_id:
continue
port = resolve_stack_port(s)
if port is not None:
used.add(port)
return used
def validate_port(
port: int,
stack: StackConfig,
stacks: list[StackConfig],
server_ui_port: int,
) -> None:
if not PORT_MIN <= port <= PORT_MAX:
raise PortConfigError(f"Port must be between {PORT_MIN} and {PORT_MAX}")
if not stack.port_editable or not stack.port_env:
raise PortConfigError(f"Port is not editable for stack: {stack.id}")
conflicts = collect_used_ports(stacks, server_ui_port, exclude_stack_id=stack.id)
if port in conflicts:
raise PortConfigError(f"Port {port} is already in use or reserved")
def get_published_host_port(stack: StackConfig) -> int | None:
proc = subprocess.run(
[
"docker",
"port",
stack.container,
],
capture_output=True,
text=True,
timeout=15,
check=False,
)
if proc.returncode != 0 or not proc.stdout.strip():
return None
for line in proc.stdout.strip().splitlines():
# 0.0.0.0:8070/tcp or [::]:8070/tcp
m = re.search(r":(\d+)/", line)
if m:
return int(m.group(1))
return None
def load_stacks(config_path: Path, stacks_base: Path) -> list[StackConfig]:
import yaml # PyYAML optional — parse minimal yaml ourselves if missing
text = config_path.read_text(encoding="utf-8")
try:
data = yaml.safe_load(text)
except Exception:
data = _parse_stacks_yaml_simple(text)
items = data.get("stacks", []) if isinstance(data, dict) else []
stacks: list[StackConfig] = []
for item in items:
compose_rel = item["compose_dir"]
full = stacks_base / compose_rel
stacks.append(
StackConfig(
id=item["id"],
name=item["name"],
compose_dir=str(full),
profile=item["profile"],
container=item.get("container", item["id"]),
ui_port=item.get("ui_port"),
ui_scheme=item.get("ui_scheme") or "http",
gpu=bool(item.get("gpu", False)),
port_env=item.get("port_env"),
port_default=item.get("port_default"),
port_editable=bool(item.get("port_editable", item.get("port_env") is not None)),
)
)
return stacks
def _parse_stacks_yaml_simple(text: str) -> dict[str, Any]:
"""Minimal parser fallback when PyYAML is not installed."""
import re
stacks: list[dict[str, Any]] = []
current: dict[str, Any] | None = None
for line in text.splitlines():
if re.match(r"^\s*-\s+id:\s*", line):
if current:
stacks.append(current)
current = {"id": line.split(":", 1)[1].strip()}
elif current is not None and ":" in line:
key, _, val = line.strip().partition(":")
key = key.strip()
val = val.strip()
if val == "null":
val = None
elif val == "true":
val = True
elif val == "false":
val = False
elif val.isdigit():
val = int(val)
current[key] = val
if current:
stacks.append(current)
return {"stacks": stacks}
def _run_compose(
stack: StackConfig,
*args: str,
timeout: int = 120,
) -> subprocess.CompletedProcess[str]:
cmd = [
"docker",
"compose",
"--project-directory",
stack.compose_dir,
"--profile",
stack.profile,
*args,
]
return subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
def get_container_state(stack: StackConfig) -> dict[str, Any]:
proc = _run_compose(
stack,
"ps",
"--format",
"json",
timeout=30,
)
running = False
status = "stopped"
health = None
if proc.returncode == 0 and proc.stdout.strip():
for line in proc.stdout.strip().splitlines():
try:
row = json.loads(line)
except json.JSONDecodeError:
continue
name = row.get("Name", "")
if stack.container in name or name == stack.container:
state = row.get("State", "").lower()
health = row.get("Health")
if state == "running":
running = True
status = "running"
elif state:
status = state
break
else:
ps = subprocess.run(
[
"docker",
"ps",
"-a",
"--filter",
f"name=^{stack.container}$",
"--format",
"{{.State}}",
],
capture_output=True,
text=True,
timeout=15,
check=False,
)
if ps.stdout.strip():
status = ps.stdout.strip().lower()
running = status == "running"
ui_port = resolve_stack_port(stack)
published = get_published_host_port(stack) if running else None
port_pending_restart = bool(
running and ui_port is not None and published is not None and published != ui_port
)
return {
"id": stack.id,
"name": stack.name,
"profile": stack.profile,
"container": stack.container,
"ui_port": ui_port,
"ui_scheme": stack.ui_scheme,
"gpu": stack.gpu,
"running": running,
"status": status,
"health": health,
"compose_dir": stack.compose_dir,
"port_env": stack.port_env,
"port_editable": stack.port_editable and bool(stack.port_env),
"port_default": stack.port_default,
"published_port": published,
"port_pending_restart": port_pending_restart,
}
def get_logs(stack: StackConfig, tail: int = 100) -> str:
proc = _run_compose(
stack,
"logs",
"--tail",
str(tail),
"--no-color",
timeout=60,
)
if proc.returncode != 0:
raise ComposeError(
proc.stderr.strip() or "Failed to fetch logs",
proc.returncode,
proc.stderr,
)
return proc.stdout
def stack_start(stack: StackConfig) -> str:
proc = _run_compose(stack, "up", "-d", timeout=300)
if proc.returncode != 0:
raise ComposeError(
proc.stderr.strip() or proc.stdout.strip() or "start failed",
proc.returncode,
proc.stderr,
)
return (proc.stdout + proc.stderr).strip()
def stack_stop(stack: StackConfig) -> str:
proc = _run_compose(stack, "stop", timeout=120)
if proc.returncode != 0:
raise ComposeError(
proc.stderr.strip() or "stop failed",
proc.returncode,
proc.stderr,
)
return (proc.stdout + proc.stderr).strip()
def stack_restart(stack: StackConfig) -> str:
proc = _run_compose(stack, "restart", timeout=180)
if proc.returncode != 0:
raise ComposeError(
proc.stderr.strip() or "restart failed",
proc.returncode,
proc.stderr,
)
return (proc.stdout + proc.stderr).strip()
def stack_recreate(stack: StackConfig) -> str:
proc = _run_compose(stack, "up", "-d", "--force-recreate", timeout=300)
if proc.returncode != 0:
raise ComposeError(
proc.stderr.strip() or proc.stdout.strip() or "recreate failed",
proc.returncode,
proc.stderr,
)
return (proc.stdout + proc.stderr).strip()
def set_stack_port(
stack: StackConfig,
port: int,
stacks: list[StackConfig],
server_ui_port: int,
*,
recreate: bool = False,
) -> dict[str, Any]:
validate_port(port, stack, stacks, server_ui_port)
assert stack.port_env is not None
write_env_value(stack.env_path, stack.port_env, str(port))
state = get_container_state(stack)
recreated = False
requires_restart = state["running"]
if recreate and state["running"]:
stack_recreate(stack)
recreated = True
requires_restart = False
state = get_container_state(stack)
return {
"ok": True,
"stack_id": stack.id,
"port": port,
"port_env": stack.port_env,
"requires_restart": requires_restart and not recreated,
"recreated": recreated,
"running": state["running"],
}
def find_running_gpu_stacks(stacks: list[StackConfig], exclude_id: str | None = None) -> list[str]:
running: list[str] = []
for s in stacks:
if not s.gpu or s.id == exclude_id:
continue
state = get_container_state(s)
if state["running"]:
running.append(s.id)
return running
+28
View File
@@ -0,0 +1,28 @@
name: server-ui
services:
server-ui:
image: server-ui:local
build:
context: ..
dockerfile: server-ui/Dockerfile
container_name: server-ui
profiles:
- server-ui
restart: unless-stopped
ports:
- "${SERVER_UI_PORT:-8091}:8091"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${REPO_ROOT:?set REPO_ROOT in .env}:/repo:ro
env_file:
- ../control-plane/.env
environment:
REPO_ROOT: /repo
SERVER_UI_HOST: "0.0.0.0"
SERVER_UI_PORT: "8091"
GPU_FAN_AGENT_URL: ${GPU_FAN_AGENT_URL:-http://host.docker.internal:18090}
extra_hosts:
- "host.docker.internal:host-gateway"
group_add:
- ${DOCKER_GID:-999}
+248
View File
@@ -0,0 +1,248 @@
"""Server filesystem browser — list, read, write, CRUD."""
from __future__ import annotations
import base64
import os
import stat
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
class FileExplorerError(Exception):
"""Raised for filesystem operations; map to HTTP in app.py."""
def _posix_error(exc: OSError, action: str) -> FileExplorerError:
if exc.errno in (13, 1):
return FileExplorerError(f"Brak uprawnień: {action}")
if exc.errno == 2:
return FileExplorerError("Nie znaleziono pliku lub katalogu")
if exc.errno == 17:
return FileExplorerError("Plik lub katalog już istnieje")
if exc.errno == 39:
return FileExplorerError("Katalog nie jest pusty")
if exc.errno == 20:
return FileExplorerError("To nie jest katalog")
return FileExplorerError(f"{action}: {exc.strerror or exc}")
class FileExplorer:
def __init__(self, root: str = "/", max_bytes: int = 2_097_152) -> None:
self.root = Path(root).resolve()
self.max_bytes = max_bytes
def resolve_path(self, raw: str) -> Path:
raw = (raw or "/").strip()
if not raw:
raw = "/"
if raw.startswith("/"):
target = Path(raw).resolve()
else:
target = (self.root / raw).resolve()
if self.root == Path("/"):
return target
root_s = str(self.root)
target_s = str(target)
if target != self.root and not target_s.startswith(root_s + os.sep):
raise FileExplorerError(f"Ścieżka poza dozwolonym katalogiem: {self.root}")
return target
def _access_flags(self, path: Path) -> tuple[bool, bool]:
readable = os.access(path, os.R_OK)
writable = os.access(path, os.W_OK)
return readable, writable
def list_directory(self, raw_path: str) -> dict[str, Any]:
path = self.resolve_path(raw_path)
if not path.exists():
raise FileExplorerError("Nie znaleziono pliku lub katalogu")
if not path.is_dir():
raise FileExplorerError("To nie jest katalog")
readable, writable = self._access_flags(path)
if not readable:
raise FileExplorerError("Brak uprawnień: odczyt katalogu")
entries: list[dict[str, Any]] = []
try:
names = sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
except OSError as exc:
raise _posix_error(exc, "odczyt katalogu") from exc
for child in names:
try:
st = child.stat()
except OSError:
continue
is_dir = stat.S_ISDIR(st.st_mode)
c_readable, c_writable = self._access_flags(child)
entries.append(
{
"name": child.name,
"path": str(child),
"is_dir": is_dir,
"size": st.st_size if not is_dir else None,
"mtime": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
"readable": c_readable,
"writable": c_writable,
}
)
parent = str(path.parent) if path != self.root else None
return {
"path": str(path),
"parent": parent,
"readable": readable,
"writable": writable,
"entries": entries,
}
def read_file(self, raw_path: str) -> dict[str, Any]:
path = self.resolve_path(raw_path)
if not path.exists():
raise FileExplorerError("Nie znaleziono pliku lub katalogu")
if path.is_dir():
raise FileExplorerError("To jest katalog, nie plik")
readable, writable = self._access_flags(path)
if not readable:
raise FileExplorerError("Brak uprawnień: odczyt pliku")
try:
size = path.stat().st_size
except OSError as exc:
raise _posix_error(exc, "odczyt pliku") from exc
if size > self.max_bytes:
raise FileExplorerError(
f"Plik za duży ({size} B, limit {self.max_bytes} B)"
)
try:
data = path.read_bytes()
except OSError as exc:
raise _posix_error(exc, "odczyt pliku") from exc
if b"\x00" in data:
return {
"path": str(path),
"binary": True,
"size": size,
"content_base64": base64.b64encode(data).decode("ascii"),
"readable": readable,
"writable": writable,
}
try:
content = data.decode("utf-8")
except UnicodeDecodeError:
return {
"path": str(path),
"binary": True,
"size": size,
"content_base64": base64.b64encode(data).decode("ascii"),
"readable": readable,
"writable": writable,
}
return {
"path": str(path),
"binary": False,
"size": size,
"content": content,
"readable": readable,
"writable": writable,
}
def write_file(self, raw_path: str, content: str) -> dict[str, Any]:
path = self.resolve_path(raw_path)
encoded = content.encode("utf-8")
if len(encoded) > self.max_bytes:
raise FileExplorerError(
f"Zawartość za duża ({len(encoded)} B, limit {self.max_bytes} B)"
)
parent = path.parent
if not parent.exists():
raise FileExplorerError("Katalog nadrzędny nie istnieje")
if not os.access(parent, os.W_OK):
raise FileExplorerError("Brak uprawnień: zapis pliku")
if path.exists() and path.is_dir():
raise FileExplorerError("To jest katalog, nie plik")
if path.exists() and not os.access(path, os.W_OK):
raise FileExplorerError("Brak uprawnień: zapis pliku")
try:
path.write_text(content, encoding="utf-8")
except OSError as exc:
raise _posix_error(exc, "zapis pliku") from exc
return {"ok": True, "path": str(path), "size": len(encoded)}
def mkdir(self, raw_path: str) -> dict[str, Any]:
path = self.resolve_path(raw_path)
parent = path.parent
if not parent.exists():
raise FileExplorerError("Katalog nadrzędny nie istnieje")
if not os.access(parent, os.W_OK):
raise FileExplorerError("Brak uprawnień: tworzenie katalogu")
if path.exists():
raise FileExplorerError("Plik lub katalog już istnieje")
try:
path.mkdir(parents=False, exist_ok=False)
except OSError as exc:
raise _posix_error(exc, "tworzenie katalogu") from exc
return {"ok": True, "path": str(path)}
def delete_path(self, raw_path: str) -> dict[str, Any]:
path = self.resolve_path(raw_path)
if path == self.root:
raise FileExplorerError("Nie można usunąć katalogu głównego")
if not path.exists():
raise FileExplorerError("Nie znaleziono pliku lub katalogu")
if not os.access(path, os.W_OK):
raise FileExplorerError("Brak uprawnień: usuwanie")
if path.is_dir():
try:
if any(path.iterdir()):
raise FileExplorerError("Katalog nie jest pusty")
except OSError as exc:
raise _posix_error(exc, "odczyt katalogu") from exc
try:
if path.is_dir():
path.rmdir()
else:
path.unlink()
except OSError as exc:
raise _posix_error(exc, "usuwanie") from exc
return {"ok": True, "path": str(path)}
def rename_path(self, raw_from: str, raw_to: str) -> dict[str, Any]:
src = self.resolve_path(raw_from)
dst = self.resolve_path(raw_to)
if not src.exists():
raise FileExplorerError("Nie znaleziono pliku lub katalogu")
if dst.exists():
raise FileExplorerError("Docelowa ścieżka już istnieje")
if not os.access(src, os.W_OK):
raise FileExplorerError("Brak uprawnień: zmiana nazwy")
parent = dst.parent
if not parent.exists() or not os.access(parent, os.W_OK):
raise FileExplorerError("Brak uprawnień: zapis w katalogu docelowym")
try:
src.rename(dst)
except OSError as exc:
raise _posix_error(exc, "zmiana nazwy") from exc
return {"ok": True, "from": str(src), "to": str(dst)}
+75
View File
@@ -0,0 +1,75 @@
"""Proxy requests to the gpu-fan host agent."""
from __future__ import annotations
import json
import logging
import urllib.error
import urllib.request
from typing import Any
log = logging.getLogger(__name__)
class AgentProxyError(Exception):
"""Raised when the gpu-fan agent request fails."""
def check_agent_health(agent_url: str, agent_api_key: str) -> dict[str, Any]:
"""Return health info for the gpu-fan agent."""
try:
forward_request(agent_url, "GET", "status", agent_api_key=agent_api_key)
return {"ok": True, "agent_url": agent_url}
except AgentProxyError as exc:
return {"ok": False, "agent_url": agent_url, "error": str(exc)}
def resolve_agent_url(configured_url: str, agent_api_key: str) -> str:
"""Pick first reachable agent URL (18090 preferred, 8090 legacy fallback)."""
candidates: list[str] = []
if configured_url:
candidates.append(configured_url.rstrip("/"))
for url in ("http://127.0.0.1:18090", "http://127.0.0.1:8090"):
if url not in candidates:
candidates.append(url)
for url in candidates:
if check_agent_health(url, agent_api_key).get("ok"):
log.info("GPU fan agent reachable at %s", url)
return url
return candidates[0] if candidates else "http://127.0.0.1:18090"
def forward_request(
agent_url: str,
method: str,
path: str,
*,
body: bytes | None = None,
agent_api_key: str,
content_type: str = "application/json",
) -> tuple[int, dict[str, str], bytes]:
"""Forward a request to the gpu-fan agent API."""
path = path.lstrip("/")
url = f"{agent_url.rstrip('/')}/api/{path}"
headers = {"Accept": "application/json"}
if agent_api_key:
headers["X-API-Key"] = agent_api_key
if body is not None:
headers["Content-Type"] = content_type
req = urllib.request.Request(url, data=body, headers=headers, method=method.upper())
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.status, dict(resp.headers), resp.read()
except urllib.error.HTTPError as exc:
payload = exc.read()
detail = payload.decode("utf-8", errors="replace")
try:
parsed = json.loads(detail)
if isinstance(parsed, dict) and "detail" in parsed:
detail = str(parsed["detail"])
except json.JSONDecodeError:
pass
raise AgentProxyError(detail or f"Agent HTTP {exc.code}") from exc
except urllib.error.URLError as exc:
raise AgentProxyError(f"Agent unreachable at {agent_url}: {exc.reason}") from exc
+69
View File
@@ -0,0 +1,69 @@
"""Query GPU stats via nvidia-smi."""
from __future__ import annotations
import json
import shutil
import subprocess
from typing import Any
class GpuInfoError(RuntimeError):
pass
def query_gpu() -> dict[str, Any]:
if not shutil.which("nvidia-smi"):
return {
"available": False,
"error": "nvidia-smi not found",
"gpus": [],
}
try:
result = subprocess.run(
[
"nvidia-smi",
"--query-gpu=index,name,memory.used,memory.total,temperature.gpu,utilization.gpu",
"--format=csv,noheader,nounits",
],
capture_output=True,
text=True,
timeout=15,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise GpuInfoError("nvidia-smi timed out") from exc
if result.returncode != 0:
return {
"available": False,
"error": (result.stderr or result.stdout or "nvidia-smi failed").strip(),
"gpus": [],
}
gpus: list[dict[str, Any]] = []
for line in result.stdout.strip().splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) < 6:
continue
idx, name, mem_used, mem_total, temp, util = parts[:6]
try:
used_mb = int(float(mem_used))
total_mb = int(float(mem_total))
except ValueError:
used_mb = 0
total_mb = 0
gpus.append(
{
"index": int(idx),
"name": name,
"memory_used_mb": used_mb,
"memory_total_mb": total_mb,
"memory_used_pct": round(100 * used_mb / total_mb, 1) if total_mb else 0,
"temperature_c": int(float(temp)) if temp not in ("N/A", "") else None,
"utilization_pct": int(float(util)) if util not in ("N/A", "") else None,
}
)
return {"available": True, "error": None, "gpus": gpus}
+4
View File
@@ -0,0 +1,4 @@
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
pydantic>=2.0.0
PyYAML>=6.0
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# One-shot deploy: gpu-fan agent :18090 + server-ui proxy. Run: sudo scripts/deploy-gpu-fan-fix.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACKS_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo ${SCRIPT_DIR}/deploy-gpu-fan-fix.sh"
exit 1
fi
echo "=== GPU Fan integration deploy ==="
pkill -f "stacks/server-ui/.venv/bin/python app.py" 2>/dev/null || true
pkill -f "SERVER_UI_PORT=18091" 2>/dev/null || true
pkill -f "SERVER_UI_PORT=8092" 2>/dev/null || true
"${STACKS_DIR}/gpu-fan/scripts/install.sh"
"${SCRIPT_DIR}/install.sh"
echo ""
echo "=== Verification ==="
UI_KEY="$(grep '^API_KEY=' /opt/control-plane/.env | cut -d= -f2-)"
curl -sf "http://127.0.0.1:8091/api/gpu-fan/health" -H "X-API-Key: ${UI_KEY}" | head -c 200
echo ""
echo ""
echo "Panel: http://$(hostname -I | awk '{print $1}'):8091/#gpu-fan"
echo "API key: grep ^API_KEY= /opt/control-plane/.env"
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env bash
# Unified installer: gpu-fan (native only) + Server UI (native or Docker).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
GPU_FAN_DIR="$(cd "${STACK_DIR}/../gpu-fan" && pwd)"
GPU_FAN_CHOICE=""
SERVER_UI_CHOICE=""
NON_INTERACTIVE=false
usage() {
cat <<'EOF'
Usage: install-control-plane.sh [options]
Options:
--gpu-fan=yes|no|skip Install gpu-fan host agent (native only; no Docker)
--server-ui=native|docker|skip
-y, --non-interactive Use defaults: gpu-fan=yes, server-ui=native
-h, --help
Recommended: run without flags for interactive menu.
gpu-fan cannot run in Docker (NVML requires root on host). See ADR-001.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--gpu-fan=*) GPU_FAN_CHOICE="${1#*=}"; NON_INTERACTIVE=true; shift ;;
--server-ui=*) SERVER_UI_CHOICE="${1#*=}"; NON_INTERACTIVE=true; shift ;;
-y|--non-interactive) NON_INTERACTIVE=true; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1"; usage; exit 1 ;;
esac
done
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo ${SCRIPT_DIR}/install-control-plane.sh"
exit 1
fi
prompt_gpu_fan() {
if [[ -n "${GPU_FAN_CHOICE}" ]]; then
return
fi
echo ""
echo "=== 1) gpu-fan (agent NVML na hoście) ==="
echo " Sterowanie wentylatorami GPU wymaga root + NVML na hoście."
echo " Docker NIE jest obsługiwany (ADR-001)."
echo ""
read -r -p "Zainstalować gpu-fan native? [Y/n]: " ans
ans="${ans:-Y}"
case "${ans^^}" in
Y|YES|TAK) GPU_FAN_CHOICE="yes" ;;
*) GPU_FAN_CHOICE="no" ;;
esac
}
prompt_server_ui() {
if [[ -n "${SERVER_UI_CHOICE}" ]]; then
return
fi
echo ""
echo "=== 2) Server UI (panel stacków Docker + GPU Fan) ==="
echo " [1] Native — systemd na hoście (zalecane)"
echo " [2] Docker — kontener + docker.sock"
echo " [3] Pomiń"
echo ""
read -r -p "Wybór [1]: " ans
ans="${ans:-1}"
case "${ans}" in
1) SERVER_UI_CHOICE="native" ;;
2) SERVER_UI_CHOICE="docker" ;;
3) SERVER_UI_CHOICE="skip" ;;
*) echo "Nieprawidłowy wybór"; exit 1 ;;
esac
}
if [[ "${NON_INTERACTIVE}" == true && -z "${GPU_FAN_CHOICE}" ]]; then
GPU_FAN_CHOICE="yes"
fi
if [[ "${NON_INTERACTIVE}" == true && -z "${SERVER_UI_CHOICE}" ]]; then
SERVER_UI_CHOICE="native"
fi
echo "=== GMKtec Control Plane — instalacja ==="
bash "${SCRIPT_DIR}/setup-control-plane-env.sh"
prompt_gpu_fan
prompt_server_ui
echo ""
echo "Plan:"
echo " gpu-fan: ${GPU_FAN_CHOICE}"
echo " server-ui: ${SERVER_UI_CHOICE}"
echo ""
case "${GPU_FAN_CHOICE,,}" in
yes|y|true|1)
if [[ ! -x "${GPU_FAN_DIR}/scripts/install.sh" ]]; then
echo "ERROR: ${GPU_FAN_DIR}/scripts/install.sh not found"
exit 1
fi
echo "--- Installing gpu-fan (native) ---"
bash "${GPU_FAN_DIR}/scripts/install.sh"
;;
skip|no|n|false|0)
echo "Skipping gpu-fan."
;;
*)
echo "ERROR: invalid --gpu-fan value: ${GPU_FAN_CHOICE}"
exit 1
;;
esac
case "${SERVER_UI_CHOICE,,}" in
native|systemd|1)
echo "--- Installing Server UI (native / systemd) ---"
# Stop Docker deployment if running
if [[ -f "${STACK_DIR}/docker-compose.yml" ]]; then
(cd "${STACK_DIR}" && docker compose --profile server-ui down 2>/dev/null) || true
fi
bash "${SCRIPT_DIR}/install.sh"
;;
docker|container|2)
echo "--- Installing Server UI (Docker) ---"
bash "${SCRIPT_DIR}/install-docker.sh"
;;
skip|no|3)
echo "Skipping Server UI."
;;
*)
echo "ERROR: invalid --server-ui value: ${SERVER_UI_CHOICE}"
exit 1
;;
esac
LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
UI_PORT=8091
echo ""
echo "=============================================="
echo " Instalacja zakończona"
echo "=============================================="
echo ""
echo "Server UI i gpu-fan NIE pojawią się w 'docker ps' gdy działają jako systemd."
echo "W docker ps widać tylko workloady (ComfyUI, LocalAI, NPMPlus, …)."
echo ""
echo "Sprawdzenie:"
echo " systemctl status gpu-fan server-ui # native"
echo " docker compose --profile server-ui ps # docker"
echo " ss -tln | grep -E '8091|18090'"
echo ""
if [[ "${SERVER_UI_CHOICE,,}" != "skip" ]]; then
# shellcheck source=print-api-key-instructions.sh
source "${SCRIPT_DIR}/print-api-key-instructions.sh"
print_api_key_instructions "/opt/control-plane/.env" "${UI_PORT}"
fi
echo "Tutorial: manual-tutorial/08-server-ui-install.md"
echo "Klucz API (szczegóły): manual-tutorial/04a-api-key.md"
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Install Server UI as Docker container (profile server-ui).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
CONTROL_PLANE_STACK="$(cd "${STACK_DIR}/../control-plane" && pwd)"
SERVICE_NAME="server-ui.service"
UI_PORT="${SERVER_UI_PORT:-8091}"
ENV_FILE="${CONTROL_PLANE_STACK}/.env"
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo ${SCRIPT_DIR}/install-docker.sh"
exit 1
fi
set_env_var() {
local file="$1" key="$2" val="$3"
if grep -q "^${key}=" "${file}"; then
sed -i "s|^${key}=.*|${key}=${val}|" "${file}"
else
echo "${key}=${val}" >> "${file}"
fi
}
detect_repo_root() {
if [[ -n "${REPO_ROOT:-}" ]]; then
echo "${REPO_ROOT}"
return
fi
local candidate
candidate="$(cd "${STACK_DIR}/../.." && pwd)"
if [[ -d "${candidate}/stacks/localai" ]]; then
echo "${candidate}"
return
fi
echo "${candidate}"
}
echo "=== Server UI — install (Docker) ==="
if ! command -v docker >/dev/null 2>&1; then
echo "ERROR: docker not found. Install Docker CE first."
exit 1
fi
bash "${SCRIPT_DIR}/setup-control-plane-env.sh"
REPO_ROOT_VAL="$(detect_repo_root)"
DOCKER_GID="$(getent group docker | cut -d: -f3 || echo 999)"
if [[ ! -f "${ENV_FILE}" ]]; then
cp "${CONTROL_PLANE_STACK}/.env.example" "${ENV_FILE}"
fi
set_env_var "${ENV_FILE}" "REPO_ROOT" "${REPO_ROOT_VAL}"
set_env_var "${ENV_FILE}" "SERVER_UI_HOST" "0.0.0.0"
set_env_var "${ENV_FILE}" "SERVER_UI_PORT" "${UI_PORT}"
set_env_var "${ENV_FILE}" "GPU_FAN_AGENT_URL" "http://host.docker.internal:18090"
set_env_var "${ENV_FILE}" "DOCKER_GID" "${DOCKER_GID}"
# Disable native systemd if present
if systemctl is-enabled "${SERVICE_NAME}" &>/dev/null; then
echo "Disabling native ${SERVICE_NAME}..."
systemctl disable --now "${SERVICE_NAME}" 2>/dev/null || true
fi
cd "${STACK_DIR}"
export REPO_ROOT="${REPO_ROOT_VAL}"
export DOCKER_GID
echo "Building server-ui image..."
docker compose --profile server-ui build
echo "Starting server-ui container..."
docker compose --profile server-ui up -d
sleep 2
LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
API_KEY_VAL="$(grep '^API_KEY=' "${ENV_FILE}" | cut -d= -f2- || true)"
echo ""
echo "Runtime: Docker (profile server-ui)"
echo "Repo mount: ${REPO_ROOT_VAL} → /repo"
echo "Env: ${ENV_FILE}"
echo "Container: $(docker compose --profile server-ui ps --format '{{.Name}} {{.Status}}' 2>/dev/null || docker ps --filter name=server-ui --format '{{.Names}} {{.Status}}')"
echo ""
echo "Web UI (LAN):"
echo " http://${LAN_IP:-<server-ip>}:${UI_PORT}/"
echo ""
echo "API key:"
echo " grep ^API_KEY= ${ENV_FILE}"
echo ""
if [[ -n "${API_KEY_VAL}" ]]; then
HEALTH="$(curl -sf "http://127.0.0.1:${UI_PORT}/api/health" 2>/dev/null || echo '{"ok":false}')"
echo "Health: ${HEALTH}"
GF_HEALTH="$(curl -sf "http://127.0.0.1:${UI_PORT}/api/gpu-fan/health" -H "X-API-Key: ${API_KEY_VAL}" 2>/dev/null || echo '{"ok":false}')"
echo "GPU Fan proxy: ${GF_HEALTH}"
fi
echo ""
echo "Logs:"
echo " docker compose --profile server-ui logs -f"
echo "Stop:"
echo " docker compose --profile server-ui down"
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
INSTALL_DIR="/opt/server-ui"
SERVICE_NAME="server-ui.service"
UI_PORT=8091
CONTROL_PLANE_ENV="/opt/control-plane/.env"
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo ${SCRIPT_DIR}/install.sh"
exit 1
fi
free_ui_port() {
pkill -f "stacks/server-ui/.venv/bin/python app.py" 2>/dev/null || true
pkill -f "SERVER_UI_PORT=18091" 2>/dev/null || true
local pids
pids="$(ss -tlnp "sport = :${UI_PORT}" 2>/dev/null | grep -oP 'pid=\K[0-9]+' | sort -u || true)"
for pid in ${pids}; do
local cmd
cmd="$(ps -p "${pid}" -o cmd= 2>/dev/null || true)"
if [[ "${cmd}" == *"stacks/server-ui"* ]]; then
echo "Stopping stale dev server-ui on :${UI_PORT} (pid ${pid})"
kill "${pid}" 2>/dev/null || true
fi
done
sleep 1
}
# Disable Docker deployment if switching to native
if [[ -f "${STACK_DIR}/docker-compose.yml" ]]; then
(cd "${STACK_DIR}" && docker compose --profile server-ui down 2>/dev/null) || true
fi
echo "=== Server UI — install (native / systemd) ==="
bash "${SCRIPT_DIR}/setup-control-plane-env.sh"
apt-get update -qq
apt-get install -y python3-venv python3-pip
mkdir -p "${INSTALL_DIR}"
rsync -a --delete \
--exclude '.venv' \
--exclude '.env' \
--exclude '__pycache__' \
"${STACK_DIR}/" "${INSTALL_DIR}/"
python3 -m venv "${INSTALL_DIR}/.venv"
"${INSTALL_DIR}/.venv/bin/pip" install --upgrade pip -q
"${INSTALL_DIR}/.venv/bin/pip" install -r "${INSTALL_DIR}/requirements.txt" -q
install -m 644 "${INSTALL_DIR}/server-ui.service" "/etc/systemd/system/${SERVICE_NAME}"
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}"
free_ui_port
systemctl restart "${SERVICE_NAME}"
sleep 2
LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
API_KEY_VAL="$(grep '^API_KEY=' "${CONTROL_PLANE_ENV}" | cut -d= -f2- || true)"
echo ""
echo "Installed to ${INSTALL_DIR}"
echo "Service: $(systemctl is-active "${SERVICE_NAME}" 2>/dev/null || echo unknown)"
echo "Env: ${CONTROL_PLANE_ENV}"
# shellcheck source=print-api-key-instructions.sh
source "${SCRIPT_DIR}/print-api-key-instructions.sh"
print_api_key_instructions "${CONTROL_PLANE_ENV}" "${UI_PORT}"
if [[ -n "${API_KEY_VAL}" ]]; then
HEALTH="$(curl -sf "http://127.0.0.1:${UI_PORT}/api/gpu-fan/health" -H "X-API-Key: ${API_KEY_VAL}" 2>/dev/null || echo '{"ok":false}')"
echo "GPU Fan proxy health: ${HEALTH}"
if ! echo "${HEALTH}" | grep -q '"ok":true'; then
echo "WARN: gpu-fan agent may be down — run: sudo ${STACK_DIR%/server-ui}/gpu-fan/scripts/install.sh"
fi
fi
echo ""
echo "Logs:"
echo " journalctl -u ${SERVICE_NAME} -f"
echo ""
echo "Unified installer (gpu-fan + server-ui):"
echo " sudo ${SCRIPT_DIR}/install-control-plane.sh"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Print API key + browser instructions (shared by install scripts and show-api-key.sh).
set -euo pipefail
print_api_key_instructions() {
local env_file="${1:-/opt/control-plane/.env}"
local ui_port="${2:-8091}"
local api_key=""
local lan_ip=""
if [[ -r "${env_file}" ]]; then
api_key="$(grep '^API_KEY=' "${env_file}" 2>/dev/null | cut -d= -f2- || true)"
elif [[ -f "${env_file}" ]]; then
api_key="$(sudo grep '^API_KEY=' "${env_file}" 2>/dev/null | cut -d= -f2- || true)"
fi
lan_ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
[[ -n "${lan_ip}" ]] || lan_ip="<IP-serwera>"
echo ""
echo "══════════════════════════════════════════════"
echo " SERVER UI — KLUCZ API"
echo "══════════════════════════════════════════════"
echo ""
if [[ -z "${api_key}" ]]; then
echo "Brak API_KEY w ${env_file}"
echo "Uruchom: sudo bash stacks/server-ui/scripts/setup-control-plane-env.sh"
echo ""
return 1
fi
echo "1. Twój klucz API (skopiuj):"
echo ""
echo " ${api_key}"
echo ""
echo "2. Otwórz panel (klucz zapisze się w przeglądarce):"
echo ""
echo " http://${lan_ip}:${ui_port}/?api_key=${api_key}"
echo ""
echo "3. Albo ręcznie:"
echo " a) Wejdź na http://${lan_ip}:${ui_port}/"
echo " b) Wklej klucz w pole „API Key”"
echo " c) Kliknij „Zapisz”"
echo " d) Kliknij „Sprawdź klucz” (powinno być: Klucz poprawny)"
echo " e) Dopiero potem Start/Stop, CLI, Pliki, GPU Fan"
echo ""
echo "Plik klucza (na przyszłość):"
echo " sudo grep ^API_KEY= ${env_file}"
echo ""
echo "══════════════════════════════════════════════"
echo ""
}
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
print_api_key_instructions "${1:-/opt/control-plane/.env}" "${2:-8091}"
fi
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Restart gpu-fan + server-ui after code sync. Requires: sudo scripts/restart-stack.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ "${EUID}" -ne 0 ]]; then
echo "Run: sudo ${SCRIPT_DIR}/restart-stack.sh"
exit 1
fi
systemctl restart gpu-fan
sleep 2
systemctl restart server-ui
sleep 2
echo "gpu-fan: $(systemctl is-active gpu-fan)"
echo "server-ui: $(systemctl is-active server-ui)"
ss -tlnp | grep -E '18090|8091' || true
UI_KEY="$(grep '^API_KEY=' /opt/control-plane/.env | cut -d= -f2-)"
curl -sf "http://127.0.0.1:8091/api/gpu-fan/health" -H "X-API-Key: ${UI_KEY}" || echo "health check failed"
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env bash
# Create /opt/control-plane/.env and migrate from legacy per-service .env files.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
REPO_ROOT="$(cd "${STACK_DIR}/../.." && pwd)"
CONTROL_PLANE_DIR="/opt/control-plane"
ENV_FILE="${CONTROL_PLANE_DIR}/.env"
EXAMPLE="${REPO_ROOT}/stacks/control-plane/.env.example"
LEGACY_SUI="/opt/server-ui/.env"
LEGACY_GF="/opt/gpu-fan/.env"
LEGACY_REPO_SUI="${REPO_ROOT}/stacks/server-ui/.env"
DEV_ENV="${REPO_ROOT}/stacks/control-plane/.env"
TIMESTAMP="$(date +%Y%m%d%H%M%S)"
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo ${SCRIPT_DIR}/setup-control-plane-env.sh"
exit 1
fi
set_env_var() {
local file="$1" key="$2" val="$3"
if grep -q "^${key}=" "${file}"; then
sed -i "s|^${key}=.*|${key}=${val}|" "${file}"
else
echo "${key}=${val}" >> "${file}"
fi
}
get_env_var() {
local file="$1" key="$2"
grep "^${key}=" "${file}" 2>/dev/null | cut -d= -f2- || true
}
remove_deprecated_keys() {
local file="$1"
[[ -f "${file}" ]] || return 0
cp -a "${file}" "${file}.bak.${TIMESTAMP}"
grep -v -E '^(API_KEY|GPU_FAN_AGENT_KEY)=' "${file}" > "${file}.tmp.${TIMESTAMP}" || true
{
echo "# API_KEY: use /opt/control-plane/.env (prod) or stacks/control-plane/.env (dev)"
echo "# Run: sudo bash stacks/server-ui/scripts/setup-control-plane-env.sh"
cat "${file}.tmp.${TIMESTAMP}"
} > "${file}"
rm -f "${file}.tmp.${TIMESTAMP}"
echo "Removed API_KEY / GPU_FAN_AGENT_KEY from ${file} (backup: ${file}.bak.${TIMESTAMP})"
}
sync_dev_env_from_prod() {
local prod_key repo_root_val
prod_key="$(get_env_var "${ENV_FILE}" API_KEY)"
repo_root_val="$(get_env_var "${ENV_FILE}" REPO_ROOT)"
[[ -n "${repo_root_val}" ]] || repo_root_val="${REPO_ROOT}"
if [[ ! -f "${DEV_ENV}" && -f "${EXAMPLE}" ]]; then
cp "${EXAMPLE}" "${DEV_ENV}"
echo "Created dev env: ${DEV_ENV}"
fi
if [[ ! -f "${DEV_ENV}" ]]; then
echo "WARN: dev env missing: ${DEV_ENV}"
return 0
fi
if [[ -n "${prod_key}" ]]; then
set_env_var "${DEV_ENV}" "API_KEY" "${prod_key}"
fi
set_env_var "${DEV_ENV}" "REPO_ROOT" "${repo_root_val}"
local ui_port
ui_port="$(get_env_var "${ENV_FILE}" SERVER_UI_PORT)"
[[ -n "${ui_port}" ]] && set_env_var "${DEV_ENV}" "SERVER_UI_PORT" "${ui_port}"
echo "Synced dev env: ${DEV_ENV} (API_KEY matches ${ENV_FILE})"
}
merge_env_file() {
local src="$1"
[[ -f "${src}" ]] || return 0
while IFS= read -r line || [[ -n "${line}" ]]; do
line="${line%%#*}"
line="$(echo "${line}" | xargs)"
[[ -n "${line}" && "${line}" == *=* ]] || continue
local key="${line%%=*}"
local val="${line#*=}"
key="$(echo "${key}" | xargs)"
[[ -n "${val}" ]] || continue
# Skip deprecated duplicate key
[[ "${key}" == "GPU_FAN_AGENT_KEY" ]] && continue
set_env_var "${ENV_FILE}" "${key}" "${val}"
done < "${src}"
}
echo "=== Control plane env setup ==="
mkdir -p "${CONTROL_PLANE_DIR}"
# Deploy env_loader for production Python imports
if [[ -f "${REPO_ROOT}/stacks/control-plane/env_loader.py" ]]; then
install -m 644 "${REPO_ROOT}/stacks/control-plane/env_loader.py" "${CONTROL_PLANE_DIR}/env_loader.py"
fi
if [[ ! -f "${ENV_FILE}" ]]; then
if [[ -f "${EXAMPLE}" ]]; then
cp "${EXAMPLE}" "${ENV_FILE}"
echo "Created ${ENV_FILE} from example"
else
touch "${ENV_FILE}"
echo "Created empty ${ENV_FILE}"
fi
fi
# Migrate legacy files (non-destructive: backup first)
SUI_KEY=""
GF_KEY=""
[[ -f "${LEGACY_SUI}" ]] && SUI_KEY="$(get_env_var "${LEGACY_SUI}" API_KEY)"
[[ -f "${LEGACY_GF}" ]] && GF_KEY="$(get_env_var "${LEGACY_GF}" API_KEY)"
for legacy in "${LEGACY_SUI}" "${LEGACY_GF}"; do
if [[ -f "${legacy}" ]]; then
cp -a "${legacy}" "${legacy}.bak.${TIMESTAMP}"
echo "Backed up ${legacy}${legacy}.bak.${TIMESTAMP}"
merge_env_file "${legacy}"
fi
done
if [[ -n "${SUI_KEY}" && -n "${GF_KEY}" && "${SUI_KEY}" != "${GF_KEY}" ]]; then
echo "WARN: API_KEY differed in legacy files — merged value: $(get_env_var "${ENV_FILE}" API_KEY)"
echo " After install, use one key everywhere: grep ^API_KEY= ${ENV_FILE}"
fi
# Defaults
set_env_var "${ENV_FILE}" "GPU_FAN_AGENT_URL" "$(get_env_var "${ENV_FILE}" GPU_FAN_AGENT_URL || echo http://127.0.0.1:18090)"
[[ -n "$(get_env_var "${ENV_FILE}" GPU_FAN_AGENT_URL)" ]] || set_env_var "${ENV_FILE}" "GPU_FAN_AGENT_URL" "http://127.0.0.1:18090"
set_env_var "${ENV_FILE}" "GPU_FAN_API_HOST" "$(get_env_var "${ENV_FILE}" GPU_FAN_API_HOST || echo 127.0.0.1)"
[[ -n "$(get_env_var "${ENV_FILE}" GPU_FAN_API_HOST)" ]] || set_env_var "${ENV_FILE}" "GPU_FAN_API_HOST" "127.0.0.1"
set_env_var "${ENV_FILE}" "GPU_FAN_API_PORT" "$(get_env_var "${ENV_FILE}" GPU_FAN_API_PORT || echo 18090)"
[[ -n "$(get_env_var "${ENV_FILE}" GPU_FAN_API_PORT)" ]] || set_env_var "${ENV_FILE}" "GPU_FAN_API_PORT" "18090"
set_env_var "${ENV_FILE}" "CURVE_PATH" "$(get_env_var "${ENV_FILE}" CURVE_PATH || echo /etc/gpu-fan/curve.json)"
[[ -n "$(get_env_var "${ENV_FILE}" CURVE_PATH)" ]] || set_env_var "${ENV_FILE}" "CURVE_PATH" "/etc/gpu-fan/curve.json"
# REPO_ROOT default
if [[ -z "$(get_env_var "${ENV_FILE}" REPO_ROOT)" ]]; then
set_env_var "${ENV_FILE}" "REPO_ROOT" "${REPO_ROOT}"
fi
# Generate API_KEY if missing or placeholder
API_KEY_VAL="$(get_env_var "${ENV_FILE}" API_KEY)"
if [[ -z "${API_KEY_VAL}" || "${API_KEY_VAL}" == change-me* ]]; then
KEY="$(openssl rand -hex 16)"
set_env_var "${ENV_FILE}" "API_KEY" "${KEY}"
echo "Generated API_KEY in ${ENV_FILE}"
fi
chmod 600 "${ENV_FILE}"
# Always sync dev copy to production key
sync_dev_env_from_prod
# Remove duplicate API keys from legacy per-service env files
for legacy in "${LEGACY_SUI}" "${LEGACY_GF}" "${LEGACY_REPO_SUI}"; do
if [[ -f "${legacy}" ]] && grep -qE '^(API_KEY|GPU_FAN_AGENT_KEY)=' "${legacy}" 2>/dev/null; then
remove_deprecated_keys "${legacy}"
fi
done
echo ""
echo "Control plane env: ${ENV_FILE}"
echo "Dev copy: ${DEV_ENV}"
echo ""
# shellcheck source=print-api-key-instructions.sh
source "${SCRIPT_DIR}/print-api-key-instructions.sh"
print_api_key_instructions "${ENV_FILE}" "$(get_env_var "${ENV_FILE}" SERVER_UI_PORT || echo 8091)"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Show API key and step-by-step browser instructions for Server UI.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
REPO_ROOT="$(cd "${STACK_DIR}/../.." && pwd)"
PROD_ENV="/opt/control-plane/.env"
DEV_ENV="${REPO_ROOT}/stacks/control-plane/.env"
UI_PORT=8091
if [[ -f "${DEV_ENV}" ]]; then
UI_PORT="$(grep '^SERVER_UI_PORT=' "${DEV_ENV}" 2>/dev/null | cut -d= -f2- || echo 8091)"
fi
# Prefer dev copy when readable (same key after sync); else production.
ENV_FILE="${DEV_ENV}"
if [[ ! -r "${ENV_FILE}" ]]; then
ENV_FILE="${PROD_ENV}"
fi
# shellcheck source=print-api-key-instructions.sh
source "${SCRIPT_DIR}/print-api-key-instructions.sh"
print_api_key_instructions "${ENV_FILE}" "${UI_PORT}"
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
CONTROL_PLANE_ENV="${STACK_DIR}/../control-plane/.env"
cd "${STACK_DIR}"
if [[ ! -f "${CONTROL_PLANE_ENV}" ]]; then
echo "ERROR: ${CONTROL_PLANE_ENV} not found."
echo "Run: cp ${STACK_DIR}/../control-plane/.env.example ${CONTROL_PLANE_ENV}"
exit 1
fi
if [[ ! -d .venv ]]; then
python3 -m venv .venv
.venv/bin/pip install --upgrade pip -q
.venv/bin/pip install -r requirements.txt -q
fi
set -a
# shellcheck disable=SC1091
source "${CONTROL_PLANE_ENV}"
set +a
export CONTROL_PLANE_ENV="${CONTROL_PLANE_ENV}"
exec .venv/bin/python app.py
+20
View File
@@ -0,0 +1,20 @@
[Unit]
Description=GMKtec K11 Server UI (Docker stack manager)
After=docker.service network-online.target
Wants=docker.service
[Service]
Type=simple
User=tomasz-syn-grzegorza
Group=tomasz-syn-grzegorza
SupplementaryGroups=docker
WorkingDirectory=/opt/server-ui
EnvironmentFile=-/opt/control-plane/.env
ExecStart=/opt/server-ui/.venv/bin/python /opt/server-ui/app.py
Restart=on-failure
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=15
[Install]
WantedBy=multi-user.target
+44
View File
@@ -0,0 +1,44 @@
stacks:
- id: localai
name: LocalAI
compose_dir: localai
profile: localai
container: localai
ui_port: 8070
port_env: LOCALAI_PORT
port_default: 8080
port_editable: true
gpu: true
- id: comfyui
name: ComfyUI
compose_dir: comfyui
profile: comfyui
container: comfyui
ui_port: 8188
port_env: COMFYUI_PORT
port_default: 8188
port_editable: true
gpu: true
- id: vllm
name: vLLM
compose_dir: vllm
profile: vllm
container: vllm
ui_port: 8000
port_env: VLLM_PORT
port_default: 8000
port_editable: true
gpu: true
- id: npmplus
name: NPMPlus
compose_dir: npmplus
profile: npmplus
container: npmplus
ui_port: 81
ui_scheme: https
port_default: 81
port_editable: false
gpu: false
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
/**
* Skipped minification because the original files appears to be already minified.
* Original file: /npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=addon-fit.js.map
+218
View File
@@ -0,0 +1,218 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility:not(.debug),
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
color: transparent;
}
.xterm .xterm-accessibility-tree {
user-select: text;
white-space: pre;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
File diff suppressed because one or more lines are too long