From bce0abba6eb05a415824b773ec9ec93c663cded4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Fri, 26 Jun 2026 21:23:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E7=9B=91=E6=8E=A7):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E7=9B=91=E6=8E=A7=E5=9B=BE=E8=A1=A8=E2=80=94?= =?UTF-8?q?=E2=80=94=E6=95=B4=E6=95=B0=E5=88=BB=E5=BA=A6Y=E8=BD=B4=20+=20?= =?UTF-8?q?=E5=9B=BE=E4=BE=8B=20+=20=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复并发量图左轴出现「0 0 0 1 1」重复刻度:值很小时改用整数刻度(nice ticks,0/1/2…无重复),并修正按 axisMax 归一 - 每张图加图例(带当前读数)+ 一句中文说明,讲清每条线含义: 并发量=峰值(红)/平均(橙);出字速度=平均(蓝)/最慢(紫) - 修复两张图共用同一个 SVG 渐变 id 导致的颜色串用(改 useId 唯一化) - 空数据态更友好(虚线占位 + 引导开启采样) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workspace/admin/concurrency-monitor.tsx | 220 ++++++++++++------ 1 file changed, 152 insertions(+), 68 deletions(-) diff --git a/frontend-web/src/components/workspace/admin/concurrency-monitor.tsx b/frontend-web/src/components/workspace/admin/concurrency-monitor.tsx index 51d6e6b8..dec77b11 100644 --- a/frontend-web/src/components/workspace/admin/concurrency-monitor.tsx +++ b/frontend-web/src/components/workspace/admin/concurrency-monitor.tsx @@ -9,7 +9,7 @@ import { Square, Zap, } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useId, useMemo, useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; @@ -60,39 +60,117 @@ interface LineSeries { values: (number | null)[]; } -/** Minimal dependency-free SVG line chart (1–2 series + area under the first). */ +/** Round a value up to a "nice" axis ceiling (1 / 2 / 5 × 10ⁿ). */ +function niceCeil(v: number): number { + if (!Number.isFinite(v) || v <= 0) return 1; + const exp = Math.floor(Math.log10(v)); + const base = 10 ** exp; + const f = v / base; + const nice = f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10; + return nice * base; +} + +/** + * Build distinct axis ticks from the data max. For integer series (并发量) this + * yields whole-number ticks with no duplicate labels (fixes the「0 0 0 1 1」 + * axis when the max is 1); for floats it uses a nice ceiling at quarters. + */ +function buildTicks(dataMax: number, integer: boolean): number[] { + if (!Number.isFinite(dataMax) || dataMax <= 0) return [0, 1]; + if (integer) { + const niceMax = Math.max(1, Math.ceil(dataMax)); + const step = Math.max(1, Math.ceil(niceMax / 4)); // ≤5 integer ticks + const top = Math.ceil(niceMax / step) * step; + const ticks: number[] = []; + for (let v = 0; v <= top; v += step) ticks.push(v); + return ticks; + } + const niceMax = niceCeil(dataMax); + return [0, 0.25, 0.5, 0.75, 1].map((r) => niceMax * r); +} + +function fmtAxis(v: number, integer: boolean): string { + if (integer) return String(Math.round(v)); + if (v === 0) return "0"; + return v < 10 ? v.toFixed(1) : String(Math.round(v)); +} + +/** Last non-null value of a series (for the legend's current reading). */ +function latestOf(values: (number | null)[]): number | null { + for (let i = values.length - 1; i >= 0; i -= 1) { + if (values[i] != null) return values[i] as number; + } + return null; +} + +/** Minimal dependency-free SVG line chart: legend + 1–2 series + area + caption. */ function LineChart({ labels, series, unit, + integer = false, + note, emptyHint, }: { labels: string[]; series: LineSeries[]; unit: string; + integer?: boolean; + note?: string; emptyHint: string; }) { + const gradId = `cm-area-${useId().replace(/:/g, "")}`; const width = 760; const height = 220; - const pad = { top: 18, right: 16, bottom: 26, left: 40 }; + const pad = { top: 16, right: 16, bottom: 26, left: 44 }; const cw = width - pad.left - pad.right; const ch = height - pad.top - pad.bottom; + const n = labels.length; const allValues = series.flatMap((s) => s.values.filter((v): v is number => v != null)); - const max = Math.max(1, ...allValues); - const n = labels.length; + const dataMax = allValues.length ? Math.max(...allValues) : 0; + const ticks = buildTicks(dataMax, integer); + const axisMax = ticks[ticks.length - 1] || 1; + + // Legend always renders (even with no data) so the lines are self-explanatory. + const legend = ( +
+ {series.map((s) => { + const latest = latestOf(s.values); + return ( + + + {s.name} + {latest != null && ( + + {Number.isInteger(latest) ? latest : latest.toFixed(1)} + {unit} + + )} + + ); + })} +
+ ); + + const caption = note ? ( +

{note}

+ ) : null; if (n === 0) { return ( -
- {emptyHint} +
+ {legend} +
+ {emptyHint} +
+ {caption}
); } const xFor = (i: number) => pad.left + (n <= 1 ? cw / 2 : (i / (n - 1)) * cw); - const yFor = (v: number) => pad.top + ch - (v / max) * ch; - const grid = [0, 0.25, 0.5, 0.75, 1]; + const yFor = (v: number) => pad.top + ch - (v / axisMax) * ch; const linePath = (vals: (number | null)[]) => { let d = ""; @@ -119,63 +197,66 @@ function LineChart({ const labelStep = Math.max(1, Math.ceil(n / 7)); return ( - - - - - - - - {grid.map((r) => { - const y = pad.top + ch * r; - return ( - - - - {Math.round(max * (1 - r))} +
+ {legend} + + + + + + + + {ticks.map((t) => { + const y = yFor(t); + return ( + + + + {fmtAxis(t, integer)} + + + ); + })} + {areaPath && } + {series.map((s) => ( + + ))} + {labels.map((label, i) => { + if (i !== 0 && i !== n - 1 && i % labelStep !== 0) return null; + return ( + + {label} - - ); - })} - {areaPath && } - {series.map((s) => ( - - ))} - {labels.map((label, i) => { - if (i !== 0 && i !== n - 1 && i % labelStep !== 0) return null; - return ( - - {label} - - ); - })} - {/* hover dots with native tooltip */} - {series.map((s) => - s.values.map((v, i) => - v == null ? null : ( - - {`${labels[i]} · ${s.name}: ${v}${unit}`} - + ); + })} + {series.map((s) => + s.values.map((v, i) => + v == null ? null : ( + + {`${labels[i]} · ${s.name}: ${v}${unit}`} + + ), ), - ), - )} - + )} + + {caption} +
); } @@ -401,7 +482,7 @@ export function ConcurrencyMonitorPanel() { {/* charts */}
- + {concurrency.isLoading ? (
加载中 @@ -410,12 +491,14 @@ export function ConcurrencyMonitorPanel() { )} - + {tokenSpeed.isLoading ? (
加载中 @@ -425,6 +508,7 @@ export function ConcurrencyMonitorPanel() { labels={tokenChart.labels} series={tokenChart.series} unit=" t/s" + note="蓝线=该时段所有大模型调用的平均出字速度(tokens/秒,越高越快);紫线=该时段最慢的一次调用。某段时间线明显下凹,说明那会儿模型/供应商变慢,是压力或瓶颈所在。" emptyHint="暂无大模型调用记录" /> )}