From 7be154d3ce73e11d9a75c3461ff67c026fc31e8e Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 15 Apr 2026 11:57:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20=E6=96=B0=E5=A2=9E=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=20Web=20Dashboard=20=E9=A1=B5=E9=9D=A2=EF=BC=8C?= =?UTF-8?q?=E8=81=9A=E5=90=88=E5=B1=95=E7=A4=BA=E6=B5=81=E9=87=8F=E4=B8=8E?= =?UTF-8?q?=E7=94=A8=E9=87=8F=E7=BB=9F=E8=AE=A1;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 src/coding/proxy/server/dashboard.py,包含: - GET /dashboard:嵌入式 HTML Dashboard 页面(深色主题,Chart.js 可视化) - GET /api/dashboard/summary:汇总今日/本周/本月请求数、Token 量、费用、故障转移、平均延迟 - GET /api/dashboard/timeline:按天分组的时序数据,支持 days 参数(1~90) - 在 routes.py register_all_routes() 末尾追加 register_dashboard_routes(app) 注册调用 - Dashboard 布局:KPI 卡片 × 6、供应商状态面板(熔断器 + 配额进度条)、 7 天请求趋势折线图、供应商分布环形图、Token 类型堆叠柱图、故障转移明细表 - 前端每 30 秒自动刷新,支持手动刷新;数据层复用 TokenLogger.query_usage() 与 PricingTable - 不修改任何现有路由逻辑,1181 个测试全部通过 🤖 Generated with [Claude Code](https://github.com/claude) Co-Authored-By: Aurelius Huang --- src/coding/proxy/server/dashboard.py | 787 +++++++++++++++++++++++++++ src/coding/proxy/server/routes.py | 4 + 2 files changed, 791 insertions(+) create mode 100644 src/coding/proxy/server/dashboard.py diff --git a/src/coding/proxy/server/dashboard.py b/src/coding/proxy/server/dashboard.py new file mode 100644 index 0000000..6dc3887 --- /dev/null +++ b/src/coding/proxy/server/dashboard.py @@ -0,0 +1,787 @@ +"""Dashboard 路由 — 流量与用量可视化看板.""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from fastapi import Request +from fastapi.responses import HTMLResponse, Response + +from ..logging.db import TimePeriod + +logger = logging.getLogger(__name__) + +# ── HTML 模板 ────────────────────────────────────────────────────────────── + +_DASHBOARD_HTML = """ + + + + + coding-proxy Dashboard + + + + +
+
+ +

coding-proxy Dashboard

+ v-.-.- +
+
+ 正在加载… + +
+
+ +
+ +
+
今日请求数
本周 –
+
今日 Token 总量
本周 –
+
今日输出 Token
本周 –
+
今日费用估算
本周 –
+
故障转移(今日)
本周 –
+
平均延迟(今日)
本周 –
+
+ + +
+
+
供应商状态
+
+
加载中…
+
+
+
+
近 7 天请求量趋势
+
+ +
+
+
+ + +
+
+
供应商请求分布(近 7 天)
+
+ +
+
+
+
Token 类型分布(近 7 天)
+
+ +
+
+
+ + +
+
故障转移明细
+
+ + + + + + + + + + + +
来源供应商目标供应商次数
加载中…
+
+
+
+ + + + +""" + + +# ── 数据计算工具 ────────────────────────────────────────────────────────── + + +def _sum_rows(rows: list[dict]) -> dict: + """汇总一组查询行的关键指标.""" + total_requests = 0 + total_input = 0 + total_output = 0 + total_cache_creation = 0 + total_cache_read = 0 + total_failovers = 0 + weighted_duration = 0.0 + + for row in rows: + req = row.get("total_requests") or 0 + total_requests += req + total_input += row.get("total_input") or 0 + total_output += row.get("total_output") or 0 + total_cache_creation += row.get("total_cache_creation") or 0 + total_cache_read += row.get("total_cache_read") or 0 + total_failovers += row.get("total_failovers") or 0 + weighted_duration += (row.get("avg_duration_ms") or 0) * req + + avg_ms = int(weighted_duration / total_requests) if total_requests else 0 + return { + "requests": total_requests, + "tokens": { + "input": total_input, + "output": total_output, + "cache_creation": total_cache_creation, + "cache_read": total_cache_read, + }, + "failovers": total_failovers, + "avg_duration_ms": avg_ms, + } + + +def _compute_cost_str(rows: list[dict], pricing_table: Any) -> str: + """计算多行的总费用字符串.""" + if pricing_table is None: + return "–" + cost_totals: dict = {} + for row in rows: + vendor = str(row.get("vendor") or "") + model = str(row.get("model_served") or "") + cv = pricing_table.compute_cost( + vendor, + model, + row.get("total_input") or 0, + row.get("total_output") or 0, + row.get("total_cache_creation") or 0, + row.get("total_cache_read") or 0, + ) + if cv is not None: + cur = cv.currency + cost_totals[cur] = cost_totals.get(cur, 0.0) + cv.amount + + if not cost_totals: + return "–" + return " + ".join(f"{cur.symbol}{amt:.4f}" for cur, amt in cost_totals.items()) + + +# ── 路由注册 ────────────────────────────────────────────────────────────── + + +def register_dashboard_routes(app: Any) -> None: + """注册 Dashboard 相关路由.""" + from .. import __version__ + + @app.get("/dashboard", response_class=HTMLResponse, include_in_schema=False) + async def dashboard() -> HTMLResponse: + """返回 Dashboard HTML 页面.""" + return HTMLResponse(content=_DASHBOARD_HTML) + + @app.get("/api/dashboard/summary") + async def dashboard_summary(request: Request) -> Response: + """返回 Dashboard 汇总数据(今日 / 本周 / 本月).""" + token_logger = getattr(request.app.state, "token_logger", None) + pricing_table = getattr(request.app.state, "pricing_table", None) + + if token_logger is None: + return Response( + content=b'{"error":"token_logger not available"}', + status_code=503, + media_type="application/json", + ) + + try: + # 今日(最近 1 天) + today_rows = await token_logger.query_usage( + period=TimePeriod.DAY, count=1 + ) + # 本周(最近 7 天) + week_rows = await token_logger.query_usage( + period=TimePeriod.DAY, count=7 + ) + # 本月(最近 30 天) + month_rows = await token_logger.query_usage( + period=TimePeriod.DAY, count=30 + ) + # 故障转移(最近 7 天) + failover_stats = await token_logger.query_failover_stats(days=7) + except Exception as exc: + logger.error("dashboard_summary query error: %s", exc, exc_info=True) + return Response( + content=b'{"error":"query failed"}', + status_code=500, + media_type="application/json", + ) + + today = _sum_rows(today_rows) + week = _sum_rows(week_rows) + month = _sum_rows(month_rows) + + today["cost"] = _compute_cost_str(today_rows, pricing_table) + week["cost"] = _compute_cost_str(week_rows, pricing_table) + month["cost"] = _compute_cost_str(month_rows, pricing_table) + + result = { + "version": __version__, + "today": today, + "week": week, + "month": month, + "failover_stats": failover_stats, + } + return Response( + content=json.dumps(result, ensure_ascii=False).encode(), + status_code=200, + media_type="application/json", + ) + + @app.get("/api/dashboard/timeline") + async def dashboard_timeline(request: Request, days: int = 7) -> Response: + """返回按天分组的时序数据(用于图表绘制).""" + token_logger = getattr(request.app.state, "token_logger", None) + + if token_logger is None: + return Response( + content=b'{"error":"token_logger not available"}', + status_code=503, + media_type="application/json", + ) + + days = max(1, min(days, 90)) # 限制范围 1~90 天 + + try: + rows = await token_logger.query_usage( + period=TimePeriod.DAY, count=days + ) + except Exception as exc: + logger.error("dashboard_timeline query error: %s", exc, exc_info=True) + return Response( + content=b'{"error":"query failed"}', + status_code=500, + media_type="application/json", + ) + + result = { + "period": "day", + "count": days, + "rows": rows, + } + return Response( + content=json.dumps(result, ensure_ascii=False).encode(), + status_code=200, + media_type="application/json", + ) diff --git a/src/coding/proxy/server/routes.py b/src/coding/proxy/server/routes.py index 5d0c21c..040d149 100644 --- a/src/coding/proxy/server/routes.py +++ b/src/coding/proxy/server/routes.py @@ -353,3 +353,7 @@ def register_all_routes( register_admin_routes(app, router) if reauth_coordinator: register_reauth_routes(app, reauth_coordinator) + + from .dashboard import register_dashboard_routes + + register_dashboard_routes(app)