Files
SayIt/server/backend/app/admin.py
T

342 lines
13 KiB
Python

from __future__ import annotations
import secrets
from dataclasses import dataclass, field, fields
from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, status
from fastapi.responses import FileResponse, JSONResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from .config import Config
from .telemetry import TelemetryService
_UI_PATH = Path(__file__).resolve().parents[2] / "web" / "admin.html"
_SECURITY = HTTPBasic(auto_error=False)
def _config(request: Request) -> Config:
cfg = getattr(request.app.state, "config", None)
if cfg is None:
raise HTTPException(status_code=503, detail="config not initialized")
return cfg
def _telemetry(request: Request) -> TelemetryService:
service = getattr(request.app.state, "telemetry", None)
if service is None:
raise HTTPException(status_code=503, detail="telemetry service not initialized")
return service
def _require_admin(
request: Request,
credentials: HTTPBasicCredentials | None = Depends(_SECURITY),
) -> None:
cfg = _config(request)
if not cfg.admin.enabled:
raise HTTPException(status_code=404, detail="admin disabled")
if not cfg.admin.password:
raise HTTPException(status_code=503, detail="admin password not configured")
username_ok = credentials is not None and secrets.compare_digest(credentials.username, cfg.admin.username)
password_ok = credentials is not None and secrets.compare_digest(credentials.password, cfg.admin.password)
if username_ok and password_ok:
return
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="admin authentication required",
headers={"WWW-Authenticate": 'Basic realm="SayIt Admin"'},
)
# ---------------------------------------------------------------------------
# Shared filter parameters
# ---------------------------------------------------------------------------
@dataclass
class _FilterParams:
"""Common query filters shared across admin endpoints."""
from_: str | None = None
to: str | None = None
user_id: str | None = None
user_name: str | None = None
app: str | None = None
process_name: str | None = None
client_ip: str | None = None
status: str | None = None
node_id: str | None = None
asr_model: str | None = None
llm_model: str | None = None
ai_enabled: str | None = None
source: str | None = None
def to_dict(self) -> dict[str, str | None]:
d = {f.name.rstrip("_"): getattr(self, f.name) for f in fields(self)}
# dataclass field is "from_" but dict key must be "from"
if "from_" in d:
d["from"] = d.pop("from_")
return d
def _parse_filters(request: Request) -> _FilterParams:
"""Build *_FilterParams* from query string — single source of truth."""
q = request.query_params
return _FilterParams(
from_=q.get("from"),
to=q.get("to"),
user_id=q.get("user_id"),
user_name=q.get("user_name"),
app=q.get("app"),
process_name=q.get("process_name"),
client_ip=q.get("client_ip"),
status=q.get("status"),
node_id=q.get("node_id"),
asr_model=q.get("asr_model"),
llm_model=q.get("llm_model"),
ai_enabled=q.get("ai_enabled"),
source=q.get("source"),
)
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(_require_admin)])
# Serve admin UI without auth (login is handled client-side)
_public_router = APIRouter(prefix="/admin", tags=["admin"])
@_public_router.get("")
@_public_router.get("/")
async def admin_ui() -> FileResponse:
return FileResponse(_UI_PATH)
# ---------------------------------------------------------------------------
# API endpoints
# ---------------------------------------------------------------------------
@router.get("/api/overview")
async def overview(request: Request, f: _FilterParams = Depends(_parse_filters)) -> JSONResponse:
return JSONResponse(_telemetry(request).get_overview(f.to_dict()))
@router.get("/api/sessions")
async def sessions(
request: Request,
f: _FilterParams = Depends(_parse_filters),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
sort: str | None = Query(default=None),
) -> JSONResponse:
return JSONResponse(_telemetry(request).list_sessions(f.to_dict(), limit=limit, offset=offset, sort=sort))
@router.get("/api/sessions/{session_id}")
async def session_detail(request: Request, session_id: str) -> JSONResponse:
detail = _telemetry(request).get_session_detail(session_id)
if not detail:
return JSONResponse({"error": "not found"}, status_code=404)
return JSONResponse(detail)
@router.get("/api/metrics/by-app")
async def metrics_by_app(request: Request, f: _FilterParams = Depends(_parse_filters)) -> JSONResponse:
return JSONResponse(_telemetry(request).metric_by_group(f.to_dict(), "process_name"))
@router.get("/api/metrics/by-user")
async def metrics_by_user(request: Request, f: _FilterParams = Depends(_parse_filters)) -> JSONResponse:
return JSONResponse(_telemetry(request).metric_by_group(f.to_dict(), "user_id"))
@router.get("/api/metrics/by-model")
async def metrics_by_model(request: Request, f: _FilterParams = Depends(_parse_filters)) -> JSONResponse:
d = f.to_dict()
return JSONResponse({
"asr": _telemetry(request).metric_by_group(d, "asr_model"),
"llm": _telemetry(request).metric_by_group(d, "llm_model"),
})
@router.get("/api/metrics/timeline")
async def metrics_timeline(
request: Request,
f: _FilterParams = Depends(_parse_filters),
bucket: str = Query(default="day"),
) -> JSONResponse:
return JSONResponse(_telemetry(request).timeline(f.to_dict(), "hour" if bucket == "hour" else "day"))
@router.get("/api/metrics/duration-distribution")
async def duration_distribution(request: Request, f: _FilterParams = Depends(_parse_filters)) -> JSONResponse:
return JSONResponse(_telemetry(request).duration_distribution(f.to_dict()))
@router.get("/api/metrics/percentiles")
async def metrics_percentiles(request: Request, f: _FilterParams = Depends(_parse_filters)) -> JSONResponse:
return JSONResponse(_telemetry(request).get_percentiles(f.to_dict()))
@router.get("/api/service-logs")
async def service_logs(
request: Request,
limit: int = Query(default=50, ge=1, le=200),
level: str | None = Query(default=None),
from_: str | None = Query(default=None, alias="from"),
to: str | None = Query(default=None),
q: str | None = Query(default=None),
) -> JSONResponse:
return JSONResponse(_telemetry(request).list_service_logs(limit=limit, level=level, from_=from_, to=to, q=q))
@router.get("/api/system-info")
async def system_info(request: Request) -> JSONResponse:
cfg = _config(request)
llm = cfg.llm
provider = llm.provider.lower() if llm.enabled else None
model_map = {"openai": llm.openai_model, "azure": llm.azure_deployment, "groq": llm.groq_model, "ollama": llm.ollama_model}
web_llm = cfg.web_demo.llm
web_provider = web_llm.provider.lower() if web_llm.enabled else None
web_model_map = {"openai": web_llm.openai_model, "azure": web_llm.azure_deployment, "groq": web_llm.groq_model, "ollama": web_llm.ollama_model}
return JSONResponse({
"asr_engine": cfg.asr.engine,
"asr_model": cfg.asr.model,
"llm": {"enabled": llm.enabled, "provider": provider, "model": model_map.get(provider, "-") if provider else "-"},
"web_demo_llm": {"enabled": web_llm.enabled, "provider": web_provider, "model": web_model_map.get(web_provider, "-") if web_provider else "-"},
"web_demo": {"enabled": cfg.web_demo.enabled, "max_duration_sec": cfg.web_demo.max_duration_sec, "max_concurrency_per_ip": cfg.web_demo.max_concurrency_per_ip},
"node_id": cfg.telemetry.node_id,
"deployment_mode": cfg.telemetry.deployment_mode,
"resources": _collect_resources(),
})
def _collect_resources() -> dict:
"""Collect CPU / memory / disk / GPU metrics."""
import psutil
mem = psutil.virtual_memory()
disk = psutil.disk_usage("/")
boot = psutil.boot_time()
uptime_sec = int(__import__("time").time() - boot)
info: dict = {
"cpu_percent": psutil.cpu_percent(interval=0),
"cpu_count": psutil.cpu_count(),
"mem_total_gb": round(mem.total / (1 << 30), 1),
"mem_used_gb": round(mem.used / (1 << 30), 1),
"mem_percent": mem.percent,
"disk_total_gb": round(disk.total / (1 << 30), 1),
"disk_used_gb": round(disk.used / (1 << 30), 1),
"disk_percent": disk.percent,
"uptime_sec": uptime_sec,
"gpus": [],
}
try:
import subprocess
r = subprocess.run(
["nvidia-smi", "--query-gpu=index,name,utilization.gpu,memory.used,memory.total,temperature.gpu", "--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=5,
)
if r.returncode == 0:
for line in r.stdout.strip().splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) >= 6:
info["gpus"].append({
"index": int(parts[0]),
"name": parts[1],
"util_percent": int(parts[2]),
"mem_used_mb": int(parts[3]),
"mem_total_mb": int(parts[4]),
"temp_c": int(parts[5]),
})
except Exception:
pass
return info
# ---------------------------------------------------------------------------
# Protected operational endpoints (moved from public API)
# ---------------------------------------------------------------------------
@router.get("/api/healthz-details")
async def healthz_details(request: Request) -> JSONResponse:
cfg = _config(request)
from .main import asr_engine, llm_engine, web_demo_llm_engine, _active_ws_count, _get_telemetry
return JSONResponse({
"status": "ok",
"asr": asr_engine is not None,
"asr_engine": cfg.asr.engine,
"asr_model": cfg.asr.model,
"llm": cfg.llm.enabled and llm_engine is not None,
"web_demo": {
"enabled": cfg.web_demo.enabled,
"llm": cfg.web_demo.llm.enabled and web_demo_llm_engine is not None,
"max_duration_sec": cfg.web_demo.max_duration_sec,
"max_concurrency_per_ip": cfg.web_demo.max_concurrency_per_ip,
},
"telemetry": _get_telemetry().health_summary() if _get_telemetry() else None,
"active_connections": _active_ws_count,
})
@router.get("/api/hotwords")
async def get_hotwords(request: Request) -> JSONResponse:
from .main import asr_engine
return JSONResponse(asr_engine.get_hotwords() if asr_engine else [])
@router.put("/api/hotwords")
async def put_hotwords(request: Request, payload: list[str] = Body(default=[])) -> JSONResponse:
from .main import asr_engine
if not asr_engine:
return JSONResponse({"error": "ASR not loaded"}, status_code=503)
return JSONResponse(asr_engine.set_hotwords(payload))
@router.get("/api/asr-tuning")
async def get_asr_tuning(request: Request) -> JSONResponse:
from .main import asr_engine
if not asr_engine:
return JSONResponse({"error": "ASR not loaded"}, status_code=503)
return JSONResponse(asr_engine.get_batching())
@router.put("/api/asr-tuning")
async def put_asr_tuning(request: Request, payload: dict = Body(default={})) -> JSONResponse:
from .main import asr_engine
if not asr_engine:
return JSONResponse({"error": "ASR not loaded"}, status_code=503)
return JSONResponse(asr_engine.set_batching(
batch_max_size=payload.get("batch_max_size"),
batch_wait_ms=payload.get("batch_wait_ms"),
))
@router.get("/api/feedback")
async def list_feedback(
request: Request,
t: TelemetryService = Depends(_telemetry),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> JSONResponse:
import json as _json
db = t._db
p = db.dialect.placeholder
rows = db.fetch_all(
f"SELECT * FROM feedback ORDER BY created_at DESC LIMIT {p} OFFSET {p}",
(limit, offset),
)
total = db.fetch_one("SELECT COUNT(*) as cnt FROM feedback")
items = []
for r in rows:
item = dict(r)
for k in ("transcript_json", "context_json"):
if item.get(k):
try:
item[k] = _json.loads(item[k])
except Exception:
pass
items.append(item)
return JSONResponse({"items": items, "total": (total or {}).get("cnt", 0)})