From 5bafac1d02f1a175f03e3aa92266162f1bfbc66f Mon Sep 17 00:00:00 2001 From: root Date: Tue, 11 Aug 2026 16:28:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(ui):=20status-code=20donut=20as=205th=20KPI?= =?UTF-8?q?=20block=20=E2=80=94=20crisp=20path=20arcs,=20no=20clipping,=20?= =?UTF-8?q?compact=20chips?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 : 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 --- internal/gateway/ui/index.html | 73 +++++++++++++++++++--------------- plan.md | 2 +- 2 files changed, 42 insertions(+), 33 deletions(-) diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 5a62bdc..66a0021 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -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() {

${t('dashModel')}

${s.sources ? `

${t('dashSrc')}

` : ''} -

${t('dashStatus')} ${t('statusTag')}

${t('dashKey')}

${t('dashRecs')}

@@ -759,7 +761,8 @@ async function paintStats() {
${t('kpiReqs')}
${fmtN(tot.reqs)}
${t('kpiOk')} ${okr}%
${t('kpiTokens')}
${fmtTok(tot.tokens)}
${t('thPrompt')} ${fmtTok(tot.prompt_tokens)} · ${t('thCompl')} ${fmtTok(tot.completion_tokens)}
-
${t('kpiLat')}
${fmtMs(avg)}
${t('kpiMaxLat')} ${fmtMs(tot.latency_max_ms)}
`; +
${t('kpiLat')}
${fmtMs(avg)}
${t('kpiMaxLat')} ${fmtMs(tot.latency_max_ms)}
+
${t('dashStatus')}
`; 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 = `
${t('noUsage')}
`; 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 ` - ${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(''); + const arcs = paths.filter(x => x.d).map(r => + ` + ${esc(r.name)}: ${fmtN(r.reqs)} (${(r.reqs / total * 100).toFixed(1)}%) + `).join(''); + const chips = paths.map(s => ` + ${esc(s.name)}${fmtN(s.reqs)}`).join(''); el.innerHTML = `
- ${arcs} + + ${arcs} +
${fmtN(total)}${t('thReqs')}
-
${legend}
+
${chips}
`; } function paintKeyTable(rows, keyNames) { diff --git a/plan.md b/plan.md index 1531e24..d921a86 100644 --- a/plan.md +++ b/plan.md @@ -245,5 +245,5 @@ p==nil → 400/404;!TryAcquire → 429 busy(快速失败) - [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-9(2026-08-11)WebUI 统计可视化**:模型用量%改为占总请求的比例(`r.reqs/total`,替代原"相对最大模型"的误导性 100%/97%);状态码分布由表格改为环形图(`SVG conic` 分片 + 中心总数 + 图例,hover `title` 显示码值/请求数/占比/成功失败)。 +- [x] **P4-9(2026-08-11)WebUI 统计可视化**:模型用量%改为占总请求的比例(`r.reqs/total`,替代原"相对最大模型"的误导性 100%/97%);状态码分布改为 KPI 行第五个方块内的小环形图(`SVG` path 弧分片,非 dasharray 防糊 + 留边距防裁切;中心总数 + 状态 chip 图例,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