diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index bbfa786..5a62bdc 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -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 = `
${t('noUsage')}
`; 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 = `
` + 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 ` - + `; @@ -788,9 +800,34 @@ function paintSrcTable(rows) { function paintStatusTable(rows) { const el = $('#tb-status'); if (!el) return; if (!rows.length) { el.innerHTML = `
${t('noUsage')}
`; return; } - el.innerHTML = `
${t('thModel')}${t('thReqs')}${t('thOk')}${t('thErr')} ${t('thPrompt')}${t('thCompl')}${t('thAvgLat')}${t('thMaxLat')}
${esc(r.name)}${w.toFixed(0)}%
${fmtN(r.reqs)}
${sharePct}%
${fmtN(r.reqs)}
${fmtN(r.ok)}${fmtN(r.err)} ${fmtTok(r.prompt_tokens)}${fmtTok(r.completion_tokens)} ${fmtMs(fmtLat(r.latency_sum_ms, r.reqs))}${fmtMs(r.latency_max_ms)}
` + - rows.map(r => ` - `).join('') + '
${t('thCode')}${t('thReqs')}${t('thOk')}${t('thErr')}
${esc(r.name)}${fmtN(r.reqs)}${fmtN(r.ok)}${fmtN(r.err)}
'; + 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 ` + ${esc(s.name)}: ${fmtN(s.reqs)} (${(s.reqs / total * 100).toFixed(1)}%) · ${t('thOk')} ${fmtN(s.ok)} · ${t('thErr')} ${fmtN(s.err)}`; + }).join(''); + const legend = slices.map(s => `
+ ${esc(s.name)}${fmtN(s.reqs)}
`).join(''); + el.innerHTML = `
+
+ ${arcs} +
${fmtN(total)}${t('thReqs')}
+
+
${legend}
+
`; } function paintKeyTable(rows, keyNames) { const el = $('#tb-key'); if (!el) return; diff --git a/plan.md b/plan.md index a393ee8..1531e24 100644 --- a/plan.md +++ b/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 适配器。 \ No newline at end of file +- [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 格式,不影响配额)。 \ No newline at end of file