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
+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()