添加统计请求的功能

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
+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()
# ---------------------------------------------------------------------------
# 代理路由
# ---------------------------------------------------------------------------