Initial import: bare-metal stacks, Server UI, GPU fan, tutorials.
Infrastructure configs for GMKtec K11 (Docker, vLLM, LocalAI, ComfyUI, control-plane, gpu-fan agent, Server UI with CLI/file explorer/GPU fan curve). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# DEPRECATED — use stacks/control-plane/.env.example
|
||||
# Copy: cp ../control-plane/.env.example ../control-plane/.env
|
||||
@@ -0,0 +1 @@
|
||||
.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
Reference in New Issue
Block a user