添加统计请求的功能
This commit is contained in:
+267
-1
@@ -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">实时请求监控 | 每 3 秒自动刷新</p>
|
||||
<div class="nav">
|
||||
<a href="/">← 返回首页</a> |
|
||||
<a href="/docs">API 文档</a> |
|
||||
<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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 代理路由
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user