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
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""GPU fan control web UI + NVML daemon (single process)."""
from __future__ import annotations
import logging
import os
import signal
import sys
import threading
from pathlib import Path
from typing import Any
import uvicorn
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from fan_controller import (
FanControlError,
FanController,
MAX_CURVE_POINTS,
MIN_CURVE_POINTS,
MIN_FAN_SPEED,
MAX_FAN_SPEED,
curve_to_dict,
parse_curve,
)
STACK_DIR = Path(__file__).resolve().parent
# Unified control-plane env (see stacks/control-plane/env_loader.py)
for _cp in ("/opt/control-plane", "/repo/stacks/control-plane", str(STACK_DIR.parent / "control-plane")):
if _cp not in sys.path and Path(_cp).exists():
sys.path.insert(0, _cp)
from env_loader import load_control_plane_env # noqa: E402
STATIC_DIR = STACK_DIR / "static"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
log = logging.getLogger(__name__)
def _is_localhost_bind(host: str) -> bool:
return host.strip().lower() in ("127.0.0.1", "localhost", "::1")
def _resolve_host_port(env: dict[str, str]) -> tuple[str, int]:
host = env.get("GPU_FAN_API_HOST") or env.get("GPU_FAN_HOST", "0.0.0.0")
port_raw = env.get("GPU_FAN_API_PORT") or env.get("GPU_FAN_PORT", "8090")
return host, int(port_raw)
ENV = load_control_plane_env(STACK_DIR)
HOST, PORT = _resolve_host_port(ENV)
API_KEY = ENV.get("API_KEY", "")
CURVE_PATH = Path(ENV.get("CURVE_PATH", "/etc/gpu-fan/curve.json"))
POLL_INTERVAL = float(ENV.get("POLL_INTERVAL", "2.0"))
GPU_INDEX = int(ENV.get("GPU_INDEX", "0"))
DRY_RUN = ENV.get("DRY_RUN", "").lower() in ("1", "true", "yes")
controller = FanController(
curve_path=CURVE_PATH,
gpu_index=GPU_INDEX,
poll_interval=POLL_INTERVAL,
)
controller.dry_run = DRY_RUN
app = FastAPI(title="GPU Fan Control", version="1.0.0")
class CurvePoint(BaseModel):
temp: int = Field(ge=0, le=120)
speed: int = Field(ge=0, le=100)
class CurveUpdate(BaseModel):
points: list[CurvePoint] = Field(min_length=MIN_CURVE_POINTS, max_length=MAX_CURVE_POINTS)
class ModeUpdate(BaseModel):
mode: str
speed: int | None = Field(default=None, ge=MIN_FAN_SPEED, le=MAX_FAN_SPEED)
def require_auth(request: Request) -> None:
if not API_KEY:
return
key = request.headers.get("X-API-Key", "")
if key != API_KEY:
raise HTTPException(status_code=401, detail="Invalid or missing API key")
@app.get("/")
def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
@app.get("/api/status")
def api_status(_: None = Depends(require_auth)) -> dict[str, Any]:
return controller.get_metrics()
@app.get("/api/curve")
def api_get_curve(_: None = Depends(require_auth)) -> dict[str, Any]:
points = controller.get_curve()
return {"points": [{"temp": t, "speed": s} for t, s in points]}
@app.put("/api/curve")
def api_put_curve(body: CurveUpdate, _: None = Depends(require_auth)) -> dict[str, Any]:
points = [(p.temp, p.speed) for p in body.points]
try:
parse_curve(curve_to_dict(points))
controller.save_curve_file(points)
controller.set_mode("curve")
except FanControlError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "curve": curve_to_dict(controller.get_curve())}
@app.post("/api/mode")
def api_set_mode(body: ModeUpdate, _: None = Depends(require_auth)) -> dict[str, Any]:
try:
controller.set_mode(body.mode, body.speed)
if body.mode != "auto":
controller.update_once()
except FanControlError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "mode": controller.mode, "manual_speed": controller.manual_speed}
@app.post("/api/reload")
def api_reload(_: None = Depends(require_auth)) -> dict[str, Any]:
try:
controller.reload_curve()
except FanControlError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True, "curve": curve_to_dict(controller.get_curve())}
def run_daemon_thread() -> None:
controller.run_loop()
def main() -> None:
if os.geteuid() != 0 and not DRY_RUN:
log.error("GPU fan control requires root (NVML write access). Run with sudo.")
sys.exit(1)
if not _is_localhost_bind(HOST) and not API_KEY:
log.error(
"API_KEY is required when GPU_FAN_HOST=%s (LAN/public bind). "
"Set API_KEY in .env",
HOST,
)
sys.exit(1)
controller.init_nvml()
def shutdown_handler(signum: int, _frame: object) -> None:
log.info("Received signal %s", signum)
if signum == signal.SIGHUP:
try:
controller.reload_curve()
except FanControlError as exc:
log.error("Curve reload failed: %s", exc)
return
controller.shutdown()
sys.exit(0)
signal.signal(signal.SIGTERM, shutdown_handler)
signal.signal(signal.SIGINT, shutdown_handler)
signal.signal(signal.SIGHUP, shutdown_handler)
thread = threading.Thread(target=run_daemon_thread, daemon=True)
thread.start()
if _is_localhost_bind(HOST):
log.info("Web UI at http://127.0.0.1:%d", PORT)
else:
log.info("Web UI listening on 0.0.0.0:%d (LAN — API key required)", PORT)
uvicorn.run(app, host=HOST, port=PORT, log_level="info")
if __name__ == "__main__":
if os.environ.get("GPU_FAN_LEGACY_UI", "").lower() in ("1", "true", "yes"):
main()
else:
from fan_daemon import main as daemon_main
daemon_main()