diff --git a/.gitignore b/.gitignore index 8f4cea1..d034a71 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/gateway/app.py b/gateway/app.py index 0cf0c6f..9eb07c3 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -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 = """ + + + + +网关请求统计 + + + +
+

📊 网关请求统计

+

实时请求监控  |  每 3 秒自动刷新

+ + + +
+
请求总数
+
成功率(code=0)
+
平均响应时间
+
最短响应
+
最长响应
+
+ + +
+

📋 按接口统计

+
+ + + +
路径请求数平均耗时最大耗时成功率
暂无数据
+
+
+ + +
+

🖥️ 按 GPU Worker 统计

+
+ + + +
Worker请求数平均耗时成功率
暂无数据
+
+
+ + +
+

🕐 最近请求(最新 100 条)

+
+ + + +
时间方法路径WorkerHTTP业务码耗时客户端 IP
暂无数据
+
+
+ + +
+ + + +""" + + +# --------------------------------------------------------------------------- +# 请求统计页面 +# --------------------------------------------------------------------------- + + +@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() + + # --------------------------------------------------------------------------- # 代理路由 # --------------------------------------------------------------------------- diff --git a/gateway/config.py b/gateway/config.py index f9be687..8198955 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -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 diff --git a/gateway/forward.py b/gateway/forward.py index 5965e63..2e6aa40 100644 --- a/gateway/forward.py +++ b/gateway/forward.py @@ -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( diff --git a/gateway/logging_middleware.py b/gateway/logging_middleware.py new file mode 100644 index 0000000..2003c21 --- /dev/null +++ b/gateway/logging_middleware.py @@ -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", + } diff --git a/static/test_interface1.html b/static/test_interface1.html index 5fd61e6..e5a6fec 100644 --- a/static/test_interface1.html +++ b/static/test_interface1.html @@ -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 = '请求失败,无标注图'; } } 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 = '请求失败'; diff --git a/static/test_interface2.html b/static/test_interface2.html index a14b6a3..d747c40 100644 --- a/static/test_interface2.html +++ b/static/test_interface2.html @@ -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 = '
无结果
'; } - } 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 = '🚀 提交'; } } diff --git a/static/test_interface3.html b/static/test_interface3.html index 12394f9..f7cdb58 100644 --- a/static/test_interface3.html +++ b/static/test_interface3.html @@ -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('✅ 成功  |  发际线类型: ' + (d.hairline_type || '—'), 'success'); + setStatus('✅ 成功 (' + _elapsed + 's)  |  发际线类型: ' + (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 = '🚀 提交'; } } diff --git a/static/test_interface4.html b/static/test_interface4.html index b19d003..b272528 100644 --- a/static/test_interface4.html +++ b/static/test_interface4.html @@ -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 = '🚀 分析特征'; } } diff --git a/static/test_interface5.html b/static/test_interface5.html index 4dfb7be..21b84b0 100644 --- a/static/test_interface5.html +++ b/static/test_interface5.html @@ -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 = '🚀 提交'; } } diff --git a/static/test_interface6.html b/static/test_interface6.html index 296a807..6882c44 100644 --- a/static/test_interface6.html +++ b/static/test_interface6.html @@ -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 = '请求失败,无标注图'; } } 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 = '请求失败'; diff --git a/static/test_interface7.html b/static/test_interface7.html index 064650d..16be61a 100644 --- a/static/test_interface7.html +++ b/static/test_interface7.html @@ -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 = '
无结果
'; } - } 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 = '🚀 提交'; } }