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: ...@@ -256,17 +256,6 @@ def require_admin(request: Request) -> str:
return username 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") @router.post("/login", summary="Authenticate admin login")
async def login( async def login(
request: Request, request: Request,
...@@ -312,18 +301,6 @@ async def logout(request: Request): ...@@ -312,18 +301,6 @@ async def logout(request: Request):
return response 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") @router.post("/admin/change-password", summary="Change admin password")
async def change_password( async def change_password(
request: Request, request: Request,
...@@ -360,57 +337,6 @@ async def change_password( ...@@ -360,57 +337,6 @@ async def change_password(
return RedirectResponse(url=_url(request, "/admin"), status_code=302) 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") @router.get("/admin/api/models", summary="List configured models")
async def api_list_models(username: str = Depends(require_admin)): async def api_list_models(username: str = Depends(require_admin)):
"""List all configured models with details.""" """List all configured models with details."""
...@@ -2891,56 +2817,6 @@ def _read_vram_info() -> Optional[dict]: ...@@ -2891,56 +2817,6 @@ def _read_vram_info() -> Optional[dict]:
return None 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: def _do_task_cancel(task_id: str) -> bool:
"""Cancel a task by id. Training ids route through loras.cancel_job (handles """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 queued vs running + the durable job record); everything else goes through the
...@@ -3069,15 +2945,6 @@ def _detect_gpu_cards() -> list: ...@@ -3069,15 +2945,6 @@ def _detect_gpu_cards() -> list:
return [] 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): def build_settings_dict(c, gpu_cards):
"""Pure ``Config`` → settings dict. Shared by the engine handler and the front """Pure ``Config`` → settings dict. Shared by the engine handler and the front
proxy (which holds the same Config) so both serve an identical 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 ...@@ -3576,11 +3443,6 @@ async def api_save_settings(request: Request, username: str = Depends(require_ad
# Archive management # 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") @router.get("/admin/api/archive", summary="List archived generations")
async def api_archive_list( async def api_archive_list(
limit: int = 50, limit: int = 50,
......
...@@ -498,6 +498,68 @@ class FrontProxy: ...@@ -498,6 +498,68 @@ class FrontProxy:
return JSONResponse({"cards": [], "error": str(exc)}) return JSONResponse({"cards": [], "error": str(exc)})
return JSONResponse({"cards": cards}) 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: async def batch(self, request: Request) -> Response:
"""Fan out several engine GET reads CONCURRENTLY (server-side) and return """Fan out several engine GET reads CONCURRENTLY (server-side) and return
them in one response. them in one response.
...@@ -1187,7 +1249,7 @@ def build_app(config, config_dir=None) -> FastAPI: ...@@ -1187,7 +1249,7 @@ def build_app(config, config_dir=None) -> FastAPI:
@app.get("/admin/api/system-stats", include_in_schema=False) @app.get("/admin/api/system-stats", include_in_schema=False)
async def _system_stats(request: Request): 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 # 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 # 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