chore: 0.0.8 更新(本地模型、小米 MiMo 供应商、体验优化与多项修复)

This commit is contained in:
crosswk committed 2026-07-08 17:30:26 +08:00
1 parent 241d83762c
commit ff035773e5
68 files changed
+2488 -1926

No files matched your search

-341
View File
@@ -1,341 +0,0 @@
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)})
+83 -43
View File
@@ -17,14 +17,12 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from .admin import _public_router as admin_public_router, router as admin_router
from .asr import ASREngine
from .config import load_config
from .db import Database
from .diagnostics import create_upload_token, router as diagnostics_router
from .llm import LLMEngine
from .logging_setup import attach_database_log_handler, bind_log_context, configure_logging, reset_log_context
from .recorder import Recorder
from .ratelimit import RateLimitMiddleware
from .releases import read_public_download, read_release_manifest, resolve_release_file
from .telemetry import TelemetryService
@@ -36,15 +34,22 @@ logger = logging.getLogger("sayit")
asr_engine: ASREngine | None = None
llm_engine: LLMEngine | None = None
web_demo_llm_engine: LLMEngine | None = None
recorder: Recorder | None = None
database: Database | None = None
telemetry_service: TelemetryService | None = None
_web_demo_active_by_ip: dict[str, int] = defaultdict(int)
_active_ws_count: int = 0
_active_ws_ids: set[str] = set()
_ws_by_ip: dict[str, int] = defaultdict(int)
# 每个真实客户端 IP -> 该 IP 当前存活连接的 cid 集合(用集合而非整数,计数由存活连接派生,永不漂移)
_ws_by_ip: dict[str, set[str]] = defaultdict(set)
_MAX_WS_TOTAL = 200
_MAX_WS_PER_IP = 10
# 接收空闲超时:客户端每 30s 发一次 ping;超过此时长无任何消息即视为死连接并回收,
# 避免半开/异常掉线的连接长期占用 per-IP 配额(不再依赖 OS TCP 超时,可能长达几分钟)。
_WS_IDLE_TIMEOUT_SEC = 90
# WebSocket 关闭码:不同拒绝/关闭原因用不同 code,客户端与日志据此区分排查
_WS_CLOSE_SERVER_FULL = 1013 # 服务器整体到达容量上限(标准“稍后再试”)
_WS_CLOSE_PER_IP_LIMIT = 4029 # 该客户端 IP 并发连接过多(对应 HTTP 429 语义)
_WS_CLOSE_IDLE = 4000 # 服务端回收空闲/疑似死连接
_MAX_PCM_BYTES = 10 * 1024 * 1024 # 10MB ≈ 5 min of 16kHz 16-bit mono
@@ -59,15 +64,13 @@ def _llm_model_name(profile) -> str:
@asynccontextmanager
async def _lifespan(app: FastAPI):
global asr_engine, llm_engine, web_demo_llm_engine, recorder, database, telemetry_service
global asr_engine, llm_engine, web_demo_llm_engine, database, telemetry_service
database = Database(cfg.telemetry.db_path, backend=cfg.telemetry.db_backend)
database.initialize()
attach_database_log_handler(database)
telemetry_service = TelemetryService(cfg, database)
app.state.telemetry = telemetry_service
recorder = Recorder(cfg)
logger.info("Telemetry ready db=%s", cfg.telemetry.db_path)
try:
@@ -108,7 +111,6 @@ async def _lifespan(app: FastAPI):
llm_status = f"{cfg.llm.provider} / {_llm_model_name(cfg.llm)}" if cfg.llm.enabled and llm_engine else "disabled"
web_llm_status = f"{cfg.web_demo.llm.provider} / {_llm_model_name(cfg.web_demo.llm)}" if cfg.web_demo.llm.enabled and web_demo_llm_engine else "disabled"
demo_status = f"enabled (LLM: {web_llm_status})" if cfg.web_demo.enabled else "disabled"
admin_pw_warning = " (⚠ default password)" if cfg.admin.password == "sayit" else ""
logger.info(
"\n"
@@ -118,17 +120,13 @@ async def _lifespan(app: FastAPI):
" │ ASR: %-34s │\n"
" │ LLM: %-34s │\n"
" │ Demo: %-34s │\n"
" │ Admin: %-34s │\n"
" │ HTTP: %-34s │\n"
" └──────────────────────────────────────────┘",
f"{cfg.asr.engine} ({cfg.asr.model})",
llm_status,
demo_status,
f"{cfg.admin.username}:***{admin_pw_warning}",
f":{cfg.server.port}",
)
if cfg.admin.password == "sayit":
logger.warning("Admin is using the DEFAULT password. Set SAYIT_ADMIN_PASSWORD to change it.")
if cfg.llm.enabled and not llm_engine:
logger.warning("LLM is configured but failed to initialize. Check API keys and provider settings.")
@@ -147,8 +145,6 @@ async def _lifespan(app: FastAPI):
app = FastAPI(title="SayIt", lifespan=_lifespan)
app.state.config = cfg
app.include_router(diagnostics_router)
app.include_router(admin_public_router)
app.include_router(admin_router)
app.add_middleware(
CORSMiddleware,
@@ -323,6 +319,22 @@ def _client_ip(ws: WebSocket) -> tuple[str | None, str | None]:
return direct, forwarded_for
def _client_ip_key(direct: str | None, forwarded: str | None) -> str:
"""用于“按 IP 限流/计数”的真实客户端 IP。
ALB 后 ws.client.host 恒为 ALB 内网地址(所有用户相同),必须取 X-Forwarded-For
的真实客户端 IP;且 XFF 可能是 "client, proxy1, ..." 列表,取最左第一个(与 diagnostics 一致)。
注意:最左值理论上可被客户端伪造。单层可信 ALB 场景足够;若前置更多不可信代理,
应按“可信代理跳数”从右侧取值,避免被伪造成他人 IP 定向占额。
"""
if forwarded:
first = forwarded.split(",")[0].strip()
if first:
return first
return (direct or "unknown").strip() or "unknown"
def _is_web_demo(ws: WebSocket) -> bool:
return (ws.query_params.get("client") or "").strip().lower() == "web_demo"
@@ -577,34 +589,31 @@ async def ws_transcribe(ws: WebSocket):
telemetry = _get_telemetry()
is_web_demo = _is_web_demo(ws)
client_ip_direct, client_ip_forwarded = _client_ip(ws)
ws_ip_key = (client_ip_direct or "unknown").strip() or "unknown"
ws_ip_key = _client_ip_key(client_ip_direct, client_ip_forwarded)
# Connection limits
# 先 accept,再用带专属 close code 的关闭来拒绝:
# 若在 accept 之前 close,框架会以 HTTP 403 拒绝握手,客户端只能看到通用错误(1006),无法区分原因。
await ws.accept()
# Connection limits(拒绝时不计数、也不进入下方的 try/finally)
if _active_ws_count >= _MAX_WS_TOTAL:
await ws.close(1013, "server at capacity")
logger.warning("ws rejected (server full): active=%d cid=%s", _active_ws_count, cid)
await ws.close(_WS_CLOSE_SERVER_FULL, "server at capacity")
reset_log_context(connection_tokens)
return
if _ws_by_ip[ws_ip_key] >= _MAX_WS_PER_IP:
await ws.close(1013, "too many connections from this IP")
if len(_ws_by_ip.get(ws_ip_key, ())) >= _MAX_WS_PER_IP:
logger.warning(
"ws rejected (per-ip limit): ip=%s count=%d cid=%s",
ws_ip_key, len(_ws_by_ip.get(ws_ip_key, ())), cid,
)
await ws.close(_WS_CLOSE_PER_IP_LIMIT, "too many connections from this IP")
reset_log_context(connection_tokens)
return
if telemetry:
telemetry.record_connection_event(cid, "ws_connected", None)
await ws.accept()
_active_ws_ids.add(cid)
_active_ws_count = len(_active_ws_ids)
_ws_by_ip[ws_ip_key] += 1
await ws.send_json(
{
"type": "ready",
"connection_id": cid,
"asr": asr_engine is not None,
"llm": _selected_llm_engine(is_web_demo) is not None,
"client": "web_demo" if is_web_demo else "desktop",
}
)
# 计数注册与 ready 发送移入下方 try,确保任何异常都会经 finally 释放(避免计数泄漏)。
pcm_buffers: list[bytes] = []
pcm_total_bytes: int = 0
@@ -675,10 +684,6 @@ async def ws_transcribe(ws: WebSocket):
session_id = None
return
rec_path = None
if telemetry:
telemetry.record_debug_audio(current_session_id, cid, rec_path)
audio = np.frombuffer(all_pcm, dtype=np.int16).astype(np.float32) / 32768.0
if len(audio) / 16000.0 < 0.3:
if telemetry:
@@ -746,6 +751,7 @@ async def ws_transcribe(ws: WebSocket):
return
llm_debug = result.get("llm_debug", {}) if isinstance(result.get("llm_debug"), dict) else {}
if telemetry:
telemetry.record_pipeline_result(
current_session_id,
@@ -782,6 +788,18 @@ async def ws_transcribe(ws: WebSocket):
"asr_engine": cfg.asr.engine,
"asr_model": "FireRedASR2-AED-TensorRT" if cfg.asr.engine == "firered" else cfg.asr.model,
}
# Audit log: metadata only (no transcript text persisted)
logger.info(
"audit ip=%s user=%s process=%s dur=%.1fs asr_ms=%d llm_ms=%d asr_len=%d llm_len=%d",
client_ip_forwarded or client_ip_direct or "-",
client_meta.get("user_id") or client_meta.get("user_name") or "-",
app_context.get("process_name") or "-",
result.get("duration_sec", 0),
result.get("asr_ms", 0),
result.get("llm_ms", 0),
len(result.get("asr_text", "")),
len(result.get("llm_text", "")),
)
if cmp:
msg["compare_no_hotwords"] = cmp
await ws.send_json(msg)
@@ -793,8 +811,29 @@ async def ws_transcribe(ws: WebSocket):
session_id = None
try:
# 在 try 内注册连接计数,保证任何异常(含 accept 后立即断开)都会经 finally 释放
_active_ws_ids.add(cid)
_active_ws_count = len(_active_ws_ids)
_ws_by_ip[ws_ip_key].add(cid)
await ws.send_json(
{
"type": "ready",
"connection_id": cid,
"asr": asr_engine is not None,
"llm": _selected_llm_engine(is_web_demo) is not None,
"client": "web_demo" if is_web_demo else "desktop",
}
)
while True:
msg = await ws.receive()
try:
msg = await asyncio.wait_for(ws.receive(), timeout=_WS_IDLE_TIMEOUT_SEC)
except asyncio.TimeoutError:
logger.info("ws idle %ds, reaping cid=%s ip=%s", _WS_IDLE_TIMEOUT_SEC, cid, ws_ip_key)
try:
await ws.close(_WS_CLOSE_IDLE, "idle timeout")
except RuntimeError:
pass
break
if msg["type"] == "websocket.disconnect":
break
raw = msg.get("bytes")
@@ -825,7 +864,7 @@ async def ws_transcribe(ws: WebSocket):
await ws.send_json({"type": "done"})
continue
if is_web_demo and not web_demo_slot_acquired:
if not _try_acquire_web_demo_slot(client_ip_direct):
if not _try_acquire_web_demo_slot(ws_ip_key):
await ws.send_json(
{
"type": "error",
@@ -898,14 +937,15 @@ async def ws_transcribe(ws: WebSocket):
finally:
_active_ws_ids.discard(cid)
_active_ws_count = len(_active_ws_ids)
if _ws_by_ip.get(ws_ip_key, 0) <= 1:
_ws_by_ip.pop(ws_ip_key, None)
else:
_ws_by_ip[ws_ip_key] -= 1
conns = _ws_by_ip.get(ws_ip_key)
if conns is not None:
conns.discard(cid)
if not conns:
_ws_by_ip.pop(ws_ip_key, None)
if telemetry:
telemetry.mark_disconnected(session_id, cid)
if web_demo_slot_acquired:
_release_web_demo_slot(client_ip_direct)
_release_web_demo_slot(ws_ip_key)
_clear_session_context()
logger.info("ws disconnected")
reset_log_context(connection_tokens)
-27
View File
@@ -1,27 +0,0 @@
"""Recording manager: disabled – audio is not persisted server-side."""
from __future__ import annotations
import logging
from .config import Config
logger = logging.getLogger("sayit.recorder")
class Recorder:
"""No-op recorder. Audio files are never saved on the backend."""
def __init__(self, config: Config) -> None:
pass
def save(self, pcm_bytes: bytes, connection_id: str) -> None:
return None
def list_recordings(self) -> list[dict]:
return []
def get_path(self, name: str) -> None:
return None
def cleanup_expired(self, retention_days: int) -> int:
return 0
-85
View File
@@ -1,85 +0,0 @@
from __future__ import annotations
import base64
import shutil
import tempfile
import unittest
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
from backend.app.admin import router as admin_router, _public_router as admin_public_router
from backend.app.config import Config, TelemetryConfig, AdminConfig, LoggingConfig
from backend.app.db import Database
from backend.app.telemetry import TelemetryService
class AdminRouterTests(unittest.TestCase):
def setUp(self) -> None:
self._tmpdir = Path(tempfile.mkdtemp(prefix="sayit-admin-test-"))
self.cfg = Config(
telemetry=TelemetryConfig(
enabled=True,
db_path=str(self._tmpdir / "sayit.sqlite3"),
),
logging=LoggingConfig(
file=str(self._tmpdir / "backend.log"),
),
admin=AdminConfig(
enabled=True,
username="admin",
password="secret",
),
)
self.db = Database(self.cfg.telemetry.db_path)
self.db.initialize()
self.telemetry = TelemetryService(self.cfg, self.db)
self.app = FastAPI()
self.app.state.config = self.cfg
self.app.state.telemetry = self.telemetry
self.app.include_router(admin_public_router)
self.app.include_router(admin_router)
self.client = TestClient(self.app)
def tearDown(self) -> None:
self.db.close()
shutil.rmtree(self._tmpdir, ignore_errors=True)
def _auth_headers(self) -> dict[str, str]:
token = base64.b64encode(b"admin:secret").decode("ascii")
return {"Authorization": f"Basic {token}"}
def test_admin_requires_basic_auth(self) -> None:
# Admin API endpoints require auth
response = self.client.get("/admin/api/overview")
self.assertEqual(response.status_code, 401)
self.assertIn("Basic", response.headers.get("www-authenticate", ""))
def test_admin_ui_and_api_work_with_auth(self) -> None:
# Admin UI page is served without auth (login handled client-side)
ui = self.client.get("/admin")
self.assertIn(ui.status_code, (200, 404)) # 404 if admin.html not at expected path in test env
overview = self.client.get("/admin/api/overview", headers=self._auth_headers())
self.assertEqual(overview.status_code, 200)
self.assertEqual(overview.json()["total_sessions"], 0)
def test_service_logs_endpoint_returns_rows(self) -> None:
self.db.execute(
"""
INSERT INTO service_logs(created_at, level, logger_name, node_id, session_id, connection_id, message, payload_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(1234567890, "WARNING", "sayit.test", "node-1", "sid-1", "cid-1", "example warning", "{}"),
)
response = self.client.get("/admin/api/service-logs", headers=self._auth_headers())
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(len(payload), 1)
self.assertEqual(payload[0]["message"], "example warning")
if __name__ == "__main__":
unittest.main()
-425
View File
@@ -1,425 +0,0 @@
/* ===== SayIt Admin v3 — Impeccable redesign ===== */
/* --- 浅色(与 Tauri 客户端 light 主题一致) --- */
:root, [data-theme="light"] {
--background: 0 0% 100%; --foreground: 240 10% 3.9%;
--card: 0 0% 100%; --card-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%; --primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%; --secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%; --muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%; --accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%; --input: 240 5.9% 90%;
--ring: 240 5.9% 10%; --radius: 0.5rem;
--sidebar-bg: 0 0% 100%; --sidebar-border: 240 5.9% 90%;
--sidebar-item-active-bg: 240 4.8% 95.9%; --sidebar-item-hover-bg: 240 4.8% 97.9%;
--sidebar-text: 240 4% 46%; --sidebar-text-active: 240 10% 3.9%;
--input-bg: 0 0% 100%; --input-border: 240 5.9% 90%;
--input-focus-border: 240 5.9% 10%; --input-placeholder: 240 4% 65%;
--success: 142 76% 36%; --success-foreground: 0 0% 100%;
--warning: 38 92% 50%; --warning-foreground: 0 0% 100%;
--info: 199 89% 48%; --info-foreground: 0 0% 100%;
}
/* 深色(与 Tauri 客户端 dark 主题一致) */
[data-theme="dark"] {
--background: 0 0% 12%; --foreground: 0 0% 90%;
--card: 0 0% 15%; --card-foreground: 0 0% 90%;
--primary: 210 60% 58%; --primary-foreground: 0 0% 100%;
--secondary: 0 0% 18%; --secondary-foreground: 0 0% 88%;
--muted: 0 0% 18%; --muted-foreground: 0 0% 65%;
--accent: 0 0% 20%; --accent-foreground: 0 0% 90%;
--destructive: 0 60% 55%; --destructive-foreground: 0 0% 95%;
--border: 0 0% 22%; --input: 0 0% 22%;
--ring: 210 60% 58%; --radius: 0.5rem;
--sidebar-bg: 0 0% 10%; --sidebar-border: 0 0% 18%;
--sidebar-item-active-bg: 0 0% 18%; --sidebar-item-hover-bg: 0 0% 16%;
--sidebar-text: 0 0% 60%; --sidebar-text-active: 0 0% 92%;
--input-bg: 0 0% 13%; --input-border: 0 0% 22%;
--input-focus-border: 210 60% 58%; --input-placeholder: 0 0% 45%;
--success: 120 40% 55%; --success-foreground: 0 0% 100%;
--warning: 40 80% 60%; --warning-foreground: 0 0% 10%;
--info: 210 60% 58%; --info-foreground: 0 0% 100%;
}
/* Claude Style 暖色(与 Tauri 客户端 claude 主题一致) */
[data-theme="claude"] {
--background: 40 33% 97%; --foreground: 25 8% 20%;
--card: 40 33% 98%; --card-foreground: 25 8% 20%;
--primary: 22 85% 38%; --primary-foreground: 40 33% 97%;
--secondary: 37 22% 90%; --secondary-foreground: 25 8% 20%;
--muted: 37 22% 90%; --muted-foreground: 25 6% 50%;
--accent: 37 22% 90%; --accent-foreground: 25 8% 20%;
--destructive: 0 72% 51%; --destructive-foreground: 0 0% 100%;
--border: 37 18% 84%; --input: 37 18% 84%;
--ring: 22 85% 38%; --radius: 0.5rem;
--sidebar-bg: 37 22% 92%; --sidebar-border: 37 18% 84%;
--sidebar-item-active-bg: 22 25% 86%; --sidebar-item-hover-bg: 37 22% 88%;
--sidebar-text: 25 6% 50%; --sidebar-text-active: 25 8% 20%;
--input-bg: 40 33% 98%; --input-border: 37 18% 84%;
--input-focus-border: 22 85% 38%; --input-placeholder: 25 6% 62%;
--success: 152 60% 36%; --success-foreground: 0 0% 100%;
--warning: 38 85% 48%; --warning-foreground: 30 20% 12%;
--info: 210 65% 50%; --info-foreground: 0 0% 100%;
}
/* --- P0.2 排版:5 级字号 + tabular-nums --- */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
background: hsl(var(--background)); color: hsl(var(--foreground));
font-size: 14px; line-height: 1.6;
}
/* type scale: 12 / 13 / 14(base) / 17 / 22 */
.text-xs { font-size: 12px; }
.text-sm { font-size: 13px; }
.text-lg { font-size: 17px; }
.text-xl { font-size: 22px; }
/* --- P0.3 间距 token (4pt base) --- */
/* 4 8 12 16 20 24 32 48 */
/* --- P2.7 动效 token --- */
:root {
--ease-out: cubic-bezier(0.25, 1, 0.5, 1);
--dur-fast: 120ms;
--dur-normal: 200ms;
}
/* --- 登录 --- */
.login-mask {
position: fixed; inset: 0; z-index: 999;
background: hsl(var(--background));
display: flex; align-items: center; justify-content: center;
}
.login-box {
width: 340px; padding: 32px;
background: hsl(var(--card)); border: 1px solid hsl(var(--border));
border-radius: 12px;
}
.login-box h2 { font-size: 22px; font-weight: 700; margin-bottom: 20px; }
.login-box .field { margin-bottom: 16px; }
.login-box .err { color: hsl(var(--destructive)); font-size: 13px; margin-bottom: 12px; min-height: 18px; }
/* --- 布局 --- */
.layout { display: flex; height: 100vh; }
.sidebar {
width: 192px; flex-shrink: 0;
background: hsl(var(--sidebar-bg)); border-right: 1px solid hsl(var(--sidebar-border));
display: flex; flex-direction: column; padding: 16px 0;
}
.sidebar-brand { padding: 0 16px 20px; font-size: 17px; font-weight: 700; color: hsl(var(--foreground)); }
.sidebar-brand small { display: block; font-size: 12px; font-weight: 400; color: hsl(var(--muted-foreground)); margin-top: 2px; }
.sidebar-nav { flex: 1; display: flex; flex-direction: column; gap: 2px; padding: 0 8px; }
.sidebar-nav a {
display: flex; align-items: center; gap: 10px; padding: 8px 12px;
border-radius: 8px; text-decoration: none; font-size: 13px;
color: hsl(var(--sidebar-text));
transition: background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);
}
.sidebar-nav a:hover { background: hsl(var(--sidebar-item-hover-bg)); color: hsl(var(--sidebar-text-active)); }
.sidebar-nav a.active { background: hsl(var(--sidebar-item-active-bg)); color: hsl(var(--sidebar-text-active)); font-weight: 600; }
.sidebar-nav a:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: -2px; border-radius: 8px; }
.sidebar-nav a svg { width: 16px; height: 16px; flex-shrink: 0; }
.sidebar-footer {
padding: 12px 12px 0; border-top: 1px solid hsl(var(--sidebar-border));
display: flex; gap: 4px; align-items: center;
}
.theme-btn {
background: none; border: 1px solid hsl(var(--border)); border-radius: 6px;
padding: 3px 8px; font-size: 11px; color: hsl(var(--muted-foreground)); cursor: pointer;
transition: all var(--dur-fast) var(--ease-out);
}
.theme-btn:hover { background: hsl(var(--accent)); color: hsl(var(--accent-foreground)); }
.theme-btn:active { transform: scale(0.95); }
.theme-btn.active { background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); border-color: transparent; }
/* 主内容 */
.main {
flex: 1; overflow-y: auto; padding: 32px;
animation: fadeIn var(--dur-normal) var(--ease-out);
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes spin { to { transform: rotate(360deg); } }
.main::-webkit-scrollbar { width: 6px; }
.main::-webkit-scrollbar-track { background: transparent; }
.main::-webkit-scrollbar-thumb { background: hsl(var(--border)); border-radius: 3px; }
.page-header { margin-bottom: 24px; }
.page-header h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.01em; }
.page-body { max-width: 1100px; }
/* --- P1.4 交互状态 --- */
/* 统一控件高度:正常 32px,小型 28px */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 5px;
border-radius: 8px; padding: 0 14px; height: 32px; font: inherit; font-size: 13px;
cursor: pointer; white-space: nowrap;
transition: all var(--dur-fast) var(--ease-out);
}
.btn:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 2px; }
.btn:active { transform: scale(0.96); }
.btn-primary {
background: hsl(var(--primary)); color: hsl(var(--primary-foreground));
border: 1px solid hsl(var(--primary));
}
.btn-primary:hover { background: hsl(var(--primary) / .88); }
.btn-outline {
background: transparent; color: hsl(var(--foreground));
border: 1px solid hsl(var(--border));
}
.btn-outline:hover { background: hsl(var(--accent)); border-color: hsl(var(--accent)); }
/* 快捷时间 — 小型 28px */
.time-presets { display: flex; gap: 4px; flex-wrap: wrap; align-items: center; }
.time-presets .btn { padding: 0 10px; height: 28px; font-size: 12px; }
.time-presets .btn.active {
background: hsl(var(--primary)); color: hsl(var(--primary-foreground));
border-color: hsl(var(--primary));
}
/* 工具栏 */
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; margin-bottom: 20px; }
.toolbar .field { min-width: 0; }
.toolbar input[type="datetime-local"], .toolbar select { width: 160px; }
.toolbar-actions { display: flex; gap: 6px; align-items: center; }
/* 表单 — 与 .btn 同高 32px */
input, select {
background: hsl(var(--input-bg)); border: 1px solid hsl(var(--input-border));
border-radius: 8px; padding: 0 10px; height: 32px; font: inherit; font-size: 13px;
color: hsl(var(--foreground)); width: 100%;
transition: border-color var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out);
}
input::placeholder { color: hsl(var(--input-placeholder)); }
input:focus, select:focus { outline: none; border-color: hsl(var(--input-focus-border)); box-shadow: 0 0 0 3px hsl(var(--ring) / .12); }
.field { display: flex; flex-direction: column; gap: 4px; }
.field > span { font-size: 12px; color: hsl(var(--muted-foreground)); font-weight: 500; }
/* --- P1.5 仪表盘:非对称布局 --- */
.dash-status { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 20px; }
.status-card {
background: hsl(var(--card)); border: 1px solid hsl(var(--border)); border-radius: 10px;
padding: 14px 16px; display: flex; align-items: center; gap: 12px;
}
.status-card .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.status-card .dot.ok { background: hsl(var(--success)); }
.status-card .dot.off { background: hsl(var(--muted-foreground)); }
.status-card .info { flex: 1; min-width: 0; }
.status-card .info .name { font-size: 13px; font-weight: 600; }
.status-card .info .detail { font-size: 12px; color: hsl(var(--muted-foreground)); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dash-metrics {
display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px;
margin-bottom: 24px;
}
.metric-card {
border-radius: 10px; padding: 14px 16px;
border: 1px solid hsl(var(--border)); background: hsl(var(--card));
}
.metric-card .label { font-size: 12px; color: hsl(var(--muted-foreground)); font-weight: 500; }
.metric-card .value {
font-size: 24px; font-weight: 700; margin-top: 4px; line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.dash-bottom { display: grid; grid-template-columns: 2fr 1fr; gap: 16px; }
/* --- P1.6 减少卡片嵌套 --- */
.section { margin-bottom: 24px; }
.section-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: hsl(var(--foreground)); }
.card {
background: hsl(var(--card)); border: 1px solid hsl(var(--border));
border-radius: 10px; padding: 16px 18px;
}
.card h3 { font-size: 14px; font-weight: 600; margin-bottom: 12px; }
/* 表格 */
table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
th, td { text-align: left; padding: 9px 10px; font-size: 13px; border-bottom: 1px solid hsl(var(--border)); vertical-align: top; }
th { color: hsl(var(--muted-foreground)); font-weight: 600; font-size: 12px; }
tr.clickable { cursor: pointer; transition: background var(--dur-fast) var(--ease-out); }
tr.clickable:hover, tr.drill:hover { background: hsl(var(--accent)); }
tr.clickable.active { background: hsl(var(--sidebar-item-active-bg)); }
tr.drill { cursor: pointer; transition: background var(--dur-fast) var(--ease-out); }
th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { color: hsl(var(--foreground)); }
/* 徽章 */
.badge { display: inline-block; border-radius: 6px; padding: 2px 8px; font-size: 12px; white-space: nowrap; font-weight: 500; }
.badge-success { background: hsl(var(--success) / .12); color: hsl(var(--success)); }
.badge-warning { background: hsl(var(--warning) / .12); color: hsl(var(--warning)); }
.badge-error { background: hsl(var(--destructive) / .12); color: hsl(var(--destructive)); }
.badge-muted { background: hsl(var(--muted)); color: hsl(var(--muted-foreground)); }
/* 分页 */
.pagination { display: flex; align-items: center; justify-content: center; gap: 4px; margin-top: 16px; font-size: 13px; color: hsl(var(--muted-foreground)); }
.pagination button, .pagination .pg {
background: transparent; color: hsl(var(--foreground)); border: 1px solid hsl(var(--border));
border-radius: 6px; padding: 0 10px; height: 28px; font-size: 12px; cursor: pointer;
transition: all var(--dur-fast) var(--ease-out);
}
.pagination button:disabled { opacity: .35; cursor: default; }
.pagination button:not(:disabled):hover, .pagination .pg:hover { background: hsl(var(--accent)); }
.pagination .pg.active { background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); border-color: hsl(var(--primary)); }
.pagination button:focus-visible, .pagination .pg:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 2px; }
.pagination span { font-size: 12px; padding: 0 2px; }
/* 两栏 */
.split { display: grid; grid-template-columns: minmax(0, 1.8fr) minmax(300px, 1fr); gap: 16px; align-items: start; }
/* 详情 */
.detail-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin: 12px 0; }
.detail-item { background: hsl(var(--background)); border: 1px solid hsl(var(--border)); border-radius: 8px; padding: 8px 10px; }
.detail-item .k { font-size: 11px; color: hsl(var(--muted-foreground)); font-weight: 500; }
.detail-item .v { margin-top: 3px; font-size: 13px; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
/* 时间线 — 结构化 */
.timeline-v { position: relative; padding-left: 20px; margin-top: 8px; max-height: 520px; overflow: auto; }
.timeline-v::before { content: ''; position: absolute; left: 6px; top: 0; bottom: 0; width: 2px; background: hsl(var(--border)); }
.tl-item { position: relative; padding-bottom: 16px; }
.tl-item:last-child { padding-bottom: 0; }
.tl-dot { position: absolute; left: -20px; top: 4px; width: 10px; height: 10px; border-radius: 50%; background: hsl(var(--muted-foreground)); border: 2px solid hsl(var(--background)); }
.tl-item.tl-error .tl-dot { background: hsl(var(--destructive)); }
.tl-body { background: hsl(var(--secondary)); border: 1px solid hsl(var(--border)); border-radius: 8px; padding: 8px 12px; }
.tl-item.tl-error .tl-body { border-color: hsl(var(--destructive) / .3); background: hsl(var(--destructive) / .04); }
.tl-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.tl-label { font-size: 13px; font-weight: 600; }
.tl-time { font-size: 11px; color: hsl(var(--muted-foreground)); white-space: nowrap; }
.tl-details { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 4px 12px; }
.tl-kv { font-size: 12px; color: hsl(var(--muted-foreground)); overflow-wrap: anywhere; }
.tl-kv .k { font-weight: 500; color: hsl(var(--foreground)); margin-right: 4px; }
/* Top 列表 */
.top-list { list-style: none; display: grid; gap: 6px; }
.top-list li {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 12px; border: 1px solid hsl(var(--border)); border-radius: 8px;
background: hsl(var(--card)); font-size: 13px;
}
.top-list li strong { font-variant-numeric: tabular-nums; }
/* 日志 */
.log-item { border: 1px solid hsl(var(--border)); border-radius: 8px; padding: 10px 14px; background: hsl(var(--card)); margin-bottom: 8px; }
.log-item .meta { font-size: 12px; color: hsl(var(--muted-foreground)); margin-bottom: 4px; }
.log-item .msg { font-size: 13px; overflow-wrap: anywhere; line-height: 1.5; }
.log-item.log-error { border-color: hsl(var(--destructive) / .3); background: hsl(var(--destructive) / .04); }
.log-item.log-error .meta { color: hsl(var(--destructive)); }
.log-item.log-warn { border-color: hsl(var(--warning) / .3); }
/* 系统信息 */
.info-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
.info-card { background: hsl(var(--card)); border: 1px solid hsl(var(--border)); border-radius: 10px; padding: 20px; }
.info-card h3 { font-size: 14px; font-weight: 600; margin-bottom: 14px; }
.info-row { display: flex; justify-content: space-between; gap: 16px; padding: 8px 0; font-size: 13px; border-bottom: 1px solid hsl(var(--border)); }
.info-row:last-child { border-bottom: none; }
.info-row .k { color: hsl(var(--muted-foreground)); flex-shrink: 0; }
.info-row .v { text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
.info-row .v[title] { cursor: help; text-decoration-line: underline; text-decoration-style: dotted; text-underline-offset: 3px; text-decoration-color: hsl(var(--border)); }
/* 统计 */
.analytics-section { margin-bottom: 24px; }
.analytics-section h2 { font-size: 15px; font-weight: 600; margin-bottom: 12px; }
/* 提示 */
.notice { padding: 12px 16px; border-radius: 8px; font-size: 13px; margin-bottom: 16px; display: none; }
.notice.error { display: block; background: hsl(var(--destructive) / .08); color: hsl(var(--destructive)); border: 1px solid hsl(var(--destructive) / .15); }
/* 趋势条形图 */
.bar-chart { display: flex; align-items: flex-end; gap: 3px; height: 72px; padding-bottom: 22px; padding-top: 18px; }
.bar-chart .bar {
flex: 1; background: hsl(var(--primary)); border-radius: 3px 3px 0 0;
min-width: 4px; max-width: 48px; position: relative; cursor: default;
transition: opacity var(--dur-fast) var(--ease-out);
}
.bar-chart .bar:hover { opacity: .7; }
.bar-chart .bar-label { position: absolute; bottom: -20px; left: 50%; transform: translateX(-50%); font-size: 10px; color: hsl(var(--muted-foreground)); white-space: nowrap; }
.bar-chart .bar-top { position: absolute; top: -16px; left: 50%; transform: translateX(-50%); font-size: 10px; color: hsl(var(--foreground)); font-weight: 600; white-space: nowrap; }
/* 加载 spinner */
.spinner { display: inline-block; width: 18px; height: 18px; border: 2px solid hsl(var(--border)); border-top-color: hsl(var(--primary)); border-radius: 50%; animation: spin .6s linear infinite; vertical-align: middle; margin-right: 8px; }
.loading-msg { color: hsl(var(--muted-foreground)); font-size: 13px; padding: 32px 0; }
/* 空状态 */
.empty-state { text-align: center; padding: 32px 16px; color: hsl(var(--muted-foreground)); font-size: 13px; }
.empty-state .hint { margin-top: 6px; font-size: 12px; }
.hidden { display: none !important; }
/* ===== 移动端适配 ===== */
/* 汉堡按钮(仅移动端显示) */
.mobile-header {
display: none; position: sticky; top: 0; z-index: 50;
background: hsl(var(--sidebar-bg)); border-bottom: 1px solid hsl(var(--sidebar-border));
padding: 10px 16px; align-items: center; justify-content: space-between;
}
.mobile-header .brand { font-size: 15px; font-weight: 700; }
.hamburger {
background: none; border: none; color: hsl(var(--foreground)); cursor: pointer; padding: 4px;
display: flex; align-items: center; justify-content: center;
}
.hamburger svg { width: 22px; height: 22px; }
/* 侧边栏遮罩 */
.sidebar-overlay { display: none; position: fixed; inset: 0; z-index: 90; background: rgba(0,0,0,.35); }
@media (max-width: 768px) {
.layout { flex-direction: column; }
.mobile-header { display: flex; }
/* 侧边栏变为抽屉 */
.sidebar {
position: fixed; top: 0; bottom: 0; z-index: 100;
width: 220px;
transform: translateX(-100%);
transition: transform var(--dur-normal) var(--ease-out);
}
.sidebar.open { transform: translateX(0); }
.sidebar-overlay.open { display: block; }
/* 主内容 */
.main { padding: 16px; }
.page-header { margin-bottom: 16px; }
.page-header h1 { font-size: 18px; }
.page-body { max-width: 100%; }
/* 仪表盘 */
.dash-status { grid-template-columns: 1fr; }
.dash-metrics { grid-template-columns: repeat(2, 1fr); }
.dash-bottom { grid-template-columns: 1fr; }
/* 会话管理:split 变单列 */
.split { grid-template-columns: 1fr; }
/* 表格横向滚动 */
.table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
table { min-width: 600px; }
/* 详情网格单列 */
.detail-grid { grid-template-columns: 1fr; }
/* 工具栏 */
.toolbar { gap: 8px; }
.toolbar input[type="datetime-local"], .toolbar select { width: 140px; }
/* 系统信息 */
.info-grid { grid-template-columns: 1fr; }
/* 登录 */
.login-box { width: calc(100vw - 32px); max-width: 340px; padding: 24px; }
}
@media (max-width: 480px) {
.dash-metrics { grid-template-columns: 1fr 1fr; }
.metric-card .value { font-size: 20px; }
.time-presets .btn { padding: 4px 8px; font-size: 11px; }
.toolbar input[type="datetime-local"], .toolbar select { width: 120px; }
}
/* Feedback detail */
.fb-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
.fb-tag { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; padding: 3px 10px; border-radius: 6px; background: hsl(var(--muted)); color: hsl(var(--foreground)); }
.fb-tag .k { color: hsl(var(--muted-foreground)); font-weight: 500; }
.fb-section { margin-bottom: 14px; }
.fb-label { font-size: 12px; font-weight: 600; color: hsl(var(--muted-foreground)); margin-bottom: 6px; text-transform: uppercase; letter-spacing: .3px; }
.fb-body { font-size: 13px; line-height: 1.7; color: hsl(var(--foreground)); white-space: pre-wrap; word-break: break-word; }
.fb-scroll { max-height: 180px; overflow-y: auto; padding: 10px 14px; background: hsl(var(--muted)); border-radius: 8px; }
.fb-mono { font-family: ui-monospace, 'SF Mono', 'Cascadia Code', 'Consolas', monospace; font-size: 12px; line-height: 1.6; }
-34
View File
@@ -1,34 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SayIt Admin</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48.png">
<link rel="icon" type="image/png" sizes="192x192" href="/favicon-192.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link href="/fonts/nunito.css" rel="stylesheet">
<link rel="stylesheet" href="/admin.css">
</head>
<body>
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<div class="layout">
<div class="mobile-header">
<span class="brand"><img src="/logo-72.png" alt="" width="22" height="22" style="vertical-align:middle;margin-right:6px;border-radius:4px"><span style="font-family:'Nunito',sans-serif;font-weight:800">SayIt</span> Admin</span>
<button class="hamburger" id="menu-btn" aria-label="菜单">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
</div>
<nav class="sidebar" id="sidebar">
<div class="sidebar-brand"><img src="/logo-72.png" alt="SayIt" width="28" height="28" style="vertical-align:middle;margin-right:8px;border-radius:6px"><span style="font-family:'Nunito',sans-serif;font-weight:800">SayIt</span><small>管理后台</small></div>
<div class="sidebar-nav"></div>
<div class="sidebar-footer"></div>
</nav>
<div class="main"></div>
</div>
<script src="/admin.js"></script>
</body>
</html>
-587
View File
@@ -1,587 +0,0 @@
/* SayIt Admin v4 */
;(function () {
'use strict'
const $ = (s, c) => (c || document).querySelector(s)
const $$ = (s, c) => [...(c || document).querySelectorAll(s)]
function esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML }
function fmtDuration(ms) {
if (!ms && ms !== 0) return '-'
const sec = ms / 1000
if (sec < 60) return `${sec.toFixed(1)}s`
const min = sec / 60
if (min < 60) return `${Math.floor(min)}m${Math.round(sec % 60)}s`
return `${Math.floor(min / 60)}h${Math.round(min % 60)}m`
}
function fmtMs(ms) { if (!ms && ms !== 0) return '-'; return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms` }
function fmtTime(ts) { if (!ts) return '-'; return new Date(ts).toLocaleString('zh-CN', { hour12: false }) }
function pct(v) { return `${((v || 0) * 100).toFixed(1)}%` }
function shortModel(m) { if (!m) return '-'; const p = m.split('/'); return p[p.length - 1] }
function cmp(cur, prev, key) {
if (!prev || !prev[key]) return ''
const diff = cur[key] - prev[key]
if (diff === 0) return ''
const p = prev[key] ? Math.round(Math.abs(diff) / prev[key] * 100) : 0
return diff > 0 ? ` <span style="color:hsl(var(--success));font-size:11px" title="较上一同等时段">↑${p}%</span>` : ` <span style="color:hsl(var(--destructive));font-size:11px" title="较上一同等时段">↓${p}%</span>`
}
const LOADING = '<div class="loading-msg"><span class="spinner"></span>加载中…</div>'
const EMPTY = (msg, hint) => `<div class="empty-state">${msg}${hint ? `<div class="hint">${hint}</div>` : ''}</div>`
/* Auth */
let _auth = sessionStorage.getItem('sayit-admin-auth') || localStorage.getItem('sayit-admin-auth') || ''
async function api(path, params) {
if (!_auth) throw new Error('未登录')
const qs = new URLSearchParams()
if (params) Object.entries(params).forEach(([k, v]) => { if (v != null && v !== '') qs.set(k, String(v)) })
const q = qs.toString(); const url = q ? `${path}?${q}` : path
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), 15000)
let res
try {
res = await fetch(url, { headers: { Accept: 'application/json', Authorization: _auth }, signal: ctrl.signal })
} catch (e) {
if (e.name === 'AbortError') throw new Error('请求超时,请检查网络连接')
throw new Error('网络连接失败,请检查服务器状态')
} finally { clearTimeout(timer) }
if (res.status === 401) { sessionStorage.removeItem('sayit-admin-auth'); localStorage.removeItem('sayit-admin-auth'); _auth = ''; showLogin('会话已过期,请重新登录'); throw new Error('401') }
if (!res.ok) throw new Error(`请求失败 (${res.status}),请稍后重试`)
return res.json()
}
function statusBadge(s) {
const c = s === 'success' ? 'badge-success' : (s === 'failed' ? 'badge-error' : 'badge-warning')
return `<span class="badge ${c}">${s}</span>`
}
/* CSV export helper */
function downloadCSV(filename, headers, rows) {
const escape = v => { const s = String(v ?? ''); return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s }
const lines = [headers.map(escape).join(','), ...rows.map(r => r.map(escape).join(','))]
const blob = new Blob(['\uFEFF' + lines.join('\n')], { type: 'text/csv;charset=utf-8' })
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; a.click()
URL.revokeObjectURL(a.href)
}
/* Theme */
const THEMES = [{id:'light',label:'浅色'},{id:'dark',label:'深色'},{id:'claude',label:'暖色'}]
function applyTheme(id) {
document.documentElement.dataset.theme = id
localStorage.setItem('sayit-admin-theme', id)
$$('.theme-btn').forEach(b => b.classList.toggle('active', b.dataset.theme === id))
}
/* Time presets */
const TIME_PRESETS = [
{ label: '1小时', hours: 1 }, { label: '6小时', hours: 6 },
{ label: '今天', hours: -1 }, { label: '7天', hours: 168 },
{ label: '15天', hours: 360 }, { label: '全部', hours: 0 },
]
function timePresetHTML(prefix) {
return `<div class="time-presets" id="${prefix}-presets">${TIME_PRESETS.map((p, i) =>
`<button class="btn btn-outline${i === 3 ? ' active' : ''}" data-hours="${p.hours}">${p.label}</button>`
).join('')}</div><label class="field"><span>开始</span><input id="${prefix}-from" type="datetime-local"></label><label class="field"><span>结束</span><input id="${prefix}-to" type="datetime-local"></label>`
}
function bindTimePresets(prefix, cb) {
const c = $(`#${prefix}-presets`); if (!c) return
c.addEventListener('click', e => {
const btn = e.target.closest('button'); if (!btn) return
$$('button', c).forEach(b => b.classList.remove('active')); btn.classList.add('active')
const h = Number(btn.dataset.hours), range = presetToRange(h)
const f = $(`#${prefix}-from`), t = $(`#${prefix}-to`)
if (f) f.value = range.from ? toLocal(range.from) : ''; if (t) t.value = ''
cb()
})
const clearActive = () => $$('button', c).forEach(b => b.classList.remove('active'))
$(`#${prefix}-from`)?.addEventListener('input', clearActive)
$(`#${prefix}-to`)?.addEventListener('input', clearActive)
}
function presetToRange(h) {
if (h === 0) return {}
const now = new Date()
if (h === -1) return { from: new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString() }
return { from: new Date(now.getTime() - h * 3600000).toISOString() }
}
function toLocal(iso) { return iso ? new Date(new Date(iso).getTime() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16) : '' }
function getTimeRange(prefix) {
const p = {}
const f = $(`#${prefix}-from`)?.value; if (f) p.from = new Date(f).toISOString()
const t = $(`#${prefix}-to`)?.value; if (t) p.to = new Date(t).toISOString()
return p
}
/* URL hash state (#/sessions?status=failed&user_id=xxx) */
function hashParams() {
const idx = location.hash.indexOf('?')
if (idx < 0) return {}
return Object.fromEntries(new URLSearchParams(location.hash.slice(idx + 1)))
}
function setHashParams(params) {
const h = location.hash.replace(/\?.*$/, '')
const qs = new URLSearchParams()
Object.entries(params).forEach(([k, v]) => { if (v != null && v !== '') qs.set(k, v) })
const s = qs.toString()
history.replaceState(null, '', s ? `${h}?${s}` : h)
}
function restoreField(id, key, params) {
const el = $(`#${id}`); if (el && params[key]) el.value = params[key]
}
/* Icons */
const IC = {
dashboard: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>',
sessions: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4z"/></svg>',
analytics: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>',
logs: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>',
feedback: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>',
system: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>',
}
/* Router */
const ROUTES = [
{ hash: '', label: '仪表盘', icon: 'dashboard', render: renderDashboard },
{ hash: 'sessions', label: '会话管理', icon: 'sessions', render: renderSessions },
{ hash: 'analytics', label: '统计分析', icon: 'analytics', render: renderAnalytics },
{ hash: 'feedback', label: '用户反馈', icon: 'feedback', render: renderFeedback },
{ hash: 'logs', label: '服务日志', icon: 'logs', render: renderLogs },
{ hash: 'system', label: '系统信息', icon: 'system', render: renderSystem },
]
let _autoRefreshTimer = null
let _logTailTimer = null
function _clearTimers() { clearInterval(_autoRefreshTimer); _autoRefreshTimer = null; clearInterval(_logTailTimer); _logTailTimer = null }
function navigate() {
if (!_auth) return
_clearTimers()
const h = location.hash.replace(/^#\/?/, '').replace(/\?.*$/, '')
const route = ROUTES.find(r => r.hash === h) || ROUTES[0]
$$('.sidebar-nav a').forEach(a => a.classList.toggle('active', a.dataset.route === route.hash))
const main = $('.main'); main.innerHTML = LOADING
route.render(main).catch(err => {
if (String(err) === 'Error: 401') return
main.innerHTML = `<div class="notice error" style="display:block">${esc(String(err))}</div>`
})
}
/* Login */
function showLogin(msg) {
let m = $('.login-mask')
if (!m) {
m = document.createElement('div'); m.className = 'login-mask'
m.innerHTML = `<div class="login-box"><h2 style="font-family:'Nunito',sans-serif;font-weight:800">SayIt Admin</h2><div class="err" id="login-err"></div><div class="field"><span>用户名</span><input id="login-user" autocomplete="username"></div><div class="field"><span>密码</span><input id="login-pass" type="password" autocomplete="current-password"></div><button class="btn btn-primary" style="width:100%;margin-top:20px" id="login-btn">登录</button></div>`
document.body.appendChild(m)
$('#login-btn').addEventListener('click', doLogin)
$('#login-pass').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin() })
$('#login-user').addEventListener('keydown', e => { if (e.key === 'Enter') $('#login-pass').focus() })
}
m.classList.remove('hidden')
if (msg) $('#login-err').textContent = msg
setTimeout(() => $('#login-user')?.focus(), 50)
}
async function doLogin() {
const u = $('#login-user').value.trim(), p = $('#login-pass').value
if (!u || !p) { $('#login-err').textContent = '请输入用户名和密码'; return }
const h = 'Basic ' + btoa(u + ':' + p)
try {
const res = await fetch('/admin/api/overview', { headers: { Accept: 'application/json', Authorization: h } })
if (res.status === 401) { $('#login-err').textContent = '用户名或密码错误'; return }
if (!res.ok) { $('#login-err').textContent = '无法连接服务器'; return }
} catch (e) { $('#login-err').textContent = '网络连接失败,请检查服务器状态'; return }
_auth = h; sessionStorage.setItem('sayit-admin-auth', h); localStorage.setItem('sayit-admin-auth', h)
$('.login-mask').classList.add('hidden'); navigate()
}
/* Boot */
function boot() {
applyTheme(localStorage.getItem('sayit-admin-theme') || 'light')
$('.sidebar-nav').innerHTML = ROUTES.map(r =>
`<a href="#/${r.hash}" data-route="${r.hash}">${IC[r.icon]}<span>${r.label}</span></a>`
).join('')
$('.sidebar-footer').innerHTML = THEMES.map(t =>
`<button class="theme-btn" data-theme="${t.id}">${t.label}</button>`
).join('')
$$('.theme-btn').forEach(b => b.addEventListener('click', () => applyTheme(b.dataset.theme)))
// 退出按钮插入到 sidebar 底部
const logoutDiv = document.createElement('div')
logoutDiv.style.cssText = 'padding:8px 12px 0'
logoutDiv.innerHTML = '<button class="btn btn-outline" style="width:100%;font-size:12px;height:28px" id="logout-btn">退出登录</button>'
$('#sidebar').appendChild(logoutDiv)
$('#logout-btn').addEventListener('click', () => { sessionStorage.removeItem('sayit-admin-auth'); localStorage.removeItem('sayit-admin-auth'); _auth = ''; showLogin() })
applyTheme(localStorage.getItem('sayit-admin-theme') || 'light')
/* 移动端汉堡菜单 */
const sidebar = $('#sidebar'), overlay = $('#sidebar-overlay')
const closeSidebar = () => { sidebar.classList.remove('open'); overlay.classList.remove('open') }
$('#menu-btn')?.addEventListener('click', () => { sidebar.classList.toggle('open'); overlay.classList.toggle('open') })
overlay?.addEventListener('click', closeSidebar)
$('.sidebar-nav').addEventListener('click', () => { if (window.innerWidth <= 768) closeSidebar() })
window.addEventListener('hashchange', navigate)
if (!_auth) { showLogin(); return }
navigate()
}
document.addEventListener('DOMContentLoaded', boot)
/* === Dashboard === */
async function renderDashboard(el) {
el.innerHTML = `<div class="page-header"><h1>仪表盘</h1></div><div class="page-body">
<div id="d-status" class="dash-status"></div>
<div class="toolbar">${timePresetHTML('d')}<div class="toolbar-actions"><label class="field" style="flex-direction:row;align-items:center;gap:6px"><span>自动刷新</span><select id="d-auto" style="width:80px"><option value="0">关闭</option><option value="30">30s</option><option value="60" selected>60s</option></select></label><button class="btn btn-outline" id="d-apply">查询</button></div></div>
<div id="d-metrics" class="dash-metrics" style="margin-top:16px"></div>
<div class="dash-bottom"><div class="card"><h3>趋势</h3><div id="d-trend"></div></div><div class="card"><h3>最近异常</h3><ul class="top-list" id="d-errors"></ul></div></div>
</div>`
const loadData = async () => {
const range = getTimeRange('d')
const ov = await api('/admin/api/overview', range)
let prevOv = null
if (range.from) {
const fromDate = new Date(range.from), dur = Date.now() - fromDate.getTime()
try { prevOv = await api('/admin/api/overview', { from: new Date(fromDate.getTime() - dur).toISOString(), to: range.from }) } catch(e) {}
}
$('#d-metrics').innerHTML = [
metricCard('会话数', ov.total_sessions + cmp(ov, prevOv, 'total_sessions')),
metricCard('总音频', fmtDuration(ov.total_audio_ms) + cmp(ov, prevOv, 'total_audio_ms')),
metricCard('总按住', fmtDuration(ov.total_hold_ms)),
metricCard('平均 ASR', fmtMs(ov.avg_asr_ms)),
metricCard('平均 LLM', fmtMs(ov.avg_llm_ms)),
metricCard('空结果率', pct(ov.empty_result_rate)), metricCard('错误率', pct(ov.error_rate)),
].join('')
/* 最近异常会话 */
try {
const errData = await api('/admin/api/sessions', { ...range, status: 'failed', limit: 5, offset: 0 })
const items = errData.items || []
$('#d-errors').innerHTML = items.length ? items.map(i =>
`<li class="clickable" data-sid="${i.session_id}"><span>${fmtTime(i.started_at)} · ${esc(i.error_code || i.status)}</span><span>${esc(i.process_name || '-')}</span></li>`
).join('') : EMPTY('暂无异常', '一切正常运行中')
$$('li.clickable', $('#d-errors')).forEach(li => li.addEventListener('click', () => {
location.hash = `#/sessions?status=failed`
}))
} catch(e) { $('#d-errors').innerHTML = EMPTY('加载失败') }
await loadTrend()
}
/* 趋势图跟随时间过滤器 */
const loadTrend = async () => {
const range = getTimeRange('d')
const from = range.from ? new Date(range.from) : new Date(Date.now() - 15 * 86400000)
const spanH = (Date.now() - from.getTime()) / 3600000
const bucket = spanH <= 72 ? 'hour' : 'day'
const tl = await api('/admin/api/metrics/timeline', { bucket, from: from.toISOString(), to: range.to || undefined })
renderBarChart($('#d-trend'), tl)
}
await loadTrend()
const [health, sys] = await Promise.all([api('/admin/api/healthz-details'), api('/admin/api/system-info')])
const llm = sys.llm || {}, wd = health.web_demo || {}, res = sys.resources || {}
const gpuCard = (res.gpus || []).length ? statusCard('GPU', true, `${res.gpus[0].util_percent}% · ${res.gpus[0].mem_used_mb}/${res.gpus[0].mem_total_mb}MB · ${res.gpus[0].temp_c}°C`) : ''
$('#d-status').innerHTML = [
statusCard('ASR', health.asr, shortModel(health.asr_model)),
statusCard('LLM', health.llm, llm.provider ? `${llm.provider} / ${llm.model || '-'}` : ''),
statusCard('WebSocket', true, `${health.active_connections || 0} 个活跃连接`),
statusCard('CPU/内存', true, `CPU ${res.cpu_percent ?? '-'}% · 内存 ${res.mem_percent ?? '-'}%`),
gpuCard,
].filter(Boolean).join('')
bindTimePresets('d', loadData)
$('#d-apply')?.addEventListener('click', loadData)
/* 自动刷新 */
const setupAutoRefresh = () => {
clearInterval(_autoRefreshTimer); _autoRefreshTimer = null
const sec = Number($('#d-auto')?.value || 0)
if (sec > 0) _autoRefreshTimer = setInterval(loadData, sec * 1000)
}
$('#d-auto')?.addEventListener('change', setupAutoRefresh)
setupAutoRefresh()
await loadData()
}
function statusCard(name, ok, detail) {
return `<div class="status-card"><div class="dot ${ok ? 'ok' : 'off'}"></div><div class="info"><div class="name">${name}</div>${detail ? `<div class="detail" title="${esc(detail)}">${esc(detail)}</div>` : ''}</div></div>`
}
function metricCard(label, value) {
return `<div class="metric-card"><div class="label">${label}</div><div class="value">${value}</div></div>`
}
function renderBarChart(el, data) {
if (!data || !data.length) { el.innerHTML = EMPTY('暂无趋势数据', '选择更大的时间范围查看'); return }
const max = Math.max(...data.map(d => d.sessions), 1)
el.innerHTML = `<div class="bar-chart">${data.map(d => {
const h = Math.max(4, (d.sessions / max) * 100), lbl = (d.bucket || '').slice(5)
return `<div class="bar" style="height:${h}%" title="${d.bucket}: ${d.sessions} 会话"><span class="bar-top">${d.sessions}</span><span class="bar-label">${lbl}</span></div>`
}).join('')}</div>`
}
/* === Sessions === */
let SS = { offset: 0, limit: 30, sel: null, sort: 'time_desc' }
async function renderSessions(el) {
SS = { offset: 0, limit: 30, sel: null, sort: 'time_desc' }
el.innerHTML = `<div class="page-header"><h1>会话管理</h1></div><div class="page-body">
<div class="toolbar">${timePresetHTML('s')}</div>
<div class="toolbar">
<label class="field"><span>用户</span><input id="s-user" placeholder="user id" style="width:110px"></label>
<label class="field"><span>应用</span><input id="s-app" placeholder="process" style="width:110px"></label>
<label class="field"><span>状态</span><select id="s-status" style="width:110px"><option value="">全部</option><option value="success">success</option><option value="empty_result">empty_result</option><option value="failed">failed</option><option value="disconnected">disconnected</option><option value="short_audio">short_audio</option><option value="empty_audio">empty_audio</option></select></label>
<label class="field"><span>AI</span><select id="s-ai" style="width:80px"><option value="">全部</option><option value="true">启用</option><option value="false">未启用</option></select></label>
<label class="field"><span>来源</span><select id="s-source" style="width:100px"><option value="">全部</option><option value="live">实时录音</option><option value="history_reprocess">重新识别</option></select></label>
<div class="toolbar-actions"><button class="btn btn-outline" id="s-export">导出 CSV</button><button class="btn btn-outline" id="s-apply">查询</button></div>
</div>
<div class="split">
<div><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px"><span class="section-title" style="margin:0">会话列表</span><span id="s-count" style="font-size:12px;color:hsl(var(--muted-foreground))"></span></div><div id="s-table"></div><div class="pagination" id="s-pager"></div></div>
<div class="card"><h3>会话详情</h3><div id="s-detail">${EMPTY('选择左侧会话查看详情')}</div></div>
</div>
</div>`
/* Restore filters from URL hash */
const hp = hashParams()
restoreField('s-user', 'user_id', hp)
restoreField('s-app', 'app', hp)
restoreField('s-status', 'status', hp)
restoreField('s-ai', 'ai_enabled', hp)
restoreField('s-source', 'source', hp)
$('#s-apply').addEventListener('click', () => { SS.offset = 0; loadSessions() })
$('#s-export').addEventListener('click', exportSessions)
bindTimePresets('s', () => { SS.offset = 0; loadSessions() })
await loadSessions()
}
function sessFilters() {
const p = getTimeRange('s')
const u = $('#s-user')?.value; if (u) p.user_id = u
const a = $('#s-app')?.value; if (a) p.app = a
const s = $('#s-status')?.value; if (s) p.status = s
const ai = $('#s-ai')?.value; if (ai) p.ai_enabled = ai
const src = $('#s-source')?.value; if (src) p.source = src
/* Persist to URL */
setHashParams({ user_id: u, app: a, status: s, ai_enabled: ai, source: src })
return p
}
async function exportSessions() {
try {
const data = await api('/admin/api/sessions', { ...sessFilters(), limit: 200, offset: 0 })
const items = data.items || []
if (!items.length) return
const headers = ['时间','用户','IP','应用','按住(ms)','音频(ms)','ASR(ms)','LLM(ms)','状态','来源']
const rows = items.map(i => [fmtTime(i.started_at), i.user_name||i.user_id||'web', i.forwarded_for||i.client_ip||'', i.process_name||'', i.ptt_hold_ms||'', i.audio_duration_ms||'', i.asr_ms||'', i.llm_ms||'', i.status, i.source||'live'])
downloadCSV('sessions.csv', headers, rows)
} catch(e) {}
}
async function loadSessions() {
const data = await api('/admin/api/sessions', { ...sessFilters(), limit: SS.limit, offset: SS.offset, sort: SS.sort })
const items = data.items || [], total = data.total || 0
$('#s-count').textContent = `${total} 条`
const sortIcon = (col) => { if (!SS.sort.startsWith(col)) return ''; return SS.sort.endsWith('_desc') ? ' ↓' : ' ↑' }
if (items.length) {
$('#s-table').innerHTML = `<div class="table-scroll"><table><thead><tr><th class="sortable" data-col="time">时间${sortIcon('time')}</th><th>用户</th><th>IP</th><th>应用</th><th class="sortable" data-col="audio">按住/音频${sortIcon('audio')}</th><th class="sortable" data-col="asr">ASR/LLM${sortIcon('asr')}</th><th>状态</th></tr></thead><tbody>${items.map(i => {
const ip = i.forwarded_for || i.client_ip || '-'
const user = i.user_name || i.user_id || '<span style="color:hsl(var(--muted-foreground))">web</span>'
const srcTag = i.source === 'history_reprocess' ? ' <span class="badge badge-muted" style="font-size:10px;padding:1px 5px">重识</span>' : ''
return `<tr class="clickable${SS.sel === i.session_id ? ' active' : ''}" data-sid="${i.session_id}"><td>${fmtTime(i.started_at)}</td><td>${user}</td><td>${esc(ip)}</td><td>${esc(i.process_name || '-')}</td><td>${fmtDuration(i.ptt_hold_ms)} / ${fmtDuration(i.audio_duration_ms)}</td><td>${fmtMs(i.asr_ms)} / ${i.llm_enabled ? fmtMs(i.llm_ms) : '-'}</td><td>${statusBadge(i.status)}${srcTag}</td></tr>`
}).join('')}</tbody></table></div>`
} else { $('#s-table').innerHTML = EMPTY('当前筛选条件下暂无会话', '尝试调整时间范围或筛选条件') }
/* Pagination with page numbers (#12) */
const tp = Math.ceil(total / SS.limit), cp = Math.floor(SS.offset / SS.limit) + 1
if (tp > 1) {
let pages = ''
const addPage = (n) => { pages += `<button class="pg ${n===cp?'active':''}" data-page="${n}">${n}</button>` }
addPage(1)
if (cp > 3) pages += '<span>…</span>'
for (let i = Math.max(2, cp - 1); i <= Math.min(tp - 1, cp + 1); i++) addPage(i)
if (cp < tp - 2) pages += '<span>…</span>'
if (tp > 1) addPage(tp)
$('#s-pager').innerHTML = `<button ${cp<=1?'disabled':''} id="s-prev">上一页</button>${pages}<button ${cp>=tp?'disabled':''} id="s-next">下一页</button>`
$$('.pg', $('#s-pager')).forEach(b => b.addEventListener('click', () => { SS.offset = (Number(b.dataset.page) - 1) * SS.limit; loadSessions() }))
} else { $('#s-pager').innerHTML = '' }
$('#s-prev')?.addEventListener('click', () => { SS.offset = Math.max(0, SS.offset - SS.limit); loadSessions() })
$('#s-next')?.addEventListener('click', () => { SS.offset += SS.limit; loadSessions() })
$$('tr.clickable', $('#s-table')).forEach(tr => tr.addEventListener('click', () => loadDetail(tr.dataset.sid)))
/* Sort by clicking column headers */
$$('th.sortable', $('#s-table')).forEach(th => th.addEventListener('click', () => {
const col = th.dataset.col
SS.sort = SS.sort === `${col}_desc` ? `${col}_asc` : `${col}_desc`
SS.offset = 0; loadSessions()
}))
if (items.length && !SS.sel) loadDetail(items[0].session_id)
}
async function loadDetail(sid) {
SS.sel = sid
$$('tr.clickable', $('#s-table')).forEach(tr => tr.classList.toggle('active', tr.dataset.sid === sid))
const d = await api(`/admin/api/sessions/${sid}`)
const ip = d.forwarded_for || d.client_ip || '-'
const startEvt = (d.events || []).find(e => e.event_type === 'session_started')
const cm = (startEvt && startEvt.payload && startEvt.payload.client_meta) || {}
/* #5 — Grouped detail sections */
const userInfo = [['用户', d.user_name || d.user_id || '-'],['设备', d.hostname || '-'],['设备 ID', d.device_id || '-'],['客户端', `${d.client_version || '-'} / ${d.platform || '-'}`],['系统', cm.os_version || '-'],['语言', cm.system_locale || '-']]
const netInfo = [['公网 IP', ip],['内网 IP', d.local_ip || '-']]
const sessInfo = [['来源', d.source === 'history_reprocess' ? '重新识别' : '实时录音'],['应用', d.process_name || '-'],['窗口', d.window_title || '-'],['模型', `${shortModel(d.asr_model)} / ${d.llm_model || '-'}`],['节点', d.node_id || '-'],['状态', d.status || '-'],['错误', d.error_code ? `${d.error_code}: ${d.error_message || ''}` : '-']]
const timing = [['PTT', fmtMs(d.ptt_hold_ms)],['音频', fmtMs(d.audio_duration_ms)],['VAD', fmtMs(d.vad_ms)],['排队', fmtMs(d.queue_wait_ms)],['ASR推理', fmtMs(d.infer_ms)],['Batch', fmtMs(d.batch_exec_ms)],['ASR总', fmtMs(d.asr_ms)],['LLM总', d.llm_enabled ? fmtMs(d.llm_ms) : '-']]
const detailGroup = (title, pairs) => `<div class="section-title" style="margin-top:14px">${title}</div><div class="detail-grid">${pairs.map(([k, v]) => `<div class="detail-item"><div class="k">${k}</div><div class="v">${esc(String(v))}</div></div>`).join('')}</div>`
/* #2 — Structured event timeline */
const LABELS = {session_started:'会话开始',session_stopped:'录音停止',asr_completed:'ASR 完成',llm_completed:'LLM 完成',session_finished:'会话结束',session_failed:'会话失败',session_status:'状态变更',ws_connected:'连接建立',ws_disconnected:'连接断开',audio_saved:'音频保存'}
const eventsHTML = (d.events || []).map(e => {
const p = e.payload || {}, label = LABELS[e.event_type] || e.event_type
const isErr = e.event_type === 'session_failed'
const details = Object.keys(p).length ? Object.entries(p).map(([k,v]) => `<span class="tl-kv"><span class="k">${esc(k)}</span> ${esc(typeof v === 'object' ? JSON.stringify(v) : String(v))}</span>`).join('') : ''
return `<div class="tl-item${isErr ? ' tl-error' : ''}"><div class="tl-dot"></div><div class="tl-body"><div class="tl-head"><span class="tl-label">${label}</span><span class="tl-time">${fmtTime(e.event_time)}</span></div>${details ? `<div class="tl-details">${details}</div>` : ''}</div></div>`
}).join('') || EMPTY('暂无事件')
$('#s-detail').innerHTML = `
${detailGroup('用户信息', userInfo)}
${detailGroup('网络信息', netInfo)}
${detailGroup('会话信息', sessInfo)}
${detailGroup('耗时拆解', timing)}
<div class="section-title" style="margin-top:14px">事件时间线</div>
<div class="timeline-v">${eventsHTML}</div>`
}
/* === Analytics === */
async function renderAnalytics(el) {
el.innerHTML = `<div class="page-header"><h1>统计分析</h1></div><div class="page-body">
<div class="toolbar">${timePresetHTML('a')}<div class="toolbar-actions"><button class="btn btn-outline" id="a-apply">查询</button></div></div>
<div id="a-content">${LOADING}</div></div>`
const load = async () => {
const r = getTimeRange('a')
const [byApp, byUser, byModel, durDist] = await Promise.all([api('/admin/api/metrics/by-app', r), api('/admin/api/metrics/by-user', r), api('/admin/api/metrics/by-model', r), api('/admin/api/metrics/duration-distribution', r)])
$('#a-content').innerHTML = aDurDist(durDist) + aBarChart(byApp) + aTable('按应用', byApp, 'app') + aTable('按用户', byUser, 'user_id') + aTable('按 ASR 模型', byModel.asr || []) + aTable('按 LLM 模型', byModel.llm || []) + await aPerfSection(r)
/* #7 drill-down: click row to jump to sessions */
$$('tr.drill', $('#a-content')).forEach(tr => tr.addEventListener('click', () => {
const key = tr.dataset.drillKey, val = tr.dataset.drillVal
if (key && val) location.hash = `#/sessions?${key}=${encodeURIComponent(val)}`
}))
}
$('#a-apply').addEventListener('click', load); bindTimePresets('a', load); await load()
}
function aDurDist(rows) {
if (!rows || !rows.length) return ''
const total = rows.reduce((s, r) => s + r.count, 0)
if (!total) return ''
const max = Math.max(...rows.map(r => r.count), 1)
return `<div class="analytics-section"><h2>音频时长分布</h2><div class="card" style="padding:20px 18px 12px"><div class="bar-chart" style="height:80px">${rows.map(r => {
const h = Math.max(3, (r.count / max) * 100)
const p = Math.round(r.count / total * 100)
return `<div class="bar" style="height:${h}%" title="${r.label}: ${r.count} 条 (${p}%)"><span class="bar-top">${r.count}</span><span class="bar-label">${r.label}</span></div>`
}).join('')}</div><div style="text-align:center;margin-top:24px;font-size:12px;color:hsl(var(--muted-foreground))">共 ${total} 条会话</div></div></div>`
}
async function aPerfSection(range) {
try {
const d = await api('/admin/api/metrics/percentiles', range)
if (!d || !d.asr_p50) return ''
return `<div class="analytics-section"><h2>性能分位数</h2><table><thead><tr><th>指标</th><th>P50</th><th>P95</th><th>P99</th></tr></thead><tbody><tr><td>ASR 耗时</td><td>${fmtMs(d.asr_p50)}</td><td>${fmtMs(d.asr_p95)}</td><td>${fmtMs(d.asr_p99)}</td></tr><tr><td>LLM 耗时</td><td>${fmtMs(d.llm_p50)}</td><td>${fmtMs(d.llm_p95)}</td><td>${fmtMs(d.llm_p99)}</td></tr></tbody></table></div>`
} catch(e) { return '' }
}
function aBarChart(rows) {
if (!rows || !rows.length) return ''
const max = Math.max(...rows.map(r => r.sessions), 1)
return `<div class="analytics-section"><h2>应用分布</h2><div class="card" style="padding:20px 18px 12px"><div class="bar-chart" style="height:80px">${rows.slice(0, 10).map(r => {
const h = Math.max(3, (r.sessions / max) * 100)
return `<div class="bar" style="height:${h}%" title="${esc(r.label)}: ${r.sessions} 会话"><span class="bar-top">${r.sessions}</span><span class="bar-label">${esc(r.label).slice(0, 10)}</span></div>`
}).join('')}</div></div></div>`
}
function aTable(title, rows, drillKey) {
if (!rows || !rows.length) return `<div class="analytics-section"><h2>${title}</h2>${EMPTY('暂无数据')}</div>`
return `<div class="analytics-section"><h2>${title}</h2><table><thead><tr><th>名称</th><th>会话</th><th>总音频</th><th>平均ASR</th><th>平均LLM</th><th>失败</th></tr></thead><tbody>${rows.map(r =>
`<tr${drillKey ? ` class="drill clickable" data-drill-key="${drillKey}" data-drill-val="${esc(r.label || '')}"` : ''}><td>${esc(r.label || '-')}</td><td>${r.sessions}</td><td>${fmtDuration(r.total_audio_ms)}</td><td>${fmtMs(r.avg_asr_ms)}</td><td>${fmtMs(r.avg_llm_ms)}</td><td>${r.failed_sessions || 0}</td></tr>`
).join('')}</tbody></table></div>`
}
/* === Feedback === */
async function renderFeedback(el) {
el.innerHTML = `<div class="page-header"><h1>用户反馈</h1></div><div class="page-body">
<div class="toolbar"><div class="toolbar-actions"><button class="btn btn-outline" id="fb-refresh">刷新</button></div></div>
<div id="fb-list">${LOADING}</div>
<div id="fb-detail" class="card" style="display:none;margin-top:16px;overflow:hidden"></div></div>`
let _page = 0, _limit = 20
const load = async () => {
const data = await api('/admin/api/feedback', { limit: _limit, offset: _page * _limit })
const items = data.items || [], total = data.total || 0
if (!items.length) { $('#fb-list').innerHTML = EMPTY('暂无反馈'); return }
const rows = items.map(i => {
const t = new Date(i.created_at).toLocaleString('zh-CN', { hour12: false })
const tr = i.transcript_json
const hasTranscript = tr && (tr.asr_text || tr.ai_text)
return `<tr class="clickable" data-id="${i.id}"><td>${t}</td><td>${esc(i.machine_id).slice(0,16)}</td><td>${esc(i.app_version || '-')}</td><td>${esc(i.feedback_text).slice(0,80)}</td><td>${hasTranscript ? '有' : '-'}</td><td>${esc(i.client_ip || '-')}</td></tr>`
}).join('')
const pages = Math.ceil(total / _limit)
$('#fb-list').innerHTML = `<div class="table-wrap"><table><thead><tr><th>时间</th><th>设备</th><th>版本</th><th>反馈内容</th><th>转录</th><th>IP</th></tr></thead><tbody>${rows}</tbody></table></div>
<div class="pagination"><span>共 ${total} 条</span>${pages > 1 ? Array.from({length: pages}, (_, i) => `<button class="btn btn-outline btn-sm ${i === _page ? 'active' : ''}" data-p="${i}">${i+1}</button>`).join('') : ''}</div>`
$$('#fb-list tr.clickable').forEach(tr => tr.addEventListener('click', () => showDetail(items.find(i => String(i.id) === tr.dataset.id))))
$$('#fb-list .pagination button').forEach(b => b.addEventListener('click', () => { _page = Number(b.dataset.p); load() }))
}
const showDetail = (item) => {
if (!item) return
const d = $('#fb-detail'); d.style.display = 'block'
const tr = item.transcript_json, ctx = item.context_json
const t = new Date(item.created_at).toLocaleString('zh-CN', { hour12: false })
const metaItems = [
['设备', item.machine_id], ['版本', item.app_version || '-'], ['IP', item.client_ip || '-'], ['时间', t],
]
if (ctx) {
metaItems.push(['模式', ctx.work_mode || '-'])
metaItems.push(['ASR', `${ctx.asr_provider || '-'} / ${ctx.asr_model || '-'}`])
if (ctx.ai_enabled) metaItems.push(['AI', `${ctx.ai_provider || '-'} / ${ctx.ai_model || '-'}`])
if (ctx.ai_preset_name) metaItems.push(['润色', ctx.ai_preset_name])
}
if (tr && tr.duration_sec) metaItems.push(['时长', tr.duration_sec.toFixed(1) + 's'])
let html = `<div class="fb-meta">${metaItems.map(([k, v]) => `<span class="fb-tag"><span class="k">${esc(k)}</span>${esc(v)}</span>`).join('')}</div>`
html += `<div class="fb-section"><div class="fb-label">反馈内容</div><div class="fb-body">${esc(item.feedback_text)}</div></div>`
if (tr && tr.asr_text) html += `<div class="fb-section"><div class="fb-label">ASR 原文</div><div class="fb-body fb-scroll">${esc(tr.asr_text)}</div></div>`
if (tr && tr.ai_text) html += `<div class="fb-section"><div class="fb-label">AI 润色</div><div class="fb-body fb-scroll">${esc(tr.ai_text)}</div></div>`
if (ctx && ctx.ai_system_prompt) html += `<div class="fb-section"><div class="fb-label">System Prompt</div><div class="fb-body fb-scroll fb-mono">${esc(ctx.ai_system_prompt)}</div></div>`
d.innerHTML = html
d.scrollIntoView({ behavior: 'smooth' })
}
$('#fb-refresh').addEventListener('click', () => { _page = 0; load() })
await load()
}
/* === Logs === */
async function renderLogs(el) {
el.innerHTML = `<div class="page-header"><h1>服务日志</h1></div><div class="page-body">
<div class="toolbar">${timePresetHTML('l')}<label class="field"><span>级别</span><select id="l-level" style="width:100px"><option value="">全部</option><option value="ERROR">ERROR</option><option value="WARNING">WARNING</option><option value="INFO">INFO</option></select></label><label class="field"><span>搜索</span><input id="l-search" placeholder="关键词" style="width:120px"></label><label class="field"><span>条数</span><select id="l-limit" style="width:70px"><option value="50">50</option><option value="100">100</option><option value="200">200</option></select></label><div class="toolbar-actions"><button class="btn btn-outline" id="l-tail">实时</button><button class="btn btn-outline" id="l-export">导出</button><button class="btn btn-outline" id="l-refresh">刷新</button></div></div>
<div id="l-list">${LOADING}</div></div>`
let _tailing = false
const load = async () => {
const p = { limit: $('#l-limit').value }; const lv = $('#l-level').value; if (lv) p.level = lv
const lr = getTimeRange('l'); if (lr.from) p.from = lr.from; if (lr.to) p.to = lr.to
const lq = $('#l-search')?.value?.trim(); if (lq) p.q = lq
const data = await api('/admin/api/service-logs', p)
$('#l-list').innerHTML = (data || []).map(i =>
`<div class="log-item ${i.level === "ERROR" ? "log-error" : i.level === "WARNING" ? "log-warn" : ""}"><div class="meta">${fmtTime(i.created_at)} · ${esc(i.level)} · ${esc(i.logger_name || '')}</div><div class="msg">${esc(i.message || '-')}</div></div>`
).join('') || EMPTY('暂无日志', '服务运行后将自动记录')
return data
}
const toggleTail = () => {
_tailing = !_tailing
const btn = $('#l-tail')
if (_tailing) {
btn.classList.add('btn-primary'); btn.classList.remove('btn-outline'); btn.textContent = '停止'
_logTailTimer = setInterval(load, 5000)
} else {
btn.classList.remove('btn-primary'); btn.classList.add('btn-outline'); btn.textContent = '实时'
clearInterval(_logTailTimer); _logTailTimer = null
}
}
const exportLogs = async () => {
try {
const data = await api('/admin/api/service-logs', { limit: 200 })
if (!data?.length) return
downloadCSV('logs.csv', ['时间','级别','来源','消息'], data.map(i => [fmtTime(i.created_at), i.level, i.logger_name||'', i.message||'']))
} catch(e) {}
}
$('#l-refresh').addEventListener('click', load); $('#l-level').addEventListener('change', load)
$('#l-tail').addEventListener('click', toggleTail)
$('#l-export').addEventListener('click', exportLogs)
bindTimePresets('l', load); await load()
}
/* === System === */
async function renderSystem(el) {
el.innerHTML = `<div class="page-header"><h1>系统信息</h1></div><div class="page-body"><div class="info-grid" id="sys-grid">${LOADING}</div></div>`
const [h, s] = await Promise.all([api('/admin/api/healthz-details'), api('/admin/api/system-info')])
const wd = s.web_demo || {}, llm = s.llm || {}, wllm = s.web_demo_llm || {}, tel = h.telemetry || {}, res = s.resources || {}
const gpus = res.gpus || []
const uptimeStr = res.uptime_sec ? fmtDuration(res.uptime_sec * 1000) : '-'
const cards = [
infoCard('ASR 引擎', [['状态', h.asr ? '已加载' : '未加载'],['引擎', s.asr_engine || '-'],['模型', shortModel(s.asr_model)]]),
infoCard('LLM 引擎', [['状态', llm.enabled ? '已启用' : '未启用'],['Provider', llm.provider || '-'],['模型', llm.model || '-']]),
infoCard('Web Demo', [['启用', wd.enabled ? '是' : '否'],['LLM', wllm.enabled ? `${wllm.provider} / ${wllm.model}` : '未启用'],['最大时长', `${wd.max_duration_sec || '-'}s`],['单IP并发', wd.max_concurrency_per_ip || '-']]),
infoCard('部署信息', [['节点', s.node_id || '-'],['模式', s.deployment_mode || '-'],['运行时间', uptimeStr],['遥测', tel.enabled ? '已启用' : '未启用'],['数据库', shortPath(tel.db_path)],['日志', shortPath(tel.log_file)]]),
infoCard('CPU / 内存', [['CPU 使用率', `${res.cpu_percent ?? '-'}%`],['CPU 核心', res.cpu_count || '-'],['内存', `${res.mem_used_gb ?? '-'} / ${res.mem_total_gb ?? '-'} GB (${res.mem_percent ?? '-'}%)`],['磁盘', `${res.disk_used_gb ?? '-'} / ${res.disk_total_gb ?? '-'} GB (${res.disk_percent ?? '-'}%)`]]),
]
gpus.forEach(g => {
cards.push(infoCard(`GPU ${g.index} — ${g.name}`, [['利用率', `${g.util_percent}%`],['显存', `${g.mem_used_mb} / ${g.mem_total_mb} MB`],['温度', `${g.temp_c}°C`]]))
})
$('#sys-grid').innerHTML = cards.join('')
}
function shortPath(p) { if (!p) return '-'; const parts = p.split('/'); return parts.length > 3 ? '…/' + parts.slice(-2).join('/') : p }
function infoCard(title, rows) {
return `<div class="info-card"><h3>${title}</h3>${rows.map(([k, v]) =>
`<div class="info-row"><span class="k">${k}</span><span class="v" title="${esc(String(v))}">${esc(String(v))}</span></div>`
).join('')}</div>`
}
})()