添加统计请求的功能

This commit is contained in:
Ubuntu
2026-07-02 23:28:49 +08:00
parent 5d94edfc1a
commit aa981229c0
12 changed files with 733 additions and 22 deletions
+4
View File
@@ -17,6 +17,10 @@ password.txt
worker.pid
worker.log
# 请求日志(运行时生成,不入 git)
gateway/request_log.jsonl
gateway/request_log.jsonl.1
# SegFormer 模型权重(~323MB,体积过大,不入 git,见 OFFLINE_ASSETS.md
hairline/models/face-parsing/model.safetensors
+267 -1
View File
@@ -15,10 +15,15 @@ from pathlib import Path
from typing import Optional
from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import JSONResponse
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from gateway.config import load_config
from gateway.logging_middleware import (
get_stats,
init_logging as _init_req_logging,
request_logging_middleware,
)
# ---------------------------------------------------------------------------
# 日志
@@ -43,6 +48,9 @@ async def lifespan(app: FastAPI):
cfg = load_config()
logger.info("网关启动中... workers=%s", cfg["workers"])
# 初始化请求日志
_init_req_logging(cfg)
# 初始化健康池(阶段二实现)
try:
from gateway.pool import init_pool, shutdown_pool as _pool_shutdown
@@ -105,6 +113,9 @@ static_root.mkdir(parents=True, exist_ok=True)
(static_root / "annotations").mkdir(parents=True, exist_ok=True)
app.mount("/static", StaticFiles(directory=str(static_root)), name="static")
# 请求日志中间件(在所有路由之前,静态文件之后)
app.middleware("http")(request_logging_middleware)
# ---------------------------------------------------------------------------
# 健康检查(网关自身)
@@ -189,6 +200,7 @@ async def index():
"service": "旷视五接口 — 网关",
"version": "0.1.0",
"docs": "/docs",
"stats": "/admin/stats",
"integration_guide": "/static/integration.html",
"test_pages": {
"if1_measure": "/static/test_interface1.html",
@@ -202,6 +214,260 @@ async def index():
}
# ---------------------------------------------------------------------------
# 请求统计仪表盘 HTML
# ---------------------------------------------------------------------------
_STATS_PAGE_HTML = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>网关请求统计</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 1300px; margin: 0 auto; padding: 24px; }
h1 { font-size: 22px; margin-bottom: 4px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 20px; }
.nav { margin-bottom: 20px; }
.nav a { color: #2563eb; text-decoration: none; font-size: 13px; }
.nav a:hover { text-decoration: underline; }
/* 汇总卡片 */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 14px; margin-bottom: 24px; }
.stat-card { background: #fff; border-radius: 12px; padding: 18px 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
.stat-card .value { font-size: 28px; font-weight: 700; color: #111827; }
.stat-card .label { font-size: 11px; color: #9ca3af; text-transform: uppercase; letter-spacing: .5px; margin-top: 4px; }
.stat-card.ok .value { color: #059669; }
.stat-card.warn .value { color: #d97706; }
/* 表格 */
.section { margin-bottom: 24px; }
.section h2 { font-size: 16px; margin-bottom: 10px; color: #374151; }
.table-wrap { background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 9px 14px; text-align: left; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
th { background: #f8fafc; font-weight: 700; color: #475569; font-size: 11px; text-transform: uppercase; letter-spacing: .3px; white-space: nowrap; }
tr:hover td { background: #fafbfc; }
td.mono { font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; }
.badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: 700; }
.badge-ok { background: #d1fae5; color: #065f46; }
.badge-err { background: #fee2e2; color: #991b1b; }
.badge-other { background: #f3f4f6; color: #6b7280; }
.duration-fast { color: #059669; }
.duration-mid { color: #d97706; }
.duration-slow { color: #dc2626; }
.footer { text-align: right; font-size: 12px; color: #9ca3af; margin-top: 20px; }
.auto-refresh { display: flex; align-items: center; gap: 8px; }
.auto-refresh input { accent-color: #2563eb; }
.empty { text-align: center; padding: 40px; color: #9ca3af; font-size: 14px; }
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
th, td { padding: 6px 8px; font-size: 12px; }
}
</style>
</head>
<body>
<div class="container">
<h1>📊 网关请求统计</h1>
<p class="subtitle">实时请求监控 &nbsp;|&nbsp; 每 3 秒自动刷新</p>
<div class="nav">
<a href="/">← 返回首页</a> &nbsp;|&nbsp;
<a href="/docs">API 文档</a> &nbsp;|&nbsp;
<a href="/static/integration.html">接入指南</a>
</div>
<!-- 汇总卡片 -->
<div class="stats-grid">
<div class="stat-card"><div class="value" id="totalCount">—</div><div class="label">请求总数</div></div>
<div class="stat-card ok"><div class="value" id="successRate">—</div><div class="label">成功率(code=0</div></div>
<div class="stat-card"><div class="value" id="avgTime">—</div><div class="label">平均响应时间</div></div>
<div class="stat-card"><div class="value" id="minTime">—</div><div class="label">最短响应</div></div>
<div class="stat-card warn"><div class="value" id="maxTime">—</div><div class="label">最长响应</div></div>
</div>
<!-- 按接口 -->
<div class="section">
<h2>📋 按接口统计</h2>
<div class="table-wrap">
<table>
<thead><tr><th>路径</th><th>请求数</th><th>平均耗时</th><th>最大耗时</th><th>成功率</th></tr></thead>
<tbody id="endpointTable"><tr><td class="empty" colspan="5">暂无数据</td></tr></tbody>
</table>
</div>
</div>
<!-- 按 Worker -->
<div class="section">
<h2>🖥️ 按 GPU Worker 统计</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Worker</th><th>请求数</th><th>平均耗时</th><th>成功率</th></tr></thead>
<tbody id="workerTable"><tr><td class="empty" colspan="4">暂无数据</td></tr></tbody>
</table>
</div>
</div>
<!-- 最近请求 -->
<div class="section">
<h2>🕐 最近请求(最新 100 条)</h2>
<div class="table-wrap" style="max-height:600px;overflow:auto;">
<table>
<thead><tr><th>时间</th><th>方法</th><th>路径</th><th>Worker</th><th>HTTP</th><th>业务码</th><th>耗时</th><th>客户端 IP</th></tr></thead>
<tbody id="recentTable"><tr><td class="empty" colspan="8">暂无数据</td></tr></tbody>
</table>
</div>
</div>
<div class="footer">
<label class="auto-refresh">
<input type="checkbox" id="autoRefresh" checked onchange="toggleAutoRefresh()"> 自动刷新(3s
</label>
<span style="margin-left:16px" id="lastUpdated">加载中…</span>
</div>
</div>
<script>
let _timer = null;
function formatDuration(ms) {
if (ms < 1000) return ms.toFixed(1) + 'ms';
if (ms < 60000) return (ms / 1000).toFixed(2) + 's';
return (ms / 60000).toFixed(1) + 'min';
}
function durationClass(ms) {
if (ms < 500) return 'duration-fast';
if (ms < 2000) return 'duration-mid';
return 'duration-slow';
}
function badgeClass(code) {
if (code === 0) return 'badge-ok';
if (code !== null && code !== undefined && code !== 0) return 'badge-err';
return 'badge-other';
}
function badgeText(code) {
if (code === 0) return 'OK';
if (code !== null && code !== undefined) return 'ERR ' + code;
return '';
}
async function refresh() {
try {
const r = await fetch('/admin/stats/json');
const data = await r.json();
const s = data.summary;
document.getElementById('totalCount').textContent = s.total.toLocaleString();
document.getElementById('successRate').textContent = s.success_rate + '%';
document.getElementById('avgTime').textContent = formatDuration(s.avg_duration_ms);
document.getElementById('minTime').textContent = formatDuration(s.min_duration_ms);
document.getElementById('maxTime').textContent = formatDuration(s.max_duration_ms);
// 按接口
let ehtml = '';
if (data.endpoints.length === 0) {
ehtml = '<tr><td class="empty" colspan="5">暂无数据</td></tr>';
} else {
data.endpoints.forEach(function(e) {
ehtml += '<tr>' +
'<td class="mono">' + e.path + '</td>' +
'<td>' + e.count + '</td>' +
'<td class="' + durationClass(e.avg_duration_ms) + '">' + formatDuration(e.avg_duration_ms) + '</td>' +
'<td>' + formatDuration(e.max_duration_ms) + '</td>' +
'<td><span class="badge ' + badgeClass(0) + '" style="opacity:' + (e.success_rate / 100) + '">' + e.success_rate + '%</span></td>' +
'</tr>';
});
}
document.getElementById('endpointTable').innerHTML = ehtml;
// 按 Worker
let whtml = '';
if (!data.workers || data.workers.length === 0) {
whtml = '<tr><td class="empty" colspan="4">暂无数据</td></tr>';
} else {
data.workers.forEach(function(w) {
whtml += '<tr>' +
'<td class="mono">' + w.worker + '</td>' +
'<td>' + w.count + '</td>' +
'<td class="' + durationClass(w.avg_duration_ms) + '">' + formatDuration(w.avg_duration_ms) + '</td>' +
'<td><span class="badge ' + badgeClass(0) + '" style="opacity:' + (w.success_rate / 100) + '">' + w.success_rate + '%</span></td>' +
'</tr>';
});
}
document.getElementById('workerTable').innerHTML = whtml;
// 最近请求
let rhtml = '';
if (data.recent.length === 0) {
rhtml = '<tr><td class="empty" colspan="8">暂无数据</td></tr>';
} else {
data.recent.forEach(function(entry) {
var ts = entry.timestamp.replace('T', ' ').substring(0, 23);
var workerDisplay = entry.worker || '';
// 短 worker 显示:只取主机部分
if (workerDisplay.length > 30) {
workerDisplay = workerDisplay.replace(/^https?:\/\//, '').substring(0, 28) + '';
}
rhtml += '<tr>' +
'<td class="mono">' + ts + '</td>' +
'<td>' + entry.method + '</td>' +
'<td class="mono">' + entry.path + '</td>' +
'<td class="mono" style="font-size:11px">' + workerDisplay + '</td>' +
'<td>' + entry.status_code + '</td>' +
'<td><span class="badge ' + badgeClass(entry.response_code) + '">' + badgeText(entry.response_code) + '</span></td>' +
'<td class="' + durationClass(entry.duration_ms) + '">' + formatDuration(entry.duration_ms) + '</td>' +
'<td class="mono">' + entry.client_ip + '</td>' +
'</tr>';
});
}
document.getElementById('recentTable').innerHTML = rhtml;
document.getElementById('lastUpdated').textContent = '最后更新: ' + new Date().toLocaleTimeString();
} catch(err) {
document.getElementById('lastUpdated').textContent = '加载失败: ' + err.message;
}
}
function toggleAutoRefresh() {
var checked = document.getElementById('autoRefresh').checked;
if (checked) {
_timer = setInterval(refresh, 3000);
} else {
clearInterval(_timer);
_timer = null;
}
}
refresh();
_timer = setInterval(refresh, 3000);
</script>
</body>
</html>"""
# ---------------------------------------------------------------------------
# 请求统计页面
# ---------------------------------------------------------------------------
@app.get("/admin/stats", include_in_schema=False)
async def admin_stats():
"""请求统计仪表盘(HTML 页面)。"""
return HTMLResponse(content=_STATS_PAGE_HTML)
@app.get("/admin/stats/json", include_in_schema=False)
async def admin_stats_json():
"""请求统计数据(JSON,供页面轮询)。"""
return get_stats()
# ---------------------------------------------------------------------------
# 代理路由
# ---------------------------------------------------------------------------
+7
View File
@@ -33,6 +33,13 @@ DEFAULTS = {
"interval_minutes": 60,
"max_age_hours": 24,
},
"request_log": {
"enabled": True,
"log_file": "gateway/request_log.jsonl",
"buffer_size": 2000,
"max_file_lines": 10000,
"max_file_age_days": 7,
},
}
_config_cache = None
+2
View File
@@ -162,6 +162,8 @@ async def proxy_request(request: Request, path: str) -> JSONResponse:
worker = None
try:
worker = await acquire_worker(cfg)
# 记录当前使用的 worker,供日志中间件读取
request.state.worker_url = worker.url
except NoWorkerAvailable:
logger.warning("无可用 worker,返回 1007")
return JSONResponse(
+406
View File
@@ -0,0 +1,406 @@
"""请求日志中间件:为每个请求记录时间、路径、耗时等,并提供统计查询。
- 内存环形缓冲区(最近 N 条)
- JSON Lines 文件持久化(自动轮转)
- ASGI 中间件透明捕获请求/响应
"""
import datetime
import json
import logging
import time
from collections import deque
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger("gateway.logging_middleware")
# ---------------------------------------------------------------------------
# 数据结构
# ---------------------------------------------------------------------------
@dataclass
class RequestLogEntry:
"""单条请求日志。"""
timestamp: str # ISO-8601
method: str
path: str
status_code: int
duration_ms: float
client_ip: str
worker: str = "" # 处理请求的 worker URL(空串表示网关本地处理)
response_code: Optional[int] = None # 响应 JSON 中的 code 字段
request_id: Optional[str] = None # 响应 JSON 中的 request_id
# ---------------------------------------------------------------------------
# 环形缓冲区
# ---------------------------------------------------------------------------
class RingBuffer:
"""固定大小的环形缓冲区,线程安全。"""
def __init__(self, maxlen: int = 2000):
self._deque: deque = deque(maxlen=maxlen)
def append(self, entry: RequestLogEntry) -> None:
self._deque.append(entry)
def snapshot(self) -> List[RequestLogEntry]:
"""返回当前缓冲区副本(最新在前)。"""
return list(reversed(self._deque))
def __len__(self) -> int:
return len(self._deque)
# ---------------------------------------------------------------------------
# JSON Lines 文件写入(含轮转)
# ---------------------------------------------------------------------------
class LogFileWriter:
"""追加写入 JSON Lines 日志文件,自动按行数 / 天数轮转。
轮转策略:保留 1 个备份 (.jsonl.1),不保留更多历史。
"""
def __init__(self, filepath: str, max_lines: int = 10000, max_age_days: int = 7):
self.filepath = Path(filepath)
self.max_lines = max_lines
self.max_age_seconds = max_age_days * 86400
def write(self, entry: RequestLogEntry) -> None:
try:
self._maybe_rotate()
self.filepath.parent.mkdir(parents=True, exist_ok=True)
line = json.dumps(asdict(entry), ensure_ascii=False) + "\n"
with open(self.filepath, "a", encoding="utf-8") as f:
f.write(line)
except Exception:
logger.warning("写入请求日志失败", exc_info=True)
def _maybe_rotate(self) -> None:
if not self.filepath.exists():
return
# 按天数轮转
mtime = self.filepath.stat().st_mtime
if time.time() - mtime > self.max_age_seconds:
self._rotate()
return
# 按行数轮转
try:
with open(self.filepath, "r", encoding="utf-8") as f:
count = sum(1 for _ in f)
if count >= self.max_lines:
self._rotate()
except Exception:
pass # 读不到就算了,下次再说
def _rotate(self) -> None:
backup = self.filepath.with_suffix(".jsonl.1")
if backup.exists():
backup.unlink()
try:
self.filepath.rename(backup)
logger.info("请求日志已轮转: %s%s", self.filepath.name, backup.name)
except Exception:
logger.warning("日志轮转失败", exc_info=True)
# ---------------------------------------------------------------------------
# 模块级全局状态(由 init_logging 初始化)
# ---------------------------------------------------------------------------
_buffer: Optional[RingBuffer] = None
_writer: Optional[LogFileWriter] = None
def init_logging(cfg: dict) -> None:
"""初始化日志缓冲区与文件写入器。"""
global _buffer, _writer
log_cfg = cfg.get("request_log", {})
if not log_cfg.get("enabled", True):
logger.info("请求日志已禁用")
return
log_file = log_cfg.get("log_file", "gateway/request_log.jsonl")
log_path = Path(log_file)
if not log_path.is_absolute():
log_path = Path(__file__).resolve().parent.parent / log_file
_buffer = RingBuffer(maxlen=log_cfg.get("buffer_size", 2000))
_writer = LogFileWriter(
filepath=str(log_path),
max_lines=log_cfg.get("max_file_lines", 10000),
max_age_days=log_cfg.get("max_file_age_days", 7),
)
# 从历史日志文件加载最近 N 条到缓冲区
buffer_size = log_cfg.get("buffer_size", 2000)
loaded = _load_from_logfile(str(log_path), buffer_size)
if loaded > 0:
logger.info("从日志文件恢复 %d 条历史记录", loaded)
logger.info("请求日志已启用 | 缓冲=%d | 文件=%s",
buffer_size, log_path)
def _load_from_logfile(filepath: str, max_entries: int) -> int:
"""从 JSON Lines 日志文件读取最近 max_entries 条到缓冲区。"""
try:
p = Path(filepath)
if not p.exists():
return 0
# 从文件末尾反向读取(高效处理大文件)
with open(p, "rb") as f:
# 估算:每条约 200 bytes,读最后 max_entries * 250 bytes 足够
chunk_size = max_entries * 250
f.seek(0, 2) # 文件末尾
file_size = f.tell()
read_size = min(chunk_size, file_size)
f.seek(max(0, file_size - read_size))
raw = f.read().decode("utf-8", errors="replace")
# 跳过可能不完整的第一行
lines = raw.split("\n")
if file_size > read_size:
# 第一行可能不完整,跳过
lines = lines[1:]
# 去掉末尾空行
lines = [l for l in lines if l.strip()]
# 只取最后 max_entries 条
lines = lines[-max_entries:]
count = 0
for line in lines:
try:
data = json.loads(line)
entry = RequestLogEntry(
timestamp=data.get("timestamp", ""),
method=data.get("method", ""),
path=data.get("path", ""),
status_code=data.get("status_code", 0),
duration_ms=data.get("duration_ms", 0.0),
client_ip=data.get("client_ip", ""),
worker=data.get("worker", ""),
response_code=data.get("response_code"),
request_id=data.get("request_id"),
)
_buffer.append(entry)
count += 1
except (json.JSONDecodeError, KeyError):
continue
return count
except Exception:
logger.warning("从日志文件恢复历史记录失败", exc_info=True)
return 0
def is_initialized() -> bool:
return _buffer is not None
# ---------------------------------------------------------------------------
# ASGI 中间件
# ---------------------------------------------------------------------------
def _should_log(path: str) -> bool:
"""只记录 API 请求(/api/ 路径),跳过静态文件、健康检查等。"""
return path.startswith("/api/")
async def request_logging_middleware(request, call_next):
"""记录每个请求的耗时、状态码等信息。"""
# 未初始化或不需要记录的路径 → 直接放行
if _buffer is None or not _should_log(request.url.path):
return await call_next(request)
start = time.perf_counter()
# 获取客户端 IP(优先级:X-Forwarded-For > X-Real-IP > client.host
client_ip = request.client.host if request.client else "unknown"
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
client_ip = forwarded.split(",")[0].strip()
else:
real_ip = request.headers.get("x-real-ip")
if real_ip:
client_ip = real_ip.strip()
response = await call_next(request)
duration_ms = round((time.perf_counter() - start) * 1000, 2)
# 读取 worker URL(由 forward.py 在转发时写入 request.state
worker_url = getattr(request.state, "worker_url", None) or ""
# 提取响应 body 并解析业务字段(仅 JSON 响应)
response_code = None
request_id = None
content_type = response.headers.get("content-type", "")
if "application/json" in content_type or "application/json" in (response.media_type or ""):
# 读取 body(兼容 body_iterator 和 body 两种属性)
body = getattr(response, "body", None)
if body is None:
body = b""
async for chunk in response.body_iterator:
body += chunk
try:
data = json.loads(body)
response_code = data.get("code")
request_id = data.get("request_id")
except (json.JSONDecodeError, UnicodeDecodeError):
pass
# 如果读取了 body_iterator,需要重建响应
if not hasattr(response, "body") or response.body is None:
from starlette.responses import Response as StarletteResponse
response = StarletteResponse(
content=body,
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.media_type,
)
# 记录
now = datetime.datetime.utcnow()
entry = RequestLogEntry(
timestamp=now.strftime("%Y-%m-%dT%H:%M:%S.") +
f"{now.microsecond // 1000:03d}Z",
method=request.method,
path=request.url.path,
status_code=response.status_code,
duration_ms=duration_ms,
client_ip=client_ip,
worker=worker_url,
response_code=response_code,
request_id=request_id,
)
_buffer.append(entry)
if _writer is not None:
_writer.write(entry)
return response
# ---------------------------------------------------------------------------
# 统计查询
# ---------------------------------------------------------------------------
def get_stats() -> Dict[str, Any]:
"""基于缓冲区数据计算统计摘要,返回给统计页面使用。"""
if _buffer is None:
return {
"summary": {"total": 0, "success_rate": 0, "avg_duration_ms": 0,
"min_duration_ms": 0, "max_duration_ms": 0},
"endpoints": [],
"workers": [],
"recent": [],
"last_updated": datetime.datetime.utcnow().isoformat() + "Z",
}
snapshot = _buffer.snapshot()
total = len(snapshot)
if total == 0:
return {
"summary": {"total": 0, "success_rate": 0, "avg_duration_ms": 0,
"min_duration_ms": 0, "max_duration_ms": 0},
"endpoints": [],
"workers": [],
"recent": [],
"last_updated": datetime.datetime.utcnow().isoformat() + "Z",
}
# 汇总指标
durations = [e.duration_ms for e in snapshot]
ok_count = sum(1 for e in snapshot if e.response_code == 0)
# 按路径聚合
by_path: Dict[str, dict] = {}
for e in snapshot:
path = e.path
if path not in by_path:
by_path[path] = {"count": 0, "total_duration": 0.0, "ok": 0}
by_path[path]["count"] += 1
by_path[path]["total_duration"] += e.duration_ms
if e.response_code == 0:
by_path[path]["ok"] += 1
endpoints = sorted(
({
"path": path,
"count": v["count"],
"avg_duration_ms": round(v["total_duration"] / v["count"], 1),
"max_duration_ms": round(
max(e.duration_ms for e in snapshot if e.path == path), 1),
"success_rate": round(v["ok"] / v["count"] * 100, 1),
} for path, v in by_path.items()),
key=lambda x: -x["count"],
)
# 最近 100 条(最新在前)
recent_100 = snapshot[:100]
recent = [
{
"timestamp": e.timestamp,
"method": e.method,
"path": e.path,
"status_code": e.status_code,
"duration_ms": e.duration_ms,
"client_ip": e.client_ip,
"worker": e.worker,
"response_code": e.response_code,
"request_id": e.request_id,
}
for e in recent_100
]
# 按 worker 聚合
by_worker: Dict[str, dict] = {}
for e in snapshot:
w = e.worker or "(网关本地)"
if w not in by_worker:
by_worker[w] = {"count": 0, "total_duration": 0.0, "ok": 0}
by_worker[w]["count"] += 1
by_worker[w]["total_duration"] += e.duration_ms
if e.response_code == 0:
by_worker[w]["ok"] += 1
workers = sorted(
({
"worker": w,
"count": v["count"],
"avg_duration_ms": round(v["total_duration"] / v["count"], 1),
"success_rate": round(v["ok"] / v["count"] * 100, 1) if v["count"] else 0,
} for w, v in by_worker.items()),
key=lambda x: -x["count"],
)
return {
"summary": {
"total": total,
"success_rate": round(ok_count / total * 100, 1),
"avg_duration_ms": round(sum(durations) / len(durations), 1),
"min_duration_ms": round(min(durations), 1),
"max_duration_ms": round(max(durations), 1),
},
"endpoints": endpoints,
"workers": workers,
"recent": recent,
"last_updated": datetime.datetime.utcnow().isoformat() + "Z",
}
+6 -3
View File
@@ -130,6 +130,7 @@ async function submitTest() {
const file = fileInput.files[0];
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
const _reqStart = performance.now();
const btn = $('submitBtn');
btn.disabled = true;
@@ -149,21 +150,23 @@ async function submitTest() {
try {
const resp = await fetch(API_BASE + '/api/v1/face/measure', { method: 'POST', body: form });
const json = await resp.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
$('resultsArea').classList.remove('hidden');
if (json.code === 0) {
setStatus('✅ 请求成功 — request_id: ' + json.request_id, 'success');
setStatus('✅ 请求成功 (' + _elapsed + 's) — request_id: ' + json.request_id, 'success');
showOverlay(json.data.annotated_image_url);
renderMetrics(json.data);
$('metricsBar').classList.remove('hidden');
} else {
setStatus('❌ 业务错误 — code: ' + json.code + ' message: ' + json.message, 'error');
setStatus('❌ 业务错误 (' + _elapsed + 's) — code: ' + json.code + ' message: ' + json.message, 'error');
$('imgPanel').innerHTML = '<span class="placeholder">请求失败,无标注图</span>';
}
} catch (err) {
setStatus('❌ 网络错误: ' + err.message, 'error');
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
setStatus('❌ 网络错误 (' + _elapsed + 's): ' + err.message, 'error');
$('jsonContent').textContent = 'Error: ' + err.message;
$('resultsArea').classList.remove('hidden');
$('imgPanel').innerHTML = '<span class="placeholder">请求失败</span>';
+7 -3
View File
@@ -150,20 +150,24 @@ async function submitTest() {
fd.append('hair_style', checked.join(','));
fd.append('use_mask', $('useMask').value);
fd.append('prompt', $('promptInput').value);
const _reqStart = performance.now();
try {
const r = await fetch(API_BASE + '/api/v1/hair/grow', { method:'POST', body:fd });
const json = await r.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
$('resultsArea').classList.remove('hidden');
if (json.code === 0) {
const results = json.data.results || [];
setStatus('✅ ' + results.length + ' 个方案', 'success');
setStatus('✅ ' + results.length + ' 个方案 (' + _elapsed + 's)', 'success');
renderSchemes(results);
} else {
setStatus('❌ code=' + json.code + ' ' + json.message, 'error');
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
$('schemesContainer').innerHTML = '<div style="color:#9ca3af">无结果</div>';
}
} catch(e) { setStatus('❌ ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
} catch(e) {
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
setStatus('❌ (' + _elapsed + 's) ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
finally { $('submitBtn').disabled = false; $('submitBtn').textContent = '🚀 提交'; }
}
+7 -3
View File
@@ -159,9 +159,11 @@ async function submitTest() {
fd.append('use_mask', $('useMask').value);
fd.append('prompt', $('promptInput').value);
const _reqStart = performance.now();
try {
const r = await fetch(API_BASE + '/api/v1/hair/grow-b', { method:'POST', body:fd });
const json = await r.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
$('resultsArea').classList.remove('hidden');
@@ -186,12 +188,14 @@ async function submitTest() {
return;
}
setStatus('✅ 成功 &nbsp;|&nbsp; 发际线类型: ' + (d.hairline_type || '—'), 'success');
setStatus('✅ 成功 (' + _elapsed + 's) &nbsp;|&nbsp; 发际线类型: ' + (d.hairline_type || '—'), 'success');
} else {
setStatus('❌ code=' + json.code + ' ' + json.message, 'error');
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
$('blendTop').style.display = 'none';
}
} catch(e) { setStatus('❌ ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
} catch(e) {
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
setStatus('❌ (' + _elapsed + 's) ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
finally { $('submitBtn').disabled = false; $('submitBtn').textContent = '🚀 提交'; }
}
+7 -3
View File
@@ -129,20 +129,24 @@ async function submitTest() {
setStatus('⏳ 调用AI视觉模型...', 'info'); $('resultsArea').classList.add('hidden');
const fd = new FormData(); fd.append('image_file', f);
const _reqStart = performance.now();
try {
const r = await fetch(API_BASE + '/api/v1/face/features', { method:'POST', body:fd });
const json = await r.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
$('resultsArea').classList.remove('hidden');
if (json.code === 0) {
const feats = JSON.parse(json.data.features);
setStatus('✅ 分析完成 — ' + Object.keys(feats).length + ' 项特征', 'success');
setStatus('✅ 分析完成 (' + _elapsed + 's) — ' + Object.keys(feats).length + ' 项特征', 'success');
renderFeatures(feats);
} else {
setStatus('❌ code=' + json.code + ' ' + json.message, 'error');
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
}
} catch(e) { setStatus('❌ ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
} catch(e) {
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
setStatus('❌ (' + _elapsed + 's) ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
finally { $('submitBtn').disabled = false; $('submitBtn').textContent = '🚀 分析特征'; }
}
+7 -3
View File
@@ -141,22 +141,26 @@ async function submitTest() {
setStatus('请求中...', 'info'); $('resultsArea').classList.add('hidden');
const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value);
const _reqStart = performance.now();
try {
const r = await fetch(API_BASE + '/api/v1/hairline/generate', { method:'POST', body:fd });
const json = await r.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
$('resultsArea').classList.remove('hidden');
if (json.code === 0) {
_images = json.data.hairline_images || [];
_center = json.data.best_hairline_center_point;
$('centerPoint').textContent = _center ? '(' + _center.x + ', ' + _center.y + ')' : '—';
setStatus('✅ ' + _images.length + ' 个方案', 'success');
setStatus('✅ ' + _images.length + ' 个方案 (' + _elapsed + 's)', 'success');
renderGrid();
if (_images.length) selectCard(0);
} else {
setStatus('❌ code=' + json.code + ' ' + json.message, 'error');
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
}
} catch(e) { setStatus('❌ ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
} catch(e) {
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
setStatus('❌ (' + _elapsed + 's) ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
finally { $('submitBtn').disabled = false; $('submitBtn').textContent = '🚀 提交'; }
}
+6 -3
View File
@@ -132,6 +132,7 @@ async function submitTest() {
const file = fileInput.files[0];
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
const _reqStart = performance.now();
const btn = $('submitBtn');
btn.disabled = true;
@@ -151,21 +152,23 @@ async function submitTest() {
try {
const resp = await fetch(API_BASE + '/api/v1/face/measure-v2', { method: 'POST', body: form });
const json = await resp.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
$('resultsArea').classList.remove('hidden');
if (json.code === 0) {
setStatus('✅ 请求成功 — request_id: ' + json.request_id, 'success');
setStatus('✅ 请求成功 (' + _elapsed + 's) — request_id: ' + json.request_id, 'success');
showOverlay(json.data.annotated_image_url);
renderMetrics(json.data);
$('metricsBar').classList.remove('hidden');
} else {
setStatus('❌ 业务错误 — code: ' + json.code + ' message: ' + json.message, 'error');
setStatus('❌ 业务错误 (' + _elapsed + 's) — code: ' + json.code + ' message: ' + json.message, 'error');
$('imgPanel').innerHTML = '<span class="placeholder">请求失败,无标注图</span>';
}
} catch (err) {
setStatus('❌ 网络错误: ' + err.message, 'error');
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
setStatus('❌ 网络错误 (' + _elapsed + 's): ' + err.message, 'error');
$('jsonContent').textContent = 'Error: ' + err.message;
$('resultsArea').classList.remove('hidden');
$('imgPanel').innerHTML = '<span class="placeholder">请求失败</span>';
+7 -3
View File
@@ -151,20 +151,24 @@ async function submitTest() {
fd.append('hair_style', checked.join(','));
fd.append('use_mask', $('useMask').value);
fd.append('prompt', $('promptInput').value);
const _reqStart = performance.now();
try {
const r = await fetch(API_BASE + '/api/v1/hair/grow-v2', { method:'POST', body:fd });
const json = await r.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
$('resultsArea').classList.remove('hidden');
if (json.code === 0) {
const results = json.data.results || [];
setStatus('✅ ' + results.length + ' 个结果', 'success');
setStatus('✅ ' + results.length + ' 个结果 (' + _elapsed + 's)', 'success');
renderSchemes(results);
} else {
setStatus('❌ code=' + json.code + ' ' + json.message, 'error');
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
$('schemesContainer').innerHTML = '<div style="color:#9ca3af">无结果</div>';
}
} catch(e) { setStatus('❌ ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
} catch(e) {
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
setStatus('❌ (' + _elapsed + 's) ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
finally { $('submitBtn').disabled = false; $('submitBtn').textContent = '🚀 提交'; }
}