diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html
index 831b7a4..aa76e9a 100644
--- a/internal/gateway/ui/index.html
+++ b/internal/gateway/ui/index.html
@@ -114,7 +114,7 @@ pre.configbox { background:var(--card2); border:1px solid var(--line); border-ra
.tab-pane { animation:tabIn .25s ease; }
.chat-log .chat-empty { animation:fadeIn .3s ease; }
.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); display:flex; flex-direction:column; height:206px; }
+.kpi { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:14px 16px; box-shadow:var(--shadow); display:flex; flex-direction:column; height:252px; }
.kpi .k-hdr { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:6px; flex:0 0 auto; }
.kpi .k-lab { font-size:12px; color:var(--muted); font-weight:600; white-space:nowrap; }
.kpi .k-val { font-size:26px; font-weight:800; margin-top:4px; font-variant-numeric:tabular-nums; letter-spacing:.3px; flex:0 0 auto; }
@@ -123,7 +123,7 @@ pre.configbox { background:var(--card2); border:1px solid var(--line); border-ra
.kpi .k-sub.k2 { display:flex; flex-direction:column; align-items:flex-start; line-height:1.5; }
.kpi .k-chart { position:relative; flex:1 1 auto; min-height:40px; margin-top:8px; }
.kpi canvas { position:absolute; inset:0; width:100%; height:100%; display:block; }
-.status-legend { display:flex; flex-wrap:wrap; gap:5px 9px; margin-top:6px; justify-content:center; }
+.status-legend { display:flex; flex-wrap:wrap; gap:5px 9px; margin-top:6px; justify-content:center; overflow-y:auto; max-height:66px; scrollbar-width:thin; }
.status-legend .lg { display:inline-flex; align-items:center; gap:5px; font-size:11px; color:var(--muted); white-space:nowrap; }
.status-legend .lg i { width:9px; height:9px; border-radius:2px; flex:0 0 auto; }
.status-legend .lg b { color:var(--fg); font-variant-numeric:tabular-nums; }
@@ -522,7 +522,7 @@ const STR = {
sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'该源暂无模型',
kpiActive:'活跃请求', kpiReqs:'总请求', kpiOk:'成功率', kpiTokens:'Tokens', kpiLat:'平均延迟', kpiMaxLat:'最大延迟',
dashModel:'模型用量', dashSrc:'源用量与延迟', dashKey:'密钥用量', dashRecs:'请求记录', exportCsv:'导出 CSV', expWeek:'近一周', expMonth:'近一月', expYear:'近一年', expRange:'自定义范围', expStart:'开始日期', expEnd:'结束日期', expDownload:'下载', expKeysCsv:'导出密钥用量', exitKeyView:'退出该密钥视图',
- dashStatus:'状态码分布', thCode:'状态码', statusTag:'状态码分类统计(含 402 欠费 / 400 schema 错误;两者不计入上游退避但单独计数)',
+ dashStatus:'状态码分布', thCode:'状态码', kOther:'其他', statusTag:'状态码分类统计(含 402 欠费 / 400 schema 错误;两者不计入上游退避但单独计数)',
thModel:'模型', thSrc:'源', thKey:'密钥', thReqs:'请求', thOk:'成功', thErr:'失败',
thPrompt:'输入 Tokens', thCompl:'输出 Tokens', thAvgLat:'平均延迟', thMaxLat:'最长延迟',
thTime:'时间', thType:'类型', thStatus:'状态', thLatMs:'延迟',
@@ -578,7 +578,7 @@ kMeTitle:'My key', kMeRole:'Role', kMeModels:'Models I can use', kMeHint:'Keys c
sortSource:'source', sortPrio:'priority %s', sortEmpty:'no models in this source',
kpiActive:'Active requests', kpiReqs:'Requests', kpiOk:'Success rate', kpiTokens:'Tokens', kpiLat:'Avg latency', kpiMaxLat:'Max latency',
dashModel:'Model usage', dashSrc:'Source usage & latency', dashKey:'Key usage', dashRecs:'Request records', exportCsv:'Export CSV', expWeek:'Last week', expMonth:'Last month', expYear:'Last year', expRange:'Custom range', expStart:'Start date', expEnd:'End date', expDownload:'Download', expKeysCsv:'Export key usage', exitKeyView:'Exit this key view',
- dashStatus:'Status codes', thCode:'Code', statusTag:'Per-status aggregates — 402 quota / 400 schema errors are counted here but never back off the provider',
+ dashStatus:'Status codes', thCode:'Code', kOther:'other', statusTag:'Per-status aggregates — 402 quota / 400 schema errors are counted here but never back off the provider',
thModel:'Model', thSrc:'Source', thKey:'Key', thReqs:'Requests', thOk:'OK', thErr:'Err',
thPrompt:'Prompt Tokens', thCompl:'Completion Tokens', thAvgLat:'Avg latency', thMaxLat:'Max latency',
thTime:'Time', thType:'Type', thStatus:'Status', thLatMs:'Latency',
@@ -962,18 +962,25 @@ function drawTokensChart(models) {
const H = rect.height || 86;
cv.width = rect.width * dpr; cv.height = H * dpr; ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, cv.width, cv.height);
- 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;
+ // all non-zero models sorted desc; total uses the full set so shares are true
+ const all = (models||[]).filter(m => (m.tokens||0) > 0).sort((a,b)=>b.tokens-a.tokens);
+ if (!all.length) return;
+ const grand = all.reduce((a,x)=>a+x.tokens,0);
+ // keep the top 4 as visible slices; lump the rest into "other"
+ const top = all.slice(0,4);
+ const rest = all.slice(4).reduce((a,x)=>a+x.tokens,0);
+ const slices = rest > 0 ? top.concat([{ name: '… ' + t('kOther'), tokens: rest, _other: true }]) : top;
+ const tot = grand;
let x = 0;
- rows.forEach((m,i) => {
+ slices.forEach((m,i) => {
const w = Math.max(2, m.tokens / tot * rect.width);
ctx.fillStyle = tokPalette[i%tokPalette.length];
ctx.fillRect(x, 0, w, H);
x += w;
});
- // color-swatch legend instead of on-chart text
+ // legend: list each individual model (not aggregated) that has tokens
const lg = document.getElementById('tb-tokens-legend');
- if (lg) lg.innerHTML = rows.map((m,i) =>
+ if (lg) lg.innerHTML = all.map((m,i) =>
`
${esc(m.name)}${fmtTok(m.tokens)}`).join('');
}