front: serve system-stats natively; strip dead page/gpu/system-stats from engine

- /admin/api/system-stats now built on the front (psutil for CPU/RAM, torch-free
  gpu_detect for GPU/VRAM) instead of poll-proxying to the GIL-busy engine, so the
  Tasks header tiles stay live during generation.
- Removed from codai/admin/routes.py (engine), now all owned by the front: the
  dead page-GET routes (login/admin/models/tokens/users/tasks/chat/settings/
  archive/change-password GET — the front renders these), api_gpu_stats and
  api_system_stats. Kept the auth POSTs (login/logout/change-password), _tmpl,
  _do_task_cancel, build_settings_dict and api_save_settings.

Verified: front _build_system_stats returns cpu/ram/gpu/vram; routes.py imports.
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DDv7BchtZQWsnPG6Jm49m
parent def78c18
......@@ -256,17 +256,6 @@ def require_admin(request: Request) -> str:
return username
@router.get("/login", response_class=HTMLResponse, summary="Admin login page")
async def login_page(request: Request):
"""Display login page."""
# If already logged in, redirect to dashboard
username = get_current_user(request)
if username:
return RedirectResponse(url=_url(request, "/admin"), status_code=302)
return _tmpl(request, "login.html", {"error": None})
@router.post("/login", summary="Authenticate admin login")
async def login(
request: Request,
......@@ -312,18 +301,6 @@ async def logout(request: Request):
return response
@router.get("/admin/change-password", response_class=HTMLResponse, summary="Change-password page")
async def change_password_page(request: Request, username: str = Depends(require_auth)):
user = session_manager.get_user(username)
must_change = user.get("must_change_password", False) if user else False
return _tmpl(request, "change_password.html", {
"username": username,
"must_change": must_change,
"is_admin": session_manager.is_admin(username),
"error": None,
})
@router.post("/admin/change-password", summary="Change admin password")
async def change_password(
request: Request,
......@@ -360,57 +337,6 @@ async def change_password(
return RedirectResponse(url=_url(request, "/admin"), status_code=302)
@router.get("/admin", response_class=HTMLResponse, summary="Admin dashboard")
async def admin_dashboard(request: Request, username: str = Depends(require_auth)):
is_admin = session_manager.is_admin(username)
return _tmpl(request, "dashboard.html", {
"username": username, "is_admin": is_admin,
})
@router.get("/admin/models", response_class=HTMLResponse, summary="Models admin page")
async def models_page(request: Request, username: str = Depends(require_admin)):
return _tmpl(request, "models.html", {
"username": username,
"is_admin": True,
"default_whisper_server_path": _default_whisper_server_path(),
})
@router.get("/admin/tokens", response_class=HTMLResponse, summary="API tokens admin page")
async def tokens_page(request: Request, username: str = Depends(require_admin)):
return _tmpl(request, "tokens.html", {"username": username, "is_admin": True})
@router.get("/admin/users", response_class=HTMLResponse, summary="Users admin page")
async def users_page(request: Request, username: str = Depends(require_admin)):
users = session_manager.list_users()
return _tmpl(request, "users.html", {
"username": username, "is_admin": True, "users": users,
})
@router.get("/admin/tasks", response_class=HTMLResponse, summary="Tasks admin page")
async def tasks_page(request: Request, username: str = Depends(require_admin)):
return _tmpl(request, "tasks.html", {"username": username, "is_admin": True})
@router.get("/chat", response_class=HTMLResponse, summary="Studio (chat) page")
async def chat_page(request: Request, username: str = Depends(require_auth)):
return _tmpl(request, "chat.html", {
"username": username, "is_admin": session_manager.is_admin(username),
})
# API endpoints for admin operations
# NOTE: /admin/api/status, /admin/api/tokens (GET/POST/DELETE) and
# /admin/api/users (POST/DELETE) are served by the FRONT now
# (codai/frontproxy/admin_data.py + FrontProxy.status); the engine only
# generates and never serves these. Removed from here.
# --- Models management endpoints ---
@router.get("/admin/api/models", summary="List configured models")
async def api_list_models(username: str = Depends(require_admin)):
"""List all configured models with details."""
......@@ -2891,56 +2817,6 @@ def _read_vram_info() -> Optional[dict]:
return None
@router.get("/admin/api/gpu-stats", summary="Per-card GPU utilization, VRAM and temperature")
def api_gpu_stats(username: str = Depends(require_auth)):
"""Live stats for EVERY physical GPU installed (NVIDIA via nvidia-smi, AMD via
sysfs), independent of which engine owns it. Used by the Tasks page to show
per-card VRAM + utilization across all cards. Best-effort; empty if unreadable.
SYNC handler: it shells out to nvidia-smi / reads sysfs, so it runs in the
threadpool rather than on the event loop."""
try:
from codai.frontproxy.gpu_detect import gpu_stats
return {"cards": gpu_stats()}
except Exception as e:
return {"cards": [], "error": str(e)}
@router.get("/admin/api/system-stats", summary="Live CPU / GPU / RAM / VRAM usage and temperatures")
def api_system_stats(username: str = Depends(require_admin)):
"""Lightweight hardware telemetry for the Tasks page header: CPU & GPU
utilization and temperature, plus RAM and VRAM usage. All fields are
best-effort and may be null when a sensor/metric is unavailable.
SYNC handler on purpose: the temperature/util/VRAM reads hit sysfs and
blocking sensor calls, so it runs in FastAPI's threadpool to avoid freezing
the event loop (and the Tasks page) while a model is loading."""
from codai.models import thermal
# CPU tile = coderai process-tree usage, scaled 100% PER CORE (0..100*cores),
# not the all-core average (which reads misleadingly low when work is on a few
# cores). `cores` lets the UI scale the bar to full capacity = cores*100%.
cpu = {"util": thermal.read_process_tree_cpu(), "temp": thermal.read_cpu_temp(),
"cores": None}
ram = None
try:
import psutil
cpu["cores"] = psutil.cpu_count()
vm = psutil.virtual_memory()
ram = {"used": vm.used / 1e9, "total": vm.total / 1e9, "percent": vm.percent}
except Exception:
pass
gpu = {"util": thermal.read_gpu_util(), "temp": thermal.read_gpu_temp()}
vram = _read_vram_info()
if vram and vram.get("total"):
vram["percent"] = round(vram["used"] / vram["total"] * 100, 1)
if gpu.get("gpu") is None:
gpu["name"] = vram.get("gpu") or ""
return {"cpu": cpu, "gpu": gpu, "ram": ram, "vram": vram}
def _do_task_cancel(task_id: str) -> bool:
"""Cancel a task by id. Training ids route through loras.cancel_job (handles
queued vs running + the durable job record); everything else goes through the
......@@ -3069,15 +2945,6 @@ def _detect_gpu_cards() -> list:
return []
@router.get("/admin/settings", response_class=HTMLResponse, summary="Settings page")
async def settings_page(request: Request, username: str = Depends(require_admin)):
return _tmpl(request, "settings.html", {"username": username, "is_admin": True})
# /admin/api/settings GET is served by the front from the same
# build_settings_dict() below (FrontProxy holds the same Config). The POST
# (save) still lives here so the engine persists + applies config changes.
def build_settings_dict(c, gpu_cards):
"""Pure ``Config`` → settings dict. Shared by the engine handler and the front
proxy (which holds the same Config) so both serve an identical
......@@ -3576,11 +3443,6 @@ async def api_save_settings(request: Request, username: str = Depends(require_ad
# Archive management
# =============================================================================
@router.get("/admin/archive", response_class=HTMLResponse, summary="Archive page")
async def archive_page(request: Request, username: str = Depends(require_admin)):
return _tmpl(request, "archive.html", {"username": username, "is_admin": True})
@router.get("/admin/api/archive", summary="List archived generations")
async def api_archive_list(
limit: int = 50,
......
......@@ -498,6 +498,68 @@ class FrontProxy:
return JSONResponse({"cards": [], "error": str(exc)})
return JSONResponse({"cards": cards})
async def system_stats(self, request: Request) -> Response:
"""CPU/GPU/RAM/VRAM telemetry for the Tasks header, built on the FRONT
(psutil + torch-free gpu_detect) so it stays live while an engine is busy
generating. Run in a thread so the brief CPU sample never blocks the loop."""
if not self._has_cred(request):
return JSONResponse({"detail": "Unauthorized"}, status_code=401)
import asyncio
try:
return JSONResponse(await asyncio.to_thread(self._build_system_stats))
except Exception as exc:
return JSONResponse({"cpu": {}, "gpu": {}, "ram": None, "vram": None,
"error": str(exc)})
@staticmethod
def _build_system_stats() -> dict:
cpu = {"util": None, "temp": None, "cores": None}
ram = None
try:
import psutil
cores = psutil.cpu_count() or 1
cpu["cores"] = cores
# System-wide load sampled briefly, scaled to the per-core sum the tile
# expects (0..cores*100).
avg = psutil.cpu_percent(interval=0.15)
cpu["util"] = round((avg or 0.0) * cores / 100.0 * 100.0, 1)
try:
temps = psutil.sensors_temperatures() or {}
for key in ("k10temp", "coretemp", "zenpower", "cpu_thermal"):
if temps.get(key):
cpu["temp"] = max(t.current for t in temps[key]
if t.current is not None)
break
except Exception:
pass
vm = psutil.virtual_memory()
ram = {"used": round(vm.used / 1e9, 2), "total": round(vm.total / 1e9, 2),
"percent": vm.percent}
except Exception:
pass
gpu = {"util": None, "temp": None, "name": None}
vram = None
try:
from codai.frontproxy.gpu_detect import gpu_stats as _gs
cards = _gs()
if cards:
utils = [c.get("util") for c in cards if c.get("util") is not None]
temps = [c.get("temp") for c in cards if c.get("temp") is not None]
gpu["util"] = round(sum(utils) / len(utils), 1) if utils else None
gpu["temp"] = max(temps) if temps else None
gpu["name"] = (cards[0]["name"] if len(cards) == 1
else f"{len(cards)} GPUs")
used = sum((c.get("mem_used") or 0) for c in cards)
total = sum((c.get("mem_total") or 0) for c in cards)
if total:
vram = {"used": round(used, 2), "total": round(total, 2),
"free": round(total - used, 2),
"percent": round(used / total * 100, 1),
"gpu": gpu["name"]}
except Exception:
pass
return {"cpu": cpu, "gpu": gpu, "ram": ram, "vram": vram}
async def batch(self, request: Request) -> Response:
"""Fan out several engine GET reads CONCURRENTLY (server-side) and return
them in one response.
......@@ -1187,7 +1249,7 @@ def build_app(config, config_dir=None) -> FastAPI:
@app.get("/admin/api/system-stats", include_in_schema=False)
async def _system_stats(request: Request):
return await front.poll(request)
return await front.system_stats(request)
# GPU stats are served by the FRONT (torch-free gpu_detect) so temps/util stay
# live even when an engine is busy generating — registered before the catch-all
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment