mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 00:48:00 +00:00
fix(ui): status-code donut as 5th KPI block — crisp path arcs, no clipping, compact chips
- move status distribution out of the full-width standalone card into the top KPI row as a 5th small block (aligned with the four KPI boxes) - swap stroke-dasharray circles for SVG path arcs inside a rotated <g>: keeps each slice crisp and prevents the thick stroke clipping against the viewBox edge - compact 108px donut + wrap-able status chip legend (hover title shows code/reqs/percent) - drop redundant dashStatus subtitle; simplify title - deployed to /usr/local/bin/llmsproxy (bak .bak.20260811j), server active
This commit is contained in:
@ -110,6 +110,8 @@ pre.configbox { background:var(--card2); border:1px solid var(--line); border-ra
|
||||
.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; padding:12px; }
|
||||
.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.err { background:var(--err); }
|
||||
@keyframes blink { 50% { opacity:.25; } }
|
||||
@ -126,16 +128,17 @@ td .errc { color:var(--err); font-weight:600; }
|
||||
.bar i { display:block; height:100%; background:var(--accent); border-radius:3px; }
|
||||
.bar.ok i { background:var(--ok); }
|
||||
.bar.err i { background:var(--err); }
|
||||
.donut-wrap { display:flex; align-items:center; gap:22px; padding:6px 2px; flex-wrap:wrap; }
|
||||
.donut { position:relative; width:150px; height:150px; flex:0 0 auto; }
|
||||
.donut svg { transform:rotate(-90deg); }
|
||||
.donut .center { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; }
|
||||
.donut .center b { font-size:24px; font-weight:800; }
|
||||
.donut .center span { font-size:11px; color:var(--muted); }
|
||||
.donut-legend { display:flex; flex-direction:column; gap:7px; min-width:130px; }
|
||||
.donut-legend .dl { display:flex; align-items:center; gap:8px; font-size:12.5px; cursor:help; }
|
||||
.donut-legend .dl i { width:11px; height:11px; border-radius:3px; flex:0 0 auto; }
|
||||
.donut-legend .dl b { margin-left:auto; font-variant-numeric:tabular-nums; }
|
||||
.donut-wrap { display:flex; flex-direction:column; align-items:center; gap:10px; padding:4px 0; }
|
||||
.donut { position:relative; width:108px; height:108px; flex:0 0 auto; }
|
||||
.donut svg { display:block; }
|
||||
.donut .center { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; pointer-events:none; }
|
||||
.donut .center b { font-size:20px; font-weight:800; }
|
||||
.donut .center span { font-size:10px; color:var(--muted); }
|
||||
.status-chips { display:flex; flex-wrap:wrap; gap:5px; justify-content:center; }
|
||||
.status-chips .sc { display:inline-flex; align-items:center; gap:4px; font-size:11px; padding:2px 7px; border-radius:99px;
|
||||
background:var(--card2); border:1px solid var(--line); cursor:help; white-space:nowrap; }
|
||||
.status-chips .sc i { width:8px; height:8px; border-radius:50%; flex:0 0 auto; }
|
||||
.status-chips .sc b { font-variant-numeric:tabular-nums; }
|
||||
table th { white-space:nowrap; }
|
||||
.recs-scroll { max-height:420px; overflow:auto; }
|
||||
.recs-scroll table th { position:sticky; top:0; background:var(--card); z-index:1; }
|
||||
@ -686,7 +689,6 @@ async function renderStatus() {
|
||||
<div class="card"><h2>${t('dashModel')}</h2><div id="tb-model"></div></div>
|
||||
${s.sources ? `<div class="card"><h2>${t('dashSrc')}</h2><div id="tb-src"></div></div>` : ''}
|
||||
</div>
|
||||
<div class="card"><h2>${t('dashStatus')} <span class="muted" style="font-weight:400;font-size:12px">${t('statusTag')}</span></h2><div id="tb-status"></div></div>
|
||||
<div class="card"><h2>${t('dashKey')}<span class="grow"></span><button class="ghost small" onclick="openExportModal()">${t('exportCsv')}</button></h2><div id="tb-key"></div></div>
|
||||
<div class="card"><h2><span>${t('dashRecs')}</span><span class="grow"></span><span id="rec-exit"></span><button class="ghost small" onclick="openExportModal()">${t('exportCsv')}</button></h2>
|
||||
<div class="filter-line">
|
||||
@ -759,7 +761,8 @@ async function paintStats() {
|
||||
<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="kpi"><div class="k-lab">${t('kpiTokens')}</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>
|
||||
<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="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 kpi-status"><div class="k-lab">${t('dashStatus')}</div><div id="tb-status"></div></div>`;
|
||||
paintModelTable(st.by_model || []);
|
||||
paintSrcTable(st.by_source || []);
|
||||
paintStatusTable(st.by_status || []);
|
||||
@ -801,32 +804,38 @@ function paintStatusTable(rows) {
|
||||
const el = $('#tb-status'); if (!el) return;
|
||||
if (!rows.length) { el.innerHTML = `<div class="muted">${t('noUsage')}</div>`; return; }
|
||||
const total = rows.reduce((m, x) => m + x.reqs, 0) || 1;
|
||||
// stable 2xx/3xx -> green, 4xx/5xx -> red-ish, but keep each code its own slice.
|
||||
const palette = ['#3fb27f', '#4fa3d8', '#f0a45c', '#e06c6c', '#b06ce0', '#5cc8d0', '#c9cc4f', '#e08c5c', '#8a9bb5', '#7fbf6f'];
|
||||
let acc = 0;
|
||||
const slices = rows.map((r, i) => {
|
||||
const frac = r.reqs / total;
|
||||
const start = acc;
|
||||
acc += frac * 100;
|
||||
return { ...r, color: palette[i % palette.length], start, end: acc };
|
||||
// 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 R = 66, C = 2 * Math.PI * R;
|
||||
const arcs = slices.map(s => {
|
||||
const len = (s.end - s.start) / 100 * C;
|
||||
const dash = `${Math.max(len, 0.5)} ${C - Math.max(len, 0.5)}`;
|
||||
const off = -s.start / 100 * C;
|
||||
return `<circle cx="70" cy="70" r="${R}" fill="none" stroke="${s.color}" stroke-width="22"
|
||||
stroke-dasharray="${dash}" stroke-dashoffset="${off}" stroke-linecap="butt">
|
||||
<title>${esc(s.name)}: ${fmtN(s.reqs)} (${(s.reqs / total * 100).toFixed(1)}%) · ${t('thOk')} ${fmtN(s.ok)} · ${t('thErr')} ${fmtN(s.err)}</title></circle>`;
|
||||
}).join('');
|
||||
const legend = slices.map(s => `<div class="dl" title="${esc(s.name)} ${t('thReqs')} ${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><span>${esc(s.name)}</span><b>${fmtN(s.reqs)}</b></div>`).join('');
|
||||
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="140" height="140" viewBox="0 0 140 140">${arcs}</svg>
|
||||
<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="donut-legend">${legend}</div>
|
||||
<div class="status-chips">${chips}</div>
|
||||
</div>`;
|
||||
}
|
||||
function paintKeyTable(rows, keyNames) {
|
||||
|
||||
Reference in New Issue
Block a user