mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 08:57:57 +00:00
fix(ui): restore five uniform KPI cards — title + big value + subtitle + real canvas charts (no gray-shell placeholders)
Redo the KPI row properly: five identical cards each with title row, large metric value, subtitle, and an 86px canvas chart. Replaces the previous gray-box canvas placeholders (which dropped the numbers — a regression). - Active requests: line sparkline (real-time refresh) + live count - Total requests: 7-day vertical bar chart + current total - Tokens: stacked bar of per-model token share + big total - Avg latency: line sparkline (real-time refresh) + avg ms - Status codes: stacked bar by code + chip subtitle (code/reqs/percent) All charts drawn with plain Canvas 2D (sparkline/dailyBars helpers), no third-party lib. DPR-aware for crispness. Deployed (bak .bak.20260811l), server active.
This commit is contained in:
@ -102,17 +102,19 @@ pre.configbox { background:var(--card2); border:1px solid var(--line); border-ra
|
||||
|
||||
/* ---------- dashboard / usage metrics ---------- */
|
||||
.kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:12px; margin-bottom:18px; }
|
||||
.kpi { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:12px 14px; box-shadow:var(--shadow); display:flex; flex-direction:column; min-height:160px; }
|
||||
.kpi .k-hdr { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px; }
|
||||
.kpi { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:14px 16px; box-shadow:var(--shadow); }
|
||||
.kpi .k-hdr { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:6px; }
|
||||
.kpi .k-lab { font-size:12px; color:var(--muted); font-weight:600; white-space:nowrap; }
|
||||
.kpi .k-val { font-size:22px; font-weight:800; font-variant-numeric:tabular-nums; }
|
||||
.kpi .k-sub { font-size:11px; color:var(--muted); }
|
||||
.kpi .k-canvas { flex:1; display:flex; align-items:center; justify-content:center; min-height:80px; position:relative; }
|
||||
.kpi canvas { width:100%; height:100%; display:block; }
|
||||
.kpi .k-val { font-size:26px; font-weight:800; margin-top:4px; font-variant-numeric:tabular-nums; letter-spacing:.3px; }
|
||||
.kpi .k-val .u { font-size:13px; font-weight:600; color:var(--muted); margin-left:3px; }
|
||||
.kpi .k-sub { font-size:11.5px; color:var(--muted); margin-top:4px; }
|
||||
.kpi .k-chart { margin-top:8px; position:relative; height:86px; }
|
||||
.kpi canvas { width:100%; height:86px; display:block; }
|
||||
.kpi.kpi-exit { cursor:pointer; border-color:var(--err); }
|
||||
.kpi.kpi-exit:hover { border-color:var(--err); box-shadow:0 0 0 3px rgba(244,67,54,.15); }
|
||||
.kpi.kpi-exit .k-lab::after { content:'×'; color:var(--err); font-weight:800; font-size:15px; margin-left:auto; }
|
||||
.kpi.kpi-status { display:flex; flex-direction:column; }
|
||||
.kpi.kpi-status .k-val { font-size:22px; }
|
||||
.dot { width:7px; height:7px; border-radius:50%; background:var(--ok); display:inline-block; animation:blink 1.6s infinite; }
|
||||
.dot.err { background:var(--err); }
|
||||
@keyframes blink { 50% { opacity:.25; } }
|
||||
@ -759,24 +761,32 @@ async function paintStats() {
|
||||
const kpi = $('#kpi-row');
|
||||
if (kpi) kpi.innerHTML = `
|
||||
<div class="kpi${statsKeyF ? ' kpi-exit' : ''}" title="${statsKeyF ? t('exitKeyView') : ''}" onclick="${statsKeyF ? `renderKeyF('${escAttr(statsKeyF)}')` : ''}">
|
||||
<div class="k-hdr"><span class="k-lab">${t('kpiActive')}</span></div>
|
||||
<div class="k-canvas"><canvas id="cv-active"></canvas></div>
|
||||
<div class="k-hdr"><span class="k-lab">${t('kpiActive')} <span class="dot"></span></span><span class="okc k-sub">${st.active || 0}</span></div>
|
||||
<div class="k-chart"><canvas id="cv-active"></canvas></div>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<div class="k-hdr"><span class="k-lab">${t('kpiReqs')}</span></div>
|
||||
<div class="k-canvas"><canvas id="cv-reqs"></canvas></div>
|
||||
<div class="k-val">${fmtN(tot.reqs)}</div>
|
||||
<div class="k-sub">${t('kpiOk')} <b class="${okr >= 90 ? 'okc' : 'errc'}">${okr}%</b></div>
|
||||
<div class="k-chart"><canvas id="cv-reqs"></canvas></div>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<div class="k-hdr"><span class="k-lab">${t('kpiTokens')}</span></div>
|
||||
<div class="k-canvas"><canvas id="cv-tokens"></canvas></div>
|
||||
<div class="k-val">${fmtTok(tot.tokens)}</div>
|
||||
<div class="k-sub">${t('thPrompt')} ${fmtTok(tot.prompt_tokens)} · ${t('thCompl')} ${fmtTok(tot.completion_tokens)}</div>
|
||||
<div class="k-chart"><canvas id="cv-tokens"></canvas></div>
|
||||
</div>
|
||||
<div class="kpi">
|
||||
<div class="k-hdr"><span class="k-lab">${t('kpiLat')}</span></div>
|
||||
<div class="k-canvas"><canvas id="cv-lat"></canvas></div>
|
||||
<div class="k-val">${fmtMs(avg)}</div>
|
||||
<div class="k-sub">${t('kpiMaxLat')} ${fmtMs(tot.latency_max_ms)}</div>
|
||||
<div class="k-chart"><canvas id="cv-lat"></canvas></div>
|
||||
</div>
|
||||
<div class="kpi kpi-status">
|
||||
<div class="k-hdr"><span class="k-lab">${t('dashStatus')}</span></div>
|
||||
<div class="k-canvas"><canvas id="cv-status"></canvas></div>
|
||||
<div class="k-val">${fmtN((st.by_status||[]).reduce((a,x)=>a+x.reqs,0))}</div>
|
||||
<div class="k-sub" id="tb-status"></div>
|
||||
<div class="k-chart"><canvas id="cv-status"></canvas></div>
|
||||
</div>`;
|
||||
paintModelTable(st.by_model || []);
|
||||
paintSrcTable(st.by_source || []);
|
||||
@ -823,108 +833,116 @@ function paintSrcTable(rows) {
|
||||
}
|
||||
function paintStatusTable(rows) {
|
||||
const el = $('#tb-status'); if (!el) return;
|
||||
if (!rows.length) { el.innerHTML = `<div class="muted">${t('noUsage')}</div>`; return; }
|
||||
if (!rows.length) { el.innerHTML = ''; return; }
|
||||
const total = rows.reduce((m, x) => m + x.reqs, 0) || 1;
|
||||
const palette = ['#3fb27f', '#4fa3d8', '#f0a45c', '#e06c6c', '#b06ce0', '#5cc8d0', '#c9cc4f', '#e08c5c', '#8a9bb5', '#7fbf6f'];
|
||||
// 108 viewBox, centre (54,54), radius 34 + stroke 14 = 41 < 54-padding -> no clip.
|
||||
const CX = 54, CY = 54, R = 34, SW = 14, PAD = 2;
|
||||
const NB = 108 + PAD * 2, BOX = 108 + PAD * 2;
|
||||
const polar = (ang) => [CX + R * Math.cos(ang * Math.PI / 180), CY + R * Math.sin(ang * Math.PI / 180)];
|
||||
const sector = (a0, a1) => {
|
||||
const [x0, y0] = polar(a0), [x1, y1] = polar(a1);
|
||||
const large = (a1 - a0) > 180 ? 1 : 0;
|
||||
return `M${x0.toFixed(2)} ${y0.toFixed(2)} A${R} ${R} 0 ${large} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`;
|
||||
};
|
||||
let ang = 0;
|
||||
const paths = rows.map((r, i) => {
|
||||
const sweep = r.reqs / total * 360;
|
||||
const a0 = ang, a1 = Math.min(ang + sweep, 360);
|
||||
const p = sector(a0, a1);
|
||||
ang = a1;
|
||||
return { ...r, color: palette[i % palette.length], d: sweep > 0.5 ? p : null };
|
||||
});
|
||||
const arcs = paths.filter(x => x.d).map(r =>
|
||||
`<path d="${r.d}" fill="none" stroke="${r.color}" stroke-width="${SW}" stroke-linecap="butt">
|
||||
<title>${esc(r.name)}: ${fmtN(r.reqs)} (${(r.reqs / total * 100).toFixed(1)}%)</title>
|
||||
</path>`).join('');
|
||||
const chips = paths.map(s => `<span class="sc" title="${esc(s.name)}: ${fmtN(s.reqs)} (${(s.reqs / total * 100).toFixed(1)}%) · ${t('thOk')} ${fmtN(s.ok)} · ${t('thErr')} ${fmtN(s.err)}">
|
||||
<i style="background:${s.color}"></i>${esc(s.name)}<b>${fmtN(s.reqs)}</b></span>`).join('');
|
||||
el.innerHTML = `<div class="donut-wrap">
|
||||
<div class="donut">
|
||||
<svg width="${BOX}" height="${BOX}" viewBox="0 0 ${NB} ${NB}" style="transform:rotate(-90deg)">
|
||||
<g transform="translate(${PAD},${PAD})">${arcs}</g>
|
||||
</svg>
|
||||
<div class="center"><b>${fmtN(total)}</b><span>${t('thReqs')}</span></div>
|
||||
</div>
|
||||
<div class="status-chips">${chips}</div>
|
||||
</div>`;
|
||||
const palette = ['#3fb27f', '#4fa3d8', '#f0a45c', '#e06c6c', '#b06ce0', '#8a9bb5'];
|
||||
el.innerHTML = rows.map((r, i) => `<span class="sc" title="${esc(r.name)}: ${fmtN(r.reqs)} (${(r.reqs/total*100).toFixed(1)}%) · ${t('thOk')} ${fmtN(r.ok)} · ${t('thErr')} ${fmtN(r.err)}">
|
||||
<i style="background:${palette[i%palette.length]}"></i>${esc(r.name)}<b>${fmtN(r.reqs)}</b></span>`).join('');
|
||||
}
|
||||
// sparkline: turn a value array into a compact polyline (area fill under accent)
|
||||
function sparkline(ctx, vals, w, h, color) {
|
||||
if (!vals.length) { ctx.strokeStyle = color; ctx.setLineDash([3,3]); ctx.beginPath(); ctx.moveTo(0, h/2); ctx.lineTo(w, h/2); ctx.stroke(); ctx.setLineDash([]); return; }
|
||||
const max = Math.max.apply(null, vals), min = Math.min.apply(null, vals);
|
||||
const rng = (max - min) || 1;
|
||||
const pad = 2;
|
||||
const px = (i) => i / (vals.length - 1) * w;
|
||||
const py = (v) => pad + (1 - (v - min) / rng) * (h - pad * 2);
|
||||
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
vals.forEach((v, i) => i ? ctx.lineTo(px(i), py(v)) : ctx.moveTo(px(i), py(v)));
|
||||
ctx.stroke();
|
||||
}
|
||||
// thin vertical bars for daily request counts
|
||||
function dailyBars(ctx, counts, w, h, color) {
|
||||
if (!counts.length) { ctx.strokeStyle = color; ctx.setLineDash([3,3]); ctx.beginPath(); ctx.moveTo(0,h/2); ctx.lineTo(w,h/2); ctx.stroke(); ctx.setLineDash([]); return; }
|
||||
const max = Math.max.apply(null, counts) || 1;
|
||||
const n = counts.length, bw = w / n, gap = Math.max(1, bw * 0.3);
|
||||
ctx.fillStyle = color;
|
||||
counts.forEach((c, i) => {
|
||||
const bh = Math.max(2, c / max * h);
|
||||
ctx.fillRect(i * bw + gap / 2, h - bh, bw - gap, bh);
|
||||
});
|
||||
}
|
||||
|
||||
function drawActiveChart(st) {
|
||||
const cv = document.getElementById('cv-active'); if (!cv) return;
|
||||
const ctx = cv.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = cv.parentElement.getBoundingClientRect();
|
||||
cv.width = rect.width * dpr; cv.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
const w = rect.width, h = rect.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.fillStyle = '#2a2f3a'; ctx.fillRect(0, 0, w, h);
|
||||
ctx.font = '14px ui-monospace,Menlo,Consolas,monospace';
|
||||
ctx.fillStyle = 'var(--ok)'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText((st.active || 0) + ' ' + t('kpiActive').replace('活跃','').replace('Active',''), w/2, h/2);
|
||||
const parent = cv.parentElement, rect = parent.getBoundingClientRect();
|
||||
cv.width = rect.width * dpr; cv.height = 86 * dpr; ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, cv.width, cv.height);
|
||||
// active count across records window (bucketed per minute) as a sparkline
|
||||
const recs = st.records || [];
|
||||
const col = window.charts && window.charts.active || [];
|
||||
sparkline(ctx, col, rect.width, 86, '#4fa3d8');
|
||||
}
|
||||
function drawReqsChart(st) {
|
||||
const cv = document.getElementById('cv-reqs'); if (!cv) return;
|
||||
const ctx = cv.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = cv.parentElement.getBoundingClientRect();
|
||||
cv.width = rect.width * dpr; cv.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
const w = rect.width, h = rect.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.fillStyle = '#2a2f3a'; ctx.fillRect(0, 0, w, h);
|
||||
ctx.font = '18px ui-monospace,Menlo,Consolas,monospace';
|
||||
ctx.fillStyle = '#e2e8f0'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText(fmtN((st.total||{}).reqs), w/2, h/2);
|
||||
const parent = cv.parentElement, rect = parent.getBoundingClientRect();
|
||||
cv.width = rect.width * dpr; cv.height = 86 * dpr; ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, cv.width, cv.height);
|
||||
const recs = st.records || [];
|
||||
// bucket by UTC day -> request counts (last 7 non-empty days)
|
||||
const byDay = new Map();
|
||||
recs.forEach(r => { const d = new Date(r.time).toISOString().slice(5,10); byDay.set(d, (byDay.get(d)||0)+1); });
|
||||
const counts = Array.from(byDay.values()).slice(-7);
|
||||
dailyBars(ctx, counts, rect.width, 86, '#3fb27f');
|
||||
}
|
||||
function drawTokensChart(models) {
|
||||
const cv = document.getElementById('cv-tokens'); if (!cv) return;
|
||||
const ctx = cv.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = cv.parentElement.getBoundingClientRect();
|
||||
cv.width = rect.width * dpr; cv.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
const w = rect.width, h = rect.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.fillStyle = '#2a2f3a'; ctx.fillRect(0, 0, w, h);
|
||||
const totTok = (models||[]).reduce((a,x)=>a+x.tokens,0);
|
||||
ctx.font = '16px ui-monospace,Menlo,Consolas,monospace';
|
||||
ctx.fillStyle = '#e2e8f0'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText(fmtTok(totTok) + ' tokens', w/2, h/2);
|
||||
const parent = cv.parentElement, rect = parent.getBoundingClientRect();
|
||||
cv.width = rect.width * dpr; cv.height = 86 * dpr; ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, cv.width, cv.height);
|
||||
// top-4 models by tokens as a stacked bar (share ~= pie) + legend line
|
||||
const rows = (models||[]).slice().sort((a,b)=>b.tokens-a.tokens).slice(0,4);
|
||||
const tot = rows.reduce((a,x)=>a+x.tokens,0) || 1;
|
||||
const palette = ['#3fb27f','#4fa3d8','#f0a45c','#b06ce0'];
|
||||
let x = 0;
|
||||
ctx.fillRect;
|
||||
rows.forEach((m,i) => {
|
||||
const w = Math.max(2, m.tokens / tot * rect.width);
|
||||
ctx.fillStyle = palette[i%palette.length];
|
||||
ctx.fillRect(x, 0, w, 86);
|
||||
x += w;
|
||||
});
|
||||
ctx.fillStyle = 'rgba(226,232,240,.85)';
|
||||
ctx.font = '10px ui-monospace,Menlo,Consolas,monospace';
|
||||
ctx.textAlign = 'left'; ctx.textBaseline = 'bottom';
|
||||
ctx.fillText((rows[0]||{}).name ? '@' + rows[0].name + ' ' + fmtTok(rows[0].tokens) : '', 3, 54);
|
||||
}
|
||||
function drawLatencyChart(st) {
|
||||
const cv = document.getElementById('cv-lat'); if (!cv) return;
|
||||
const ctx = cv.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = cv.parentElement.getBoundingClientRect();
|
||||
cv.width = rect.width * dpr; cv.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
const w = rect.width, h = rect.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.fillStyle = '#2a2f3a'; ctx.fillRect(0, 0, w, h);
|
||||
ctx.font = '16px ui-monospace,Menlo,Consolas,monospace';
|
||||
ctx.fillStyle = '#e2e8f0'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText('avg ' + fmtMs((st.total||{}).latency_sum_ms, (st.total||{}).reqs), w/2, h/2);
|
||||
const parent = cv.parentElement, rect = parent.getBoundingClientRect();
|
||||
cv.width = rect.width * dpr; cv.height = 86 * dpr; ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, cv.width, cv.height);
|
||||
const recs = st.records || [];
|
||||
// per-record latency sparkline (last 40)
|
||||
const lat = recs.slice(-40).map(r => r.latency_ms || 0);
|
||||
sparkline(ctx, lat, rect.width, 86, '#e06c6c');
|
||||
}
|
||||
function drawStatusChart(rows) {
|
||||
const cv = document.getElementById('cv-status'); if (!cv) return;
|
||||
const ctx = cv.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = cv.parentElement.getBoundingClientRect();
|
||||
cv.width = rect.width * dpr; cv.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
const w = rect.width, h = rect.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
const parent = cv.parentElement, rect = parent.getBoundingClientRect();
|
||||
cv.width = rect.width * dpr; cv.height = 86 * dpr; ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, cv.width, cv.height);
|
||||
// stacked bar of request counts by status code
|
||||
const total = rows.reduce((a,x)=>a+x.reqs,0) || 1;
|
||||
const palette = ['#3fb27f','#4fa3d8','#f0a45c','#e06c6c','#b06ce0','#8a9bb5'];
|
||||
let x = 0;
|
||||
rows.forEach((r,i) => {
|
||||
const w = Math.max(2, r.reqs / total * rect.width);
|
||||
ctx.fillStyle = palette[i%palette.length];
|
||||
ctx.fillRect(x, 0, w, 86);
|
||||
x += w;
|
||||
});
|
||||
}
|
||||
function paintKeyTable(rows, keyNames) {
|
||||
const el = $('#tb-key'); if (!el) return;
|
||||
|
||||
Reference in New Issue
Block a user