chore: 0.0.8 更新(本地模型、小米 MiMo 供应商、体验优化与多项修复)
This commit is contained in:
1 parent
241d83762c
commit
ff035773e5
68 files changed
+2488
-1926
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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>`
|
||||
}
|
||||
|
||||
})()
|
||||
Reference in new issue
Block a user