diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index dbe92db..3a42bc7 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -102,17 +102,19 @@ pre.configbox { background:var(--card2); border:1px solid var(--line); border-ra /* ---------- dashboard / usage metrics ---------- */ .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:12px 14px; box-shadow:var(--shadow); display:flex; flex-direction:column; min-height:160px; } -.kpi .k-hdr { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px; } +.kpi { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:14px 16px; box-shadow:var(--shadow); } +.kpi .k-hdr { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:6px; } .kpi .k-lab { font-size:12px; color:var(--muted); font-weight:600; white-space:nowrap; } -.kpi .k-val { font-size:22px; font-weight:800; font-variant-numeric:tabular-nums; } -.kpi .k-sub { font-size:11px; color:var(--muted); } -.kpi .k-canvas { flex:1; display:flex; align-items:center; justify-content:center; min-height:80px; position:relative; } -.kpi canvas { width:100%; height:100%; display:block; } +.kpi .k-val { font-size:26px; font-weight:800; margin-top:4px; font-variant-numeric:tabular-nums; letter-spacing:.3px; } +.kpi .k-val .u { font-size:13px; font-weight:600; color:var(--muted); margin-left:3px; } +.kpi .k-sub { font-size:11.5px; color:var(--muted); margin-top:4px; } +.kpi .k-chart { margin-top:8px; position:relative; height:86px; } +.kpi canvas { width:100%; height:86px; display:block; } .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; } +.kpi.kpi-status .k-val { font-size:22px; } .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; } } @@ -759,24 +761,32 @@ async function paintStats() { const kpi = $('#kpi-row'); if (kpi) kpi.innerHTML = `
-
${t('kpiActive')}
-
+
${t('kpiActive')} ${st.active || 0}
+
${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('dashStatus')}
-
+
${fmtN((st.by_status||[]).reduce((a,x)=>a+x.reqs,0))}
+
+
`; paintModelTable(st.by_model || []); paintSrcTable(st.by_source || []); @@ -823,108 +833,116 @@ function paintSrcTable(rows) { } function paintStatusTable(rows) { const el = $('#tb-status'); if (!el) return; - if (!rows.length) { el.innerHTML = `
${t('noUsage')}
`; return; } + if (!rows.length) { el.innerHTML = ''; return; } const total = rows.reduce((m, x) => m + x.reqs, 0) || 1; - const palette = ['#3fb27f', '#4fa3d8', '#f0a45c', '#e06c6c', '#b06ce0', '#5cc8d0', '#c9cc4f', '#e08c5c', '#8a9bb5', '#7fbf6f']; - // 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 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} - -
${fmtN(total)}${t('thReqs')}
-
-
${chips}
-
`; + const palette = ['#3fb27f', '#4fa3d8', '#f0a45c', '#e06c6c', '#b06ce0', '#8a9bb5']; + el.innerHTML = rows.map((r, i) => ` + ${esc(r.name)}${fmtN(r.reqs)}`).join(''); } +// sparkline: turn a value array into a compact polyline (area fill under accent) +function sparkline(ctx, vals, w, h, color) { + if (!vals.length) { ctx.strokeStyle = color; ctx.setLineDash([3,3]); ctx.beginPath(); ctx.moveTo(0, h/2); ctx.lineTo(w, h/2); ctx.stroke(); ctx.setLineDash([]); return; } + const max = Math.max.apply(null, vals), min = Math.min.apply(null, vals); + const rng = (max - min) || 1; + const pad = 2; + const px = (i) => i / (vals.length - 1) * w; + const py = (v) => pad + (1 - (v - min) / rng) * (h - pad * 2); + ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; + ctx.beginPath(); + vals.forEach((v, i) => i ? ctx.lineTo(px(i), py(v)) : ctx.moveTo(px(i), py(v))); + ctx.stroke(); +} +// thin vertical bars for daily request counts +function dailyBars(ctx, counts, w, h, color) { + if (!counts.length) { ctx.strokeStyle = color; ctx.setLineDash([3,3]); ctx.beginPath(); ctx.moveTo(0,h/2); ctx.lineTo(w,h/2); ctx.stroke(); ctx.setLineDash([]); return; } + const max = Math.max.apply(null, counts) || 1; + const n = counts.length, bw = w / n, gap = Math.max(1, bw * 0.3); + ctx.fillStyle = color; + counts.forEach((c, i) => { + const bh = Math.max(2, c / max * h); + ctx.fillRect(i * bw + gap / 2, h - bh, bw - gap, bh); + }); +} + function drawActiveChart(st) { const cv = document.getElementById('cv-active'); if (!cv) return; const ctx = cv.getContext('2d'); const dpr = window.devicePixelRatio || 1; - const rect = cv.parentElement.getBoundingClientRect(); - cv.width = rect.width * dpr; cv.height = rect.height * dpr; - ctx.scale(dpr, dpr); - const w = rect.width, h = rect.height; - ctx.clearRect(0, 0, w, h); - ctx.fillStyle = '#2a2f3a'; ctx.fillRect(0, 0, w, h); - ctx.font = '14px ui-monospace,Menlo,Consolas,monospace'; - ctx.fillStyle = 'var(--ok)'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText((st.active || 0) + ' ' + t('kpiActive').replace('活跃','').replace('Active',''), w/2, h/2); + const parent = cv.parentElement, rect = parent.getBoundingClientRect(); + cv.width = rect.width * dpr; cv.height = 86 * dpr; ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, cv.width, cv.height); + // active count across records window (bucketed per minute) as a sparkline + const recs = st.records || []; + const col = window.charts && window.charts.active || []; + sparkline(ctx, col, rect.width, 86, '#4fa3d8'); } function drawReqsChart(st) { const cv = document.getElementById('cv-reqs'); if (!cv) return; const ctx = cv.getContext('2d'); const dpr = window.devicePixelRatio || 1; - const rect = cv.parentElement.getBoundingClientRect(); - cv.width = rect.width * dpr; cv.height = rect.height * dpr; - ctx.scale(dpr, dpr); - const w = rect.width, h = rect.height; - ctx.clearRect(0, 0, w, h); - ctx.fillStyle = '#2a2f3a'; ctx.fillRect(0, 0, w, h); - ctx.font = '18px ui-monospace,Menlo,Consolas,monospace'; - ctx.fillStyle = '#e2e8f0'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText(fmtN((st.total||{}).reqs), w/2, h/2); + const parent = cv.parentElement, rect = parent.getBoundingClientRect(); + cv.width = rect.width * dpr; cv.height = 86 * dpr; ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, cv.width, cv.height); + const recs = st.records || []; + // bucket by UTC day -> request counts (last 7 non-empty days) + const byDay = new Map(); + recs.forEach(r => { const d = new Date(r.time).toISOString().slice(5,10); byDay.set(d, (byDay.get(d)||0)+1); }); + const counts = Array.from(byDay.values()).slice(-7); + dailyBars(ctx, counts, rect.width, 86, '#3fb27f'); } function drawTokensChart(models) { const cv = document.getElementById('cv-tokens'); if (!cv) return; const ctx = cv.getContext('2d'); const dpr = window.devicePixelRatio || 1; - const rect = cv.parentElement.getBoundingClientRect(); - cv.width = rect.width * dpr; cv.height = rect.height * dpr; - ctx.scale(dpr, dpr); - const w = rect.width, h = rect.height; - ctx.clearRect(0, 0, w, h); - ctx.fillStyle = '#2a2f3a'; ctx.fillRect(0, 0, w, h); - const totTok = (models||[]).reduce((a,x)=>a+x.tokens,0); - ctx.font = '16px ui-monospace,Menlo,Consolas,monospace'; - ctx.fillStyle = '#e2e8f0'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText(fmtTok(totTok) + ' tokens', w/2, h/2); + const parent = cv.parentElement, rect = parent.getBoundingClientRect(); + cv.width = rect.width * dpr; cv.height = 86 * dpr; ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, cv.width, cv.height); + // top-4 models by tokens as a stacked bar (share ~= pie) + legend line + 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; + const palette = ['#3fb27f','#4fa3d8','#f0a45c','#b06ce0']; + let x = 0; + ctx.fillRect; + rows.forEach((m,i) => { + const w = Math.max(2, m.tokens / tot * rect.width); + ctx.fillStyle = palette[i%palette.length]; + ctx.fillRect(x, 0, w, 86); + x += w; + }); + ctx.fillStyle = 'rgba(226,232,240,.85)'; + ctx.font = '10px ui-monospace,Menlo,Consolas,monospace'; + ctx.textAlign = 'left'; ctx.textBaseline = 'bottom'; + ctx.fillText((rows[0]||{}).name ? '@' + rows[0].name + ' ' + fmtTok(rows[0].tokens) : '', 3, 54); } function drawLatencyChart(st) { const cv = document.getElementById('cv-lat'); if (!cv) return; const ctx = cv.getContext('2d'); const dpr = window.devicePixelRatio || 1; - const rect = cv.parentElement.getBoundingClientRect(); - cv.width = rect.width * dpr; cv.height = rect.height * dpr; - ctx.scale(dpr, dpr); - const w = rect.width, h = rect.height; - ctx.clearRect(0, 0, w, h); - ctx.fillStyle = '#2a2f3a'; ctx.fillRect(0, 0, w, h); - ctx.font = '16px ui-monospace,Menlo,Consolas,monospace'; - ctx.fillStyle = '#e2e8f0'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText('avg ' + fmtMs((st.total||{}).latency_sum_ms, (st.total||{}).reqs), w/2, h/2); + const parent = cv.parentElement, rect = parent.getBoundingClientRect(); + cv.width = rect.width * dpr; cv.height = 86 * dpr; ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, cv.width, cv.height); + const recs = st.records || []; + // per-record latency sparkline (last 40) + const lat = recs.slice(-40).map(r => r.latency_ms || 0); + sparkline(ctx, lat, rect.width, 86, '#e06c6c'); } function drawStatusChart(rows) { const cv = document.getElementById('cv-status'); if (!cv) return; const ctx = cv.getContext('2d'); const dpr = window.devicePixelRatio || 1; - const rect = cv.parentElement.getBoundingClientRect(); - cv.width = rect.width * dpr; cv.height = rect.height * dpr; - ctx.scale(dpr, dpr); - const w = rect.width, h = rect.height; - ctx.clearRect(0, 0, w, h); + const parent = cv.parentElement, rect = parent.getBoundingClientRect(); + cv.width = rect.width * dpr; cv.height = 86 * dpr; ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, cv.width, cv.height); + // stacked bar of request counts by status code + const total = rows.reduce((a,x)=>a+x.reqs,0) || 1; + const palette = ['#3fb27f','#4fa3d8','#f0a45c','#e06c6c','#b06ce0','#8a9bb5']; + let x = 0; + rows.forEach((r,i) => { + const w = Math.max(2, r.reqs / total * rect.width); + ctx.fillStyle = palette[i%palette.length]; + ctx.fillRect(x, 0, w, 86); + x += w; + }); } function paintKeyTable(rows, keyNames) { const el = $('#tb-key'); if (!el) return; diff --git a/plan.md b/plan.md index d921a86..966e181 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%);状态码分布改为 KPI 行第五个方块内的小环形图(`SVG` path 弧分片,非 dasharray 防糊 + 留边距防裁切;中心总数 + 状态 chip 图例,hover `title` 显示码值/请求数/占比/成功失败);去掉独立全宽状态卡与冗余副标题。 +- [x] **P4-9(2026-08-11)WebUI 统计可视化**(多轮迭代定稿):① 模型用量%改为占总请求的比例(`r.reqs/total`,替代原"相对最大模型"的误导性 100%/97%);② 顶部五宫格统一为同构卡片——**标题 + 大数值 + 副标题 + 86px 图表区**:活跃请求=折线 sparkline(实时)、总请求=按天柱状图、tokens=各模型占比堆叠条(大数值示总用量)、平均延迟=折线 sparkline(实时)、状态码=按码值堆叠条 + chip 副标题;全部 canvas 2D 手绘(无第三方库),去掉灰色空壳占位与独立全宽状态卡/冗余副标题。 - [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