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:
root
2026-08-11 16:08:43 +08:00
parent 20d2268546
commit 675789693c
2 changed files with 46 additions and 7 deletions

View File

@ -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;

View File

@ -224,7 +224,7 @@ p==nil → 400/404!TryAcquire → 429 busy快速失败
- [x] **P2-42026-08-10单测`scheduler_test.go`**tier 分桶/降序、同 tier 游标轮转交替s1,s2,s1,s2、负偏好沉底仍可达硬失败换槽后由负面槽承接、busy 跳过不记分、整档全忙有界等待(实测 <1s后降级配额耗尽槽不调度全灭 503 汇总Tiers+Skipped 文本断言)、流式首 chunk 前失败换槽游标首帧从 0
- [x] **P2-52026-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-62026-08-10Phase 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-72026-08-11**生产机 `go test -tags luajit ./...` 最终回归全绿 e2eluajit 版二进制11.4MB部署 `/usr/local/bin/llmsproxy`备份 `.bak.20260811h`+ 重启在线验证非流式/流式 AUTO权限回归全通过
### Phase 3 — 收尾
- [x] **P3-12026-08-10UI 链上健康展示**`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-52026-08-11`hasScopeModel` 剥前缀比对P10-2**:改为 Gateway 方法,先用 `Registry.EffectiveModel` 严格剥 `source-`/`source:`/`source/` 前缀(仅当前缀是真实源名且该源确实服务裸模型时才剥,避免误伤 `deepseek-v4-flash-free` 这类自带 `-` 的模型 ID单测 `TestHasScopeModelWithSourcePrefix` 覆盖前缀剥离与勿误伤。
- [x] **P4-62026-08-11runtime 源默认超时策略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-72026-08-11AUTO 链 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-82026-08-11调度延滞排查结论**homeagent AUTO 请求"看起来停在 opus"实为调度正常降级——完整 ChainErr 列出 tier1→2→3→4 全部尝试,审计只记 `ce.Tiers[0]`(首档)故 UI 显 opuszen 源 `opencode.ai/zen/v1` 直连即 403 `[server_error] Upstream response was not valid JSON`(间歇)/ nemotron 404确认为上游源自身问题非网关透传或 homeagent 适配器。
- [x] **P4-82026-08-11调度延滞排查结论**homeagent AUTO 请求"看起来停在 opus"实为调度正常降级——完整 ChainErr 列出 tier1→2→3→4 全部尝试,审计只记 `ce.Tiers[0]`(首档)故 UI 显 opuszen 源 `opencode.ai/zen/v1` 直连即 403 `[server_error] Upstream response was not valid JSON`(间歇)/ nemotron 404确认为上游源自身问题非网关透传或 homeagent 适配器。
- [x] **P4-92026-08-11WebUI 统计可视化**:模型用量%改为占总请求的比例(`r.reqs/total`,替代原"相对最大模型"的误导性 100%/97%);状态码分布由表格改为环形图(`SVG conic` 分片 + 中心总数 + 图例hover `title` 显示码值/请求数/占比/成功失败)。
- [x] **P4-102026-08-11claude "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-5AUTO 链/直连的 claude 失败多为上游 `model_not_found`(无 distributor 渠道)、`Concurrency limit exceeded``extra usage` 配额——均为上游账号/渠道级问题,非网关。若确需走 Anthropic 原生 `/v1/messages`,可为其源切换内置 `anthropic` 适配器(仅改变量 wire 格式,不影响配额)。