Files
hair/static/api_timing_dashboard.html
T
UbuntuandClaude bb9f55e93c feat(gateway): 全局串行化(并发=1) + 记录入参/出参/耗时日志
并发模型从「每worker并发1 + 多worker并行」改为全局串行:同一时间
只处理1个请求,其余排队;多 worker 仅作热备(主 worker 坏了才用备机)。
接口4(face/features) 走豆包、不占 GPU,不纳入串行。

- pool: asyncio.Semaphore(max_global_concurrency=1) + acquire/release_global_slot
  (依赖单进程 uvicorn 部署,已在注释中标注)
- forward: proxy_request 最外层 acquire 全局槽、try/finally 全路径释放;
  入参 multipart 解析挂 request.state;原重试/故障转移逻辑抽到 _dispatch_with_retries
- reqlog(新): 标量入参保留;图片(file/base64)存盘转URL,绝不内嵌base64;
  出参递归摘要截断(landmarks/长串/大数组)
- logging_middleware: RequestLogEntry 加 request_params/response_data,
  jsonl 全量记录;_load_from_logfile 同步映射防重启丢字段;get_stats recent 暴露
- /gateway-health 暴露 global_max/global_busy/global_waiting
- config: dispatch 新增 max_global_concurrency / max_queue_wait_seconds
- tests: test_reqlog + test_gateway_serialization(9 用例)

顺带提交此前未提交的网关统计(daily stats 接口)与耗时看板(api_timing_dashboard.html)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-23 22:56:48 +08:00

232 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API 耗时看板</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: 1200px; margin: 0 auto; padding: 24px; }
h1 { font-size: 22px; margin-bottom: 4px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 18px; }
.nav { margin-bottom: 18px; }
.nav a { color: #2563eb; text-decoration: none; font-size: 13px; margin-right: 14px; }
.nav a:hover { text-decoration: underline; }
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
.toolbar label { font-size: 13px; font-weight: 600; color: #374151; }
.toolbar select, .toolbar button { padding: 8px 14px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; background: #fff; cursor: pointer; }
.toolbar select { min-width: 160px; }
.toolbar button { background: #2563eb; color: #fff; border: none; font-weight: 600; }
.toolbar button:hover { background: #1d4ed8; }
#loadingHint { font-size: 12px; color: #9ca3af; }
.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: 26px; 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; }
.hourly-wrap { background: #fff; border-radius: 12px; padding: 16px 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 24px; }
.hourly-wrap h2 { font-size: 14px; color: #374151; margin-bottom: 12px; }
.hourly-bars { display: flex; align-items: flex-end; gap: 4px; height: 80px; }
.hourly-bar { flex: 1; background: #93c5fd; border-radius: 3px 3px 0 0; position: relative; min-height: 2px; }
.hourly-bar .cnt { position: absolute; top: -16px; left: 0; right: 0; text-align: center; font-size: 10px; color: #6b7280; }
.hourly-labels { display: flex; gap: 4px; margin-top: 4px; }
.hourly-labels span { flex: 1; text-align: center; font-size: 10px; color: #9ca3af; }
.ep-card { background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 16px; overflow: hidden; }
.ep-header { padding: 14px 18px; background: #fafafa; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.ep-badge { background: #2563eb; color: #fff; padding: 3px 12px; border-radius: 12px; font-size: 12px; font-weight: 700; white-space: nowrap; }
.ep-name { font-size: 15px; font-weight: 700; color: #111827; }
.ep-path { font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; color: #9ca3af; }
.ep-success { margin-left: auto; font-size: 12px; }
.ep-body { padding: 16px 18px; }
.metrics-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 10px; margin-bottom: 4px; }
.metric { text-align: center; padding: 10px 4px; background: #f9fafb; border-radius: 8px; }
.metric .v { font-size: 17px; font-weight: 700; color: #1f2937; }
.metric .l { font-size: 10px; color: #9ca3af; margin-top: 2px; text-transform: uppercase; letter-spacing: .3px; }
.style-box { margin-top: 14px; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 10px; padding: 14px 16px; }
.style-box .title { font-size: 13px; font-weight: 700; color: #1e40af; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.style-box .formula { font-size: 12px; color: #1e3a8a; margin-bottom: 10px; font-family: "SF Mono", "Fira Code", monospace; background: #dbeafe; padding: 6px 10px; border-radius: 6px; display: inline-block; }
.style-metrics { display: flex; gap: 24px; margin-bottom: 10px; flex-wrap: wrap; }
.style-metrics .sm { }
.style-metrics .sm .v { font-size: 22px; font-weight: 700; color: #1d4ed8; }
.style-metrics .sm .l { font-size: 11px; color: #3b82f6; margin-top: 2px; }
.cluster-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 6px; }
.cluster-table th, .cluster-table td { padding: 5px 10px; text-align: center; border-bottom: 1px solid #dbeafe; }
.cluster-table th { color: #1e40af; font-weight: 700; }
.no-breakdown { font-size: 12px; color: #9ca3af; margin-top: 10px; }
.empty-state { text-align: center; padding: 60px 20px; color: #9ca3af; }
.footer-note { font-size: 12px; color: #9ca3af; margin-top: 20px; line-height: 1.8; }
.footer-note b { color: #6b7280; }
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.metrics-row { grid-template-columns: repeat(3, 1fr); }
}
</style>
</head>
<body>
<div class="container">
<h1>⏱️ API 耗时看板</h1>
<p class="subtitle">按天统计各接口调用次数与耗时分布,多发型接口额外拆解「单次换发型耗时」与「整体耗时」</p>
<div class="nav">
<a href="/">← 返回首页</a>
<a href="/admin/stats">实时请求统计</a>
<a href="/docs">API 文档</a>
</div>
<div class="toolbar">
<label for="dateSelect">选择日期</label>
<select id="dateSelect" onchange="loadDate()"></select>
<button onclick="loadDate()">🔄 刷新</button>
<span id="loadingHint"></span>
</div>
<div id="content"></div>
<div class="footer-note">
<div><b>说明:</b>「整个 API 耗时」为网关实测的请求总耗时(收到请求到返回响应),已排除网络传输本身的额外延迟。</div>
<div><b>单次换发型耗时</b>为估算值:C端生发 / C端生发v2 / 发际线PNG生成 这几个接口,对每个勾选的发型会串行调用一次 ComfyUI 重绘,
总耗时 ≈ 固定开销(人脸检测等,通常 &lt; 1s)+ 单次换发型耗时 × 勾选的发型数量。日志未记录具体勾选了几个发型,
这里基于当天所有请求耗时的分布,用迭代最小二乘回归拆解出「单次换发型耗时」与「固定开销」两个分量,供参考。</div>
</div>
</div>
<script>
function fmtSeconds(ms) {
if (ms == null) return '—';
const s = ms / 1000;
if (s < 60) return s.toFixed(1) + 's';
return (s / 60).toFixed(1) + 'min';
}
function fmtSecondsRaw(s) {
if (s == null) return '—';
if (s < 60) return s.toFixed(1) + 's';
return (s / 60).toFixed(1) + 'min';
}
async function loadDates() {
const r = await fetch('/admin/stats/dates');
const data = await r.json();
const sel = document.getElementById('dateSelect');
sel.innerHTML = '';
if (!data.dates || data.dates.length === 0) {
sel.innerHTML = '<option>暂无数据</option>';
return;
}
data.dates.forEach((d, i) => {
const opt = document.createElement('option');
opt.value = d;
opt.textContent = d + (i === 0 ? '(今天)' : '');
sel.appendChild(opt);
});
loadDate();
}
async function loadDate() {
const date = document.getElementById('dateSelect').value;
if (!date) return;
document.getElementById('loadingHint').textContent = '⏳ 加载中...';
try {
const r = await fetch('/admin/stats/daily?date=' + encodeURIComponent(date));
const data = await r.json();
render(data);
document.getElementById('loadingHint').textContent = '✅ 已更新 ' + new Date().toLocaleTimeString();
} catch (e) {
document.getElementById('loadingHint').textContent = '❌ 加载失败: ' + e.message;
}
}
function render(data) {
const el = document.getElementById('content');
if (data.error) {
el.innerHTML = '<div class="empty-state">⚠ ' + data.error + '</div>';
return;
}
if (!data.summary || data.summary.total === 0) {
el.innerHTML = '<div class="empty-state">📭 ' + data.date + ' 当天没有调用记录</div>';
return;
}
const s = data.summary;
let html = '';
// 汇总卡片
html += '<div class="stats-grid">' +
'<div class="stat-card"><div class="value">' + s.total + '</div><div class="label">当天请求总数</div></div>' +
'<div class="stat-card ok"><div class="value">' + s.success_rate + '%</div><div class="label">成功率</div></div>' +
'<div class="stat-card"><div class="value">' + fmtSeconds(s.avg_duration_ms) + '</div><div class="label">平均耗时/次</div></div>' +
'<div class="stat-card"><div class="value">' + fmtSecondsRaw(s.total_duration_seconds) + '</div><div class="label">累计耗时</div></div>' +
'<div class="stat-card"><div class="value">' + fmtSeconds(s.max_duration_ms) + '</div><div class="label">最长单次耗时</div></div>' +
'</div>';
// 按小时分布
if (data.hourly && data.hourly.length > 0) {
const maxCnt = Math.max(...data.hourly.map(h => h.count));
html += '<div class="hourly-wrap"><h2>📅 按小时请求量分布</h2><div class="hourly-bars">';
data.hourly.forEach(h => {
const pct = Math.max(4, Math.round(h.count / maxCnt * 100));
html += '<div class="hourly-bar" style="height:' + pct + '%"><div class="cnt">' + h.count + '</div></div>';
});
html += '</div><div class="hourly-labels">';
data.hourly.forEach(h => {
html += '<span>' + String(h.hour).padStart(2, '0') + '时</span>';
});
html += '</div></div>';
}
// 按接口
data.endpoints.forEach(ep => {
const badge = ep.interface_num != null ? ('接口' + ep.interface_num) : '未知接口';
html += '<div class="ep-card">' +
'<div class="ep-header">' +
'<span class="ep-badge">' + badge + '</span>' +
'<span class="ep-name">' + ep.interface_name + '</span>' +
'<span class="ep-path">' + ep.path + '</span>' +
'<span class="ep-success">调用 <b>' + ep.count + '</b> 次 · 成功率 <b style="color:' + (ep.success_rate >= 99 ? '#059669' : '#d97706') + '">' + ep.success_rate + '%</b></span>' +
'</div>' +
'<div class="ep-body">' +
'<div class="metrics-row">' +
'<div class="metric"><div class="v">' + fmtSeconds(ep.avg_duration_ms) + '</div><div class="l">平均耗时</div></div>' +
'<div class="metric"><div class="v">' + fmtSeconds(ep.median_duration_ms) + '</div><div class="l">中位数</div></div>' +
'<div class="metric"><div class="v">' + fmtSeconds(ep.p95_duration_ms) + '</div><div class="l">P95</div></div>' +
'<div class="metric"><div class="v">' + fmtSeconds(ep.min_duration_ms) + '</div><div class="l">最快</div></div>' +
'<div class="metric"><div class="v">' + fmtSeconds(ep.max_duration_ms) + '</div><div class="l">最慢</div></div>' +
'<div class="metric"><div class="v">' + fmtSecondsRaw(ep.total_duration_seconds) + '</div><div class="l">累计耗时</div></div>' +
'</div>';
if (ep.per_style) {
const ps = ep.per_style;
html += '<div class="style-box">' +
'<div class="title">✂️ 单次换发型耗时拆解(估算)</div>' +
'<div class="style-metrics">' +
'<div class="sm"><div class="v">' + ps.per_style_seconds.toFixed(1) + 's</div><div class="l">单次换发型耗时</div></div>' +
'<div class="sm"><div class="v">' + ps.fixed_overhead_seconds.toFixed(1) + 's</div><div class="l">固定开销</div></div>' +
'</div>' +
'<div class="formula">整体 API 耗时 ≈ ' + ps.fixed_overhead_seconds.toFixed(1) + 's + ' + ps.per_style_seconds.toFixed(1) + 's × 发型数量</div>' +
'<table class="cluster-table"><thead><tr><th>推断发型数</th><th>样本数</th><th>平均耗时</th></tr></thead><tbody>';
ps.clusters.forEach(c => {
html += '<tr><td>' + c.styles + '</td><td>' + c.count + '</td><td>' + c.avg_seconds.toFixed(1) + 's</td></tr>';
});
html += '</tbody></table></div>';
} else if (['/api/v1/hair/grow', '/api/v1/hair/grow-v2', '/api/v1/hairline/generate'].includes(ep.path)) {
html += '<div class="no-breakdown">️ 当天该接口耗时分布过于集中,样本不足以拆解出单次换发型耗时(换个天试试,或等积累更多调用)</div>';
}
html += '</div></div>';
});
el.innerHTML = html;
}
loadDates();
</script>
</body>
</html>