b18d243ef2
1. 架构解耦 — SidecarContext + FastAPI Depends 注入 - 新增 context.py: SidecarContext dataclass 收敛全部全局状态 - server.py: 移除模块级全局变量,lifespan 创建 ctx → app.state.sidecar - webui.py: 移除反向导入 server,改用 Depends(get_context) 2. Prometheus 标签基数治理 — model_id → provider - upstream_latency_seconds / upstream_errors_total label 收敛为 provider - 模型级信息保留在 structlog JSON 日志 3. SSE 快照共享缓存 - 1s TTL 共享 snapshot cache + double-check locking - 多客户端不重复构建快照 4. 部署支撑 - Dockerfile (python:3.12-slim, 非 root 用户, HEALTHCHECK) - systemd service (安全加固, 资源限制) - .env.example (完整环境变量清单) 5. Readiness HTTP Client 复用 - check_upstream() 注入主 http_client,不再每次创建新 client 6. Retreat 并发回归测试 - 5 个测试用例全部通过(死锁检测 + 状态转换 + 并发安全) 7. Dashboard UX 优化 - 队列柱状图 300ms 平滑动画 - SSE 断连 5s 半透明遮罩 - 队列图标题显示总排队数 - 页面加载同步配置 验证: mypy strict 通过 (0 errors), pytest 5/5 通过, server 导入正常 (13 routes) Co-authored-by: multica-agent <github@multica.ai>
327 lines
15 KiB
HTML
327 lines
15 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>NVIDIA Sidecar — 实时仪表盘</title>
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 24px; }
|
||
h1 { font-size: 22px; font-weight: 600; margin-bottom: 4px; color: #f8fafc; }
|
||
.subtitle { color: #94a3b8; font-size: 13px; margin-bottom: 24px; }
|
||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); gap: 20px; margin-bottom: 24px; }
|
||
.card { background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155; }
|
||
.card h2 { font-size: 15px; font-weight: 600; color: #94a3b8; margin-bottom: 14px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||
.card canvas { max-height: 220px; }
|
||
.stat-row { display: flex; gap: 16px; flex-wrap: wrap; }
|
||
.stat { flex: 1; min-width: 100px; background: #0f172a; border-radius: 8px; padding: 12px; text-align: center; border: 1px solid #334155; }
|
||
.stat .value { font-size: 28px; font-weight: 700; color: #38bdf8; }
|
||
.stat .label { font-size: 11px; color: #64748b; margin-top: 4px; text-transform: uppercase; }
|
||
.stat.warn .value { color: #f59e0b; }
|
||
.stat.danger .value { color: #ef4444; }
|
||
.retreat-badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; }
|
||
.retreat-badge.normal { background: #065f46; color: #6ee7b7; }
|
||
.retreat-badge.retreat { background: #78350f; color: #fbbf24; }
|
||
.retreat-badge.recover { background: #1e3a5f; color: #60a5fa; }
|
||
.config-panel { background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155; }
|
||
.config-panel h2 { font-size: 15px; font-weight: 600; color: #94a3b8; margin-bottom: 14px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||
.config-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
|
||
.config-row label { min-width: 100px; font-size: 13px; color: #cbd5e1; }
|
||
.config-row input, .config-row select { background: #0f172a; border: 1px solid #334155; border-radius: 6px; color: #e2e8f0; padding: 6px 10px; font-size: 13px; }
|
||
.config-row input[type="range"] { width: 140px; }
|
||
.config-row button { background: #38bdf8; color: #0f172a; border: none; border-radius: 6px; padding: 6px 16px; font-size: 13px; font-weight: 600; cursor: pointer; }
|
||
.config-row button:hover { background: #7dd3fc; }
|
||
.config-row button:disabled { background: #475569; cursor: not-allowed; }
|
||
.toast { position: fixed; top: 16px; right: 16px; padding: 10px 20px; border-radius: 8px; font-size: 13px; z-index: 999; animation: fadeInOut 3s; }
|
||
.toast.success { background: #065f46; color: #6ee7b7; }
|
||
.toast.error { background: #7f1d1d; color: #fca5a5; }
|
||
@keyframes fadeInOut { 0% { opacity: 0; transform: translateY(-8px); } 10% { opacity: 1; transform: translateY(0); } 80% { opacity: 1; } 100% { opacity: 0; } }
|
||
.disconnected { background: #7f1d1d; color: #fca5a5; padding: 4px 10px; border-radius: 4px; font-size: 12px; display: inline-block; margin-left: 8px; }
|
||
.connected { background: #065f46; color: #6ee7b7; padding: 4px 10px; border-radius: 4px; font-size: 12px; display: inline-block; margin-left: 8px; }
|
||
|
||
/* BIZ-46 Phase3: 队列柱状图 300ms 平滑动画 */
|
||
.queue-bar { transition: height 0.3s ease; }
|
||
|
||
/* BIZ-46 Phase3: SSE 断连 5s 半透明遮罩 */
|
||
#reconnect-mask {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0; left: 0; right: 0; bottom: 0;
|
||
background: rgba(15, 23, 42, 0.85);
|
||
z-index: 1000;
|
||
justify-content: center;
|
||
align-items: center;
|
||
flex-direction: column;
|
||
}
|
||
#reconnect-mask.visible { display: flex; }
|
||
#reconnect-mask .mask-icon { font-size: 48px; margin-bottom: 16px; }
|
||
#reconnect-mask .mask-text { color: #94a3b8; font-size: 16px; font-weight: 500; }
|
||
#reconnect-mask .mask-sub { color: #64748b; font-size: 13px; margin-top: 8px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- BIZ-46 Phase3: SSE 断连遮罩 -->
|
||
<div id="reconnect-mask">
|
||
<div class="mask-icon">⚠️</div>
|
||
<div class="mask-text">数据暂不可用</div>
|
||
<div class="mask-sub">SSE 连接中断,正在重连…</div>
|
||
</div>
|
||
|
||
<h1>🚀 NVIDIA Sidecar 实时仪表盘
|
||
<span id="conn-status" class="connected">已连接</span>
|
||
</h1>
|
||
<p class="subtitle">令牌桶限流 · 优先级队列 · 避退模式 · 实时监控</p>
|
||
|
||
<!-- 状态卡片 -->
|
||
<div class="stat-row" style="margin-bottom: 24px;">
|
||
<div class="stat"><div class="value" id="val-total">0</div><div class="label">总请求</div></div>
|
||
<div class="stat"><div class="value" id="val-nvidia">0</div><div class="label">NVIDIA 请求</div></div>
|
||
<div class="stat"><div class="value" id="val-rate">0</div><div class="label">当前 RPM</div></div>
|
||
<div class="stat"><div class="value" id="val-429">0%</div><div class="label">上游 429 率</div></div>
|
||
<div class="stat"><div class="value" id="val-retreat">正常</div><div class="label">避退状态</div></div>
|
||
<div class="stat"><div class="value" id="val-uptime">0s</div><div class="label">运行时间</div></div>
|
||
</div>
|
||
|
||
<!-- 图表 -->
|
||
<div class="grid">
|
||
<div class="card">
|
||
<h2>📊 令牌桶使用率</h2>
|
||
<canvas id="chart-tokens"></canvas>
|
||
</div>
|
||
<div class="card">
|
||
<!-- BIZ-46 Phase3: 队列图标题显示总排队数 -->
|
||
<h2>📈 队列深度 <span id="queue-total" style="font-size:13px;color:#38bdf8;">(共 0)</span></h2>
|
||
<canvas id="chart-queue"></canvas>
|
||
</div>
|
||
<div class="card">
|
||
<h2>📉 请求吞吐量 (最近 20 点)</h2>
|
||
<canvas id="chart-throughput"></canvas>
|
||
</div>
|
||
<div class="card">
|
||
<h2>⚙️ 速率历史</h2>
|
||
<canvas id="chart-rate"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 配置面板 -->
|
||
<div class="config-panel">
|
||
<h2>🔧 实时配置</h2>
|
||
<div class="config-row">
|
||
<label>速率 (RPM)</label>
|
||
<input type="range" id="cfg-rate-rpm" min="1" max="100" value="40" oninput="document.getElementById('cfg-rate-val').textContent=this.value">
|
||
<span id="cfg-rate-val" style="min-width:30px;">40</span>
|
||
</div>
|
||
<div class="config-row">
|
||
<label>队列上限</label>
|
||
<input type="number" id="cfg-queue-max" value="500" min="1" max="2000" style="width:80px;">
|
||
</div>
|
||
<div class="config-row">
|
||
<button onclick="applyConfig()">应用配置</button>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// SSE 连接
|
||
let evtSource = null;
|
||
let dataHistory = { throughput: [], rates: [] };
|
||
const MAX_HISTORY = 20;
|
||
let lastSSETime = Date.now();
|
||
|
||
// BIZ-46 Phase3: SSE 断连 5s 遮罩
|
||
function checkReconnect() {
|
||
const mask = document.getElementById('reconnect-mask');
|
||
if (Date.now() - lastSSETime > 5000) {
|
||
mask.classList.add('visible');
|
||
}
|
||
}
|
||
setInterval(checkReconnect, 1000);
|
||
|
||
function connectSSE() {
|
||
if (evtSource) evtSource.close();
|
||
evtSource = new EventSource('/api/dashboard/stream');
|
||
evtSource.onmessage = (e) => {
|
||
try {
|
||
const snap = JSON.parse(e.data);
|
||
lastSSETime = Date.now();
|
||
// 隐藏断连遮罩
|
||
document.getElementById('reconnect-mask').classList.remove('visible');
|
||
updateDashboard(snap);
|
||
document.getElementById('conn-status').className = 'connected';
|
||
document.getElementById('conn-status').textContent = '已连接';
|
||
} catch (err) {
|
||
document.getElementById('conn-status').className = 'disconnected';
|
||
document.getElementById('conn-status').textContent = '解析错误';
|
||
}
|
||
};
|
||
evtSource.onerror = () => {
|
||
document.getElementById('conn-status').className = 'disconnected';
|
||
document.getElementById('conn-status').textContent = '断开 - 重连中';
|
||
};
|
||
}
|
||
|
||
// 初始化 Chart.js
|
||
const ctxTokens = document.getElementById('chart-tokens').getContext('2d');
|
||
const chartTokens = new Chart(ctxTokens, {
|
||
type: 'doughnut',
|
||
data: {
|
||
labels: ['已用令牌', '可用令牌'],
|
||
datasets: [{ data: [0, 40], backgroundColor: ['#ef4444', '#22c55e'], borderWidth: 0 }]
|
||
},
|
||
options: { responsive: true, maintainAspectRatio: true, cutout: '65%', plugins: { legend: { position: 'bottom', labels: { color: '#94a3b8' } } },
|
||
// BIZ-46 Phase3: 300ms 平滑动画
|
||
animation: { duration: 300 } }
|
||
});
|
||
|
||
const ctxQueue = document.getElementById('chart-queue').getContext('2d');
|
||
const chartQueue = new Chart(ctxQueue, {
|
||
type: 'bar',
|
||
data: {
|
||
labels: ['URGENT', 'HIGH', 'NORMAL', 'LOW'],
|
||
datasets: [{ label: '排队数', data: [0, 0, 0, 0], backgroundColor: ['#ef4444', '#f59e0b', '#38bdf8', '#a78bfa'] }]
|
||
},
|
||
options: { responsive: true, maintainAspectRatio: true,
|
||
scales: { y: { beginAtZero: true, ticks: { color: '#94a3b8' } }, x: { ticks: { color: '#94a3b8' } } },
|
||
plugins: { legend: { display: false } },
|
||
// BIZ-46 Phase3: 300ms 平滑动画
|
||
animation: { duration: 300 } }
|
||
});
|
||
|
||
const ctxThroughput = document.getElementById('chart-throughput').getContext('2d');
|
||
const chartThroughput = new Chart(ctxThroughput, {
|
||
type: 'line',
|
||
data: { labels: [], datasets: [
|
||
{ label: '成功', data: [], borderColor: '#22c55e', backgroundColor: '#22c55e20', fill: false, tension: 0.3, pointRadius: 2 },
|
||
{ label: '429', data: [], borderColor: '#f59e0b', backgroundColor: '#f59e0b20', fill: false, tension: 0.3, pointRadius: 2 },
|
||
{ label: '直通', data: [], borderColor: '#a78bfa', backgroundColor: '#a78bfa20', fill: false, tension: 0.3, pointRadius: 2 },
|
||
]},
|
||
options: { responsive: true, maintainAspectRatio: true,
|
||
scales: { y: { beginAtZero: true, ticks: { color: '#94a3b8' } }, x: { ticks: { color: '#94a3b8' } } },
|
||
plugins: { legend: { position: 'bottom', labels: { color: '#94a3b8' } } },
|
||
animation: { duration: 300 } }
|
||
});
|
||
|
||
const ctxRate = document.getElementById('chart-rate').getContext('2d');
|
||
const chartRate = new Chart(ctxRate, {
|
||
type: 'line',
|
||
data: { labels: [], datasets: [
|
||
{ label: '有效 RPM', data: [], borderColor: '#38bdf8', fill: false, tension: 0.3, pointRadius: 2 },
|
||
{ label: '基准 RPM', data: [], borderColor: '#64748b', fill: false, tension: 0.3, pointRadius: 2, borderDash: [4, 4] },
|
||
]},
|
||
options: { responsive: true, maintainAspectRatio: true,
|
||
scales: { y: { beginAtZero: true, ticks: { color: '#94a3b8' } }, x: { ticks: { color: '#94a3b8' } } },
|
||
plugins: { legend: { position: 'bottom', labels: { color: '#94a3b8' } } },
|
||
animation: { duration: 300 } }
|
||
});
|
||
|
||
function updateDashboard(snap) {
|
||
const r = snap.requests || {};
|
||
const tb = snap.token_bucket || {};
|
||
const rt = snap.retreat || {};
|
||
|
||
document.getElementById('val-total').textContent = (r.total || 0).toLocaleString();
|
||
document.getElementById('val-nvidia').textContent = (r.nvidia || 0).toLocaleString();
|
||
document.getElementById('val-rate').textContent = Math.round(rt.effective_rpm || 40);
|
||
document.getElementById('val-429').textContent = ((rt.upstream_429_rate || 0) * 100).toFixed(1) + '%';
|
||
document.getElementById('val-uptime').textContent = fmtDuration(snap.uptime_seconds || 0);
|
||
|
||
const retreatEl = document.getElementById('val-retreat');
|
||
const state = rt.state || 'normal';
|
||
retreatEl.textContent = state === 'retreat' ? '⚠️ 避退' : state === 'recover' ? '↗ 恢复中' : '✅ 正常';
|
||
retreatEl.style.color = state === 'retreat' ? '#f59e0b' : state === 'recover' ? '#60a5fa' : '#22c55e';
|
||
|
||
chartTokens.data.datasets[0].data = [
|
||
Math.round((tb.capacity || 40) - (tb.tokens || 40)),
|
||
Math.round(tb.tokens || 0)
|
||
];
|
||
chartTokens.update();
|
||
|
||
const qs = snap.queue || {};
|
||
const perPriority = qs.per_priority || {};
|
||
const totalQueued = perPriority.URGENT + perPriority.HIGH + perPriority.NORMAL + perPriority.LOW || qs.current_size || 0;
|
||
chartQueue.data.datasets[0].data = [
|
||
perPriority.URGENT || 0,
|
||
perPriority.HIGH || 0,
|
||
perPriority.NORMAL || 0,
|
||
perPriority.LOW || 0
|
||
];
|
||
chartQueue.update();
|
||
|
||
// BIZ-46 Phase3: 队列图标题显示总排队数
|
||
document.getElementById('queue-total').textContent = '(共 ' + totalQueued + ')';
|
||
|
||
const now = new Date().toLocaleTimeString();
|
||
const prev = dataHistory.throughput.length > 0 ? dataHistory.throughput[dataHistory.throughput.length - 1].nvidia : 0;
|
||
const throughput = Math.max(0, (r.nvidia || 0) - prev);
|
||
|
||
dataHistory.throughput.push({ time: now, nvidia: throughput, ratelimited: r.ratelimited || 0, passthrough: r.passthrough || 0 });
|
||
dataHistory.rates.push({ time: now, effective: rt.effective_rpm || 40, base: rt.base_rpm || 40 });
|
||
if (dataHistory.throughput.length > MAX_HISTORY) dataHistory.throughput.shift();
|
||
if (dataHistory.rates.length > MAX_HISTORY) dataHistory.rates.shift();
|
||
|
||
chartThroughput.data.labels = dataHistory.throughput.map(d => d.time);
|
||
chartThroughput.data.datasets[0].data = dataHistory.throughput.map(d => d.nvidia);
|
||
chartThroughput.data.datasets[1].data = dataHistory.throughput.map(d => d.ratelimited);
|
||
chartThroughput.data.datasets[2].data = dataHistory.throughput.map(d => d.passthrough);
|
||
chartThroughput.update();
|
||
|
||
chartRate.data.labels = dataHistory.rates.map(d => d.time);
|
||
chartRate.data.datasets[0].data = dataHistory.rates.map(d => d.effective);
|
||
chartRate.data.datasets[1].data = dataHistory.rates.map(d => d.base);
|
||
chartRate.update();
|
||
}
|
||
|
||
function fmtDuration(s) {
|
||
if (s < 60) return s + 's';
|
||
if (s < 3600) return Math.floor(s/60) + 'm ' + (s%60) + 's';
|
||
return Math.floor(s/3600) + 'h ' + Math.floor((s%3600)/60) + 'm';
|
||
}
|
||
|
||
async function applyConfig() {
|
||
const btn = document.querySelector('.config-row button');
|
||
btn.disabled = true;
|
||
try {
|
||
const resp = await fetch('/api/admin/config', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
rate_rpm: parseInt(document.getElementById('cfg-rate-rpm').value),
|
||
queue_max_size: parseInt(document.getElementById('cfg-queue-max').value),
|
||
})
|
||
});
|
||
const result = await resp.json();
|
||
showToast(resp.ok ? 'success' : 'error', resp.ok ? '配置已更新' : (result.detail || '配置更新失败'));
|
||
} catch (err) {
|
||
showToast('error', '请求失败: ' + err.message);
|
||
}
|
||
btn.disabled = false;
|
||
}
|
||
|
||
function showToast(type, msg) {
|
||
const t = document.createElement('div');
|
||
t.className = 'toast ' + type;
|
||
t.textContent = msg;
|
||
document.body.appendChild(t);
|
||
setTimeout(() => t.remove(), 3000);
|
||
}
|
||
|
||
// BIZ-46 Phase3: 页面加载时同步当前配置值
|
||
async function loadConfig() {
|
||
try {
|
||
const resp = await fetch('/api/admin/config');
|
||
if (resp.ok) {
|
||
const config = await resp.json();
|
||
document.getElementById('cfg-rate-rpm').value = config.rate_rpm || 40;
|
||
document.getElementById('cfg-rate-val').textContent = config.rate_rpm || 40;
|
||
document.getElementById('cfg-queue-max').value = config.queue_max_size || 500;
|
||
}
|
||
} catch (e) {
|
||
console.warn('配置加载失败(可能需要 Admin Token)', e);
|
||
}
|
||
}
|
||
|
||
loadConfig();
|
||
connectSSE();
|
||
</script>
|
||
</body>
|
||
</html> |