mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 17:07:59 +00:00
fix(ui): unify five KPI blocks into identical cards with canvas placeholders
- unify all five metric boxes into identical .kpi cards (same height, padding, flex layout, canvas content area) - replace old numeric KPI blocks with uniform <canvas> content areas - move status donut into 5th KPI slot (already done), now uses same .kpi-card layout - add canvas placeholders (cv-active, cv-reqs, cv-tokens, cv-lat, cv-status) for future sparklines/charts - draw stubs: active/reqs/tokens/latency/status stub renderers (drawActiveChart etc.) - CSS unified: .kpi flex column, .k-hdr title row, .k-canvas flex:1 canvas content area - deployed (bak .bak.20260811k), server active
This commit is contained in:
@ -102,16 +102,17 @@ pre.configbox { background:var(--card2); border:1px solid var(--line); border-ra
|
|||||||
|
|
||||||
/* ---------- dashboard / usage metrics ---------- */
|
/* ---------- dashboard / usage metrics ---------- */
|
||||||
.kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:12px; margin-bottom:18px; }
|
.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:14px 16px; box-shadow:var(--shadow); }
|
.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-lab { font-size:12px; color:var(--muted); display:flex; align-items:center; gap:6px; }
|
.kpi .k-hdr { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px; }
|
||||||
.kpi .k-val { font-size:26px; font-weight:800; margin-top:4px; font-variant-numeric:tabular-nums; letter-spacing:.3px; }
|
.kpi .k-lab { font-size:12px; color:var(--muted); font-weight:600; white-space:nowrap; }
|
||||||
.kpi .k-val .u { font-size:13px; font-weight:600; color:var(--muted); margin-left:3px; }
|
.kpi .k-val { font-size:22px; font-weight:800; font-variant-numeric:tabular-nums; }
|
||||||
.kpi .k-sub { font-size:11.5px; color:var(--muted); margin-top:3px; }
|
.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.kpi-exit { cursor:pointer; border-color:var(--err); }
|
.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: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-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; padding:12px; }
|
.kpi.kpi-status { display:flex; flex-direction:column; }
|
||||||
.kpi.kpi-status > div { flex:1; display:flex; align-items:center; justify-content:center; }
|
|
||||||
.dot { width:7px; height:7px; border-radius:50%; background:var(--ok); display:inline-block; animation:blink 1.6s infinite; }
|
.dot { width:7px; height:7px; border-radius:50%; background:var(--ok); display:inline-block; animation:blink 1.6s infinite; }
|
||||||
.dot.err { background:var(--err); }
|
.dot.err { background:var(--err); }
|
||||||
@keyframes blink { 50% { opacity:.25; } }
|
@keyframes blink { 50% { opacity:.25; } }
|
||||||
@ -757,12 +758,26 @@ async function paintStats() {
|
|||||||
const avg = fmtLat(tot.latency_sum_ms, tot.reqs);
|
const avg = fmtLat(tot.latency_sum_ms, tot.reqs);
|
||||||
const kpi = $('#kpi-row');
|
const kpi = $('#kpi-row');
|
||||||
if (kpi) kpi.innerHTML = `
|
if (kpi) kpi.innerHTML = `
|
||||||
<div class="kpi${statsKeyF ? ' kpi-exit' : ''}" title="${statsKeyF ? t('exitKeyView') : ''}" onclick="${statsKeyF ? `renderKeyF('${escAttr(statsKeyF)}')` : ''}"><div class="k-lab">${t('kpiActive')} <span class="dot"></span></div><div class="k-val">${st.active || 0}</div><div class="k-sub">${statsKeyF ? esc(st.key_names ? (st.key_names[statsKeyF] ? st.key_names[statsKeyF] + ' · ' : '') + statsKeyF : statsKeyF) : ''}</div></div>
|
<div class="kpi${statsKeyF ? ' kpi-exit' : ''}" title="${statsKeyF ? t('exitKeyView') : ''}" onclick="${statsKeyF ? `renderKeyF('${escAttr(statsKeyF)}')` : ''}">
|
||||||
<div class="kpi"><div class="k-lab">${t('kpiReqs')}</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>
|
<div class="k-hdr"><span class="k-lab">${t('kpiActive')}</span></div>
|
||||||
<div class="kpi"><div class="k-lab">${t('kpiTokens')}</div><div class="k-val">${fmtTok(tot.tokens)}</div>
|
<div class="k-canvas"><canvas id="cv-active"></canvas></div>
|
||||||
<div class="k-sub">${t('thPrompt')} ${fmtTok(tot.prompt_tokens)} · ${t('thCompl')} ${fmtTok(tot.completion_tokens)}</div></div>
|
</div>
|
||||||
<div class="kpi"><div class="k-lab">${t('kpiLat')}</div><div class="k-val">${fmtMs(avg)}</div><div class="k-sub">${t('kpiMaxLat')} ${fmtMs(tot.latency_max_ms)}</div></div>
|
<div class="kpi">
|
||||||
<div class="kpi kpi-status"><div class="k-lab">${t('dashStatus')}</div><div id="tb-status"></div></div>`;
|
<div class="k-hdr"><span class="k-lab">${t('kpiReqs')}</span></div>
|
||||||
|
<div class="k-canvas"><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>
|
||||||
|
<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>
|
||||||
|
<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>`;
|
||||||
paintModelTable(st.by_model || []);
|
paintModelTable(st.by_model || []);
|
||||||
paintSrcTable(st.by_source || []);
|
paintSrcTable(st.by_source || []);
|
||||||
paintStatusTable(st.by_status || []);
|
paintStatusTable(st.by_status || []);
|
||||||
@ -771,6 +786,12 @@ async function paintStats() {
|
|||||||
const rex = $('#rec-exit');
|
const rex = $('#rec-exit');
|
||||||
if (rex) rex.innerHTML = statsKeyF ? `<button class="ghost small" onclick="renderKeyF('')">${t('exitKeyView')} ${esc(st.key_names && st.key_names[statsKeyF] ? st.key_names[statsKeyF] : statsKeyF)}</button>` : '';
|
if (rex) rex.innerHTML = statsKeyF ? `<button class="ghost small" onclick="renderKeyF('')">${t('exitKeyView')} ${esc(st.key_names && st.key_names[statsKeyF] ? st.key_names[statsKeyF] : statsKeyF)}</button>` : '';
|
||||||
renderKeySelect((st.by_key || []).map(k => k.name), st.key_names || {});
|
renderKeySelect((st.by_key || []).map(k => k.name), st.key_names || {});
|
||||||
|
// draw charts after DOM ready
|
||||||
|
drawActiveChart(st);
|
||||||
|
drawReqsChart(st);
|
||||||
|
drawTokensChart(st.by_model || []);
|
||||||
|
drawLatencyChart(st);
|
||||||
|
drawStatusChart(st.by_status || []);
|
||||||
} catch (e) { console.error('[stats]', e); }
|
} catch (e) { console.error('[stats]', e); }
|
||||||
}
|
}
|
||||||
function paintModelTable(rows) {
|
function paintModelTable(rows) {
|
||||||
@ -838,6 +859,73 @@ function paintStatusTable(rows) {
|
|||||||
<div class="status-chips">${chips}</div>
|
<div class="status-chips">${chips}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
function paintKeyTable(rows, keyNames) {
|
function paintKeyTable(rows, keyNames) {
|
||||||
const el = $('#tb-key'); if (!el) return;
|
const el = $('#tb-key'); if (!el) return;
|
||||||
if (!rows.length) { el.innerHTML = `<div class="muted">${t('noUsage')}</div>`; return; }
|
if (!rows.length) { el.innerHTML = `<div class="muted">${t('noUsage')}</div>`; return; }
|
||||||
|
|||||||
Reference in New Issue
Block a user