mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 00:48:00 +00:00
fix(ui): status-code donut chart with hover tooltips + model usage % = share of total; plan.md P4-9/P4-10
- paintStatusTable: replace plain table with SVG ring (conic slices per status code, center total, clickable legend with title=value/reqs/percent/ok/err) - paintModelTable: percentage now = model share of total requests (was relative-to-max, so the top model always showed 100%/97%); bar stays max-relative - plan.md: record P4-9 (visualization) and P4-10 (claude 400 'extra usage' = Anthropic account quota, not fingerprint; qijiar+openai serves claude fine, no adapter disguise needed) - deployed to /usr/local/bin/llmsproxy (bak .bak.20260811i), server active
This commit is contained in:
@ -126,6 +126,16 @@ 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; }
|
||||
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; }
|
||||
@ -763,13 +773,15 @@ async function paintStats() {
|
||||
function paintModelTable(rows) {
|
||||
const el = $('#tb-model'); if (!el) return;
|
||||
if (!rows.length) { el.innerHTML = `<div class="muted">${t('noUsage')}</div>`; return; }
|
||||
const totalReq = rows.reduce((m, x) => m + x.reqs, 0);
|
||||
const maxReq = rows.reduce((m, x) => Math.max(m, x.reqs), 0);
|
||||
el.innerHTML = `<div class="tbl-wrap"><table><tr><th>${t('thModel')}</th><th class="num">${t('thReqs')}</th><th class="num">${t('thOk')}</th><th class="num">${t('thErr')}</th>
|
||||
<th class="num">${t('thPrompt')}</th><th class="num">${t('thCompl')}</th><th class="num">${t('thAvgLat')}</th><th class="num">${t('thMaxLat')}</th></tr>` +
|
||||
rows.map(r => {
|
||||
const maxReq = rows.reduce((m, x) => Math.max(m, x.reqs), 0);
|
||||
const sharePct = totalReq ? (r.reqs * 100 / totalReq).toFixed(1) : '0.0';
|
||||
const w = maxReq ? Math.max(6, r.reqs * 100 / maxReq) : 0;
|
||||
return `<tr><td>${esc(r.name)}</td>
|
||||
<td class="num"><span class="mini">${w.toFixed(0)}%</span><div class="bar"><i style="width:${w}%"></i></div>${fmtN(r.reqs)}</td>
|
||||
<td class="num"><span class="mini" title="${r.reqs} of ${totalReq} requests">${sharePct}%</span><div class="bar"><i style="width:${w}%"></i></div>${fmtN(r.reqs)}</td>
|
||||
<td class="num okc">${fmtN(r.ok)}</td><td class="num errc">${fmtN(r.err)}</td>
|
||||
<td class="num">${fmtTok(r.prompt_tokens)}</td><td class="num">${fmtTok(r.completion_tokens)}</td>
|
||||
<td class="num">${fmtMs(fmtLat(r.latency_sum_ms, r.reqs))}</td><td class="num">${fmtMs(r.latency_max_ms)}</td></tr>`;
|
||||
@ -788,9 +800,34 @@ 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; }
|
||||
el.innerHTML = `<div class="tbl-wrap"><table><tr><th>${t('thCode')}</th><th class="num">${t('thReqs')}</th><th class="num">${t('thOk')}</th><th class="num">${t('thErr')}</th></tr>` +
|
||||
rows.map(r => `<tr><td><b class="${+r.name >= 400 ? 'errc' : 'okc'}">${esc(r.name)}</b></td>
|
||||
<td class="num">${fmtN(r.reqs)}</td><td class="num okc">${fmtN(r.ok)}</td><td class="num errc">${fmtN(r.err)}</td></tr>`).join('') + '</table></div>';
|
||||
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 };
|
||||
});
|
||||
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('');
|
||||
el.innerHTML = `<div class="donut-wrap">
|
||||
<div class="donut">
|
||||
<svg width="140" height="140" viewBox="0 0 140 140">${arcs}</svg>
|
||||
<div class="center"><b>${fmtN(total)}</b><span>${t('thReqs')}</span></div>
|
||||
</div>
|
||||
<div class="donut-legend">${legend}</div>
|
||||
</div>`;
|
||||
}
|
||||
function paintKeyTable(rows, keyNames) {
|
||||
const el = $('#tb-key'); if (!el) return;
|
||||
|
||||
6
plan.md
6
plan.md
@ -224,7 +224,7 @@ p==nil → 400/404;!TryAcquire → 429 busy(快速失败)
|
||||
- [x] **P2-4(2026-08-10)单测(`scheduler_test.go`)**:tier 分桶/降序、同 tier 游标轮转交替(s1,s2,s1,s2)、负偏好沉底仍可达(硬失败换槽后由负面槽承接)、busy 跳过不记分、整档全忙有界等待(实测 <1s)后降级、配额耗尽槽不调度、全灭 503 汇总(Tiers+Skipped 文本断言)、流式首 chunk 前失败换槽、游标首帧从 0 起。
|
||||
- [x] **P2-5(2026-08-10)本地验证(Windows 已补齐 Lua)**:把 golua 自带 Lua 5.1 头对应的源码编成 `liblua.a` 放入 golua 模块目录(golua 官方 Windows 做法),本机 `go build/vet/test ./...` 全绿——**provider/gateway/lua/e2e 全部首次真正跑通**,并借此揪出三处从未被发现的存量问题:① `RecordFailure(auth)` 只把 failCount 上限用于冷却算式、未落盘计数器(已修:auth 时 `failCount.Store(backoffCapN)`);② gateway 测试种子 key 未进 runtime store 导致全 401(已修:测试 cfg 补 `GatewayKeys`);③ e2e failover 用例只有一个 chat 源、AUTO 全灭必然 503(已修:新增第二 chat 源 `fallback`,真故障转移);e2e `buildBinary` Windows 回退无 tag 构建(bundled Lua,透传适配器行为一致),生产机仍优先 `-tags luajit`。
|
||||
- [x] **P2-6(2026-08-10)Phase 2 场景测试**:gateway 新增——同 tier 硬失败顺延承接(`TestChatAutoChainTierFailover`)、全灭 503 含 `a/a-m` 分档汇总(`TestChatAutoChain503Summary`)、配额耗尽槽跳过且不再打上游(`TestChatAutoQuotaSkip`)、`PUT /api/auto` 后冷却立即复位并恢复调度(`TestAutoSaveResetsCooldown`,修 P1 回归);e2e 新增 `TestEndToEndAuto503`(真实二进制 503 汇总)。全部本地跑通。
|
||||
- [ ] **P2-7**:生产机(192.168.2.60)`go test -tags luajit ./...` 最终回归 + 灰度部署(luajit 与 bundled Lua 的适配器行为差异由生产验证兜底)。
|
||||
- [x] **P2-7(2026-08-11)**:生产机 `go test -tags luajit ./...` 最终回归全绿(含 e2e),luajit 版二进制(11.4MB)部署 `/usr/local/bin/llmsproxy`(备份 `.bak.20260811h`)+ 重启,在线验证非流式/流式 AUTO、权限回归全通过。
|
||||
|
||||
### Phase 3 — 收尾
|
||||
- [x] **P3-1(2026-08-10)UI 链上健康展示**:`GET /api/auto` 增加 `states`(`Core.AutoSlotStates` 遍历当前 Chain 槽 × `Provider.ModelHealthInfo`:pref/failCount/cooldownUntil/cooling);优先级页每块按 `model|source` 渲染徽标(冷却红/失败橙×N/±偏好蓝,title 说明);状态页新增"状态码分布"卡片。
|
||||
@ -244,4 +244,6 @@ p==nil → 400/404;!TryAcquire → 429 busy(快速失败)
|
||||
- [x] **P4-5(2026-08-11)`hasScopeModel` 剥前缀比对(P10-2)**:改为 Gateway 方法,先用 `Registry.EffectiveModel` 严格剥 `source-`/`source:`/`source/` 前缀(仅当前缀是真实源名且该源确实服务裸模型时才剥,避免误伤 `deepseek-v4-flash-free` 这类自带 `-` 的模型 ID);单测 `TestHasScopeModelWithSourcePrefix` 覆盖前缀剥离与勿误伤。
|
||||
- [x] **P4-6(2026-08-11)runtime 源默认超时策略(P10-3,区分流式/非流式)**:新增 `config.DefaultSourceTimeout=120s/DefaultSourceQueueTimeout=60s/DefaultSourceConcurrency=8`;`Core.mergedSources` 对 runtime 源兜底(JSON 源不序列化 timeout);`Provider.New` 拆两个 client——非流式 `client{Timeout}` 整请求限时、流式 `stream` 无总超时(共享 Transport 仅 `ResponseHeaderTimeout` 限首字节),长 SSE 不被掐;`ChatStream` 改用 `doRawStream`。单测 + 线上流/非流式验证通过。
|
||||
- [x] **P4-7(2026-08-11)AUTO 链 tier 序 + 审计导出修复 + UI 密钥视图(今日会话)**:① `BuildChain` 排序由降序改升序(tier1=最高优先级先试,修 P10-5 中的序颠倒),单测同步;② 统计/CSV 全量(limit 20000、`Stats.AuditRecords` 读磁盘含 `*.old`、key_names 掩码键、非 admin 过滤掩码);③ UI 卡片滚动区修正 + 密钥视图 toggle(再点同一密钥回全局)+ `rec-exit` 退出入口;④ WebUI 测试"所有档位失败"观察:AUTO 链降级正常(opus 必死→deepseek 撞 zen 间歇故障→frank key 失效→nemotron 404 全灭 503)。
|
||||
- [x] **P4-8(2026-08-11)调度延滞排查结论**:homeagent AUTO 请求"看起来停在 opus"实为调度正常降级——完整 ChainErr 列出 tier1→2→3→4 全部尝试,审计只记 `ce.Tiers[0]`(首档)故 UI 显 opus;zen 源 `opencode.ai/zen/v1` 直连即 403 `[server_error] Upstream response was not valid JSON`(间歇)/ nemotron 404,确认为上游源自身问题,非网关透传或 homeagent 适配器。
|
||||
- [x] **P4-8(2026-08-11)调度延滞排查结论**:homeagent AUTO 请求"看起来停在 opus"实为调度正常降级——完整 ChainErr 列出 tier1→2→3→4 全部尝试,审计只记 `ce.Tiers[0]`(首档)故 UI 显 opus;zen 源 `opencode.ai/zen/v1` 直连即 403 `[server_error] Upstream response was not valid JSON`(间歇)/ nemotron 404,确认为上游源自身问题,非网关透传或 homeagent 适配器。
|
||||
- [x] **P4-9(2026-08-11)WebUI 统计可视化**:模型用量%改为占总请求的比例(`r.reqs/total`,替代原"相对最大模型"的误导性 100%/97%);状态码分布由表格改为环形图(`SVG conic` 分片 + 中心总数 + 图例,hover `title` 显示码值/请求数/占比/成功失败)。
|
||||
- [x] **P4-10(2026-08-11)claude "Third-party apps now draw from your extra usage" 400 结论**:该报错为 **Anthropic 官方配额提示**(账号第三方 app 用量额度耗尽,提示到 Anthropic usage 设置充值),**非流量特征被识破**;伪装流量/换 adaptive 适配器解决不了(请求已被 Anthropic 接受并按账号用量计费)。且 qijiar+openai 适配器下 claude 模型本就能 200 成功(审计 16 次,含 claude-opus-4-8/claude-sonnet-5),AUTO 链/直连的 claude 失败多为上游 `model_not_found`(无 distributor 渠道)、`Concurrency limit exceeded`、`extra usage` 配额——均为上游账号/渠道级问题,非网关。若确需走 Anthropic 原生 `/v1/messages`,可为其源切换内置 `anthropic` 适配器(仅改变量 wire 格式,不影响配额)。
|
||||
Reference in New Issue
Block a user