BIZ-46 Phase3: 7项 follow-up 开发完成

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>
This commit is contained in:
2026-06-24 22:26:35 +08:00
parent 8a12ff9693
commit b18d243ef2
12 changed files with 928 additions and 312 deletions
+77 -11
View File
@@ -39,9 +39,35 @@
@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>
@@ -64,7 +90,8 @@
<canvas id="chart-tokens"></canvas>
</div>
<div class="card">
<h2>📈 队列深度</h2>
<!-- 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">
@@ -99,7 +126,16 @@
let evtSource = null;
let dataHistory = { throughput: [], rates: [] };
const MAX_HISTORY = 20;
let latencyLog = [];
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();
@@ -107,8 +143,10 @@ function connectSSE() {
evtSource.onmessage = (e) => {
try {
const snap = JSON.parse(e.data);
lastSSETime = Date.now();
// 隐藏断连遮罩
document.getElementById('reconnect-mask').classList.remove('visible');
updateDashboard(snap);
updateLatencies(snap);
document.getElementById('conn-status').className = 'connected';
document.getElementById('conn-status').textContent = '已连接';
} catch (err) {
@@ -130,7 +168,9 @@ const chartTokens = new Chart(ctxTokens, {
labels: ['已用令牌', '可用令牌'],
datasets: [{ data: [0, 40], backgroundColor: ['#ef4444', '#22c55e'], borderWidth: 0 }]
},
options: { responsive: true, maintainAspectRatio: true, cutout: '65%', plugins: { legend: { position: 'bottom', labels: { color: '#94a3b8' } } } }
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');
@@ -140,7 +180,11 @@ const chartQueue = new Chart(ctxQueue, {
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 } } }
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');
@@ -151,7 +195,10 @@ const chartThroughput = new Chart(ctxThroughput, {
{ 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' } } } }
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');
@@ -161,7 +208,10 @@ const chartRate = new Chart(ctxRate, {
{ 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' } } } }
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) {
@@ -188,6 +238,7 @@ function updateDashboard(snap) {
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,
@@ -196,6 +247,9 @@ function updateDashboard(snap) {
];
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);
@@ -217,10 +271,6 @@ function updateDashboard(snap) {
chartRate.update();
}
function updateLatencies(snap) {
const tb = snap.token_bucket || {};
}
function fmtDuration(s) {
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s/60) + 'm ' + (s%60) + 's';
@@ -255,6 +305,22 @@ function showToast(type, msg) {
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>