Merge branch 'ji' into master

完善并发监控图表(整数刻度Y轴 + 图例 + 说明)
This commit is contained in:
吉鑫升 committed 2026-06-26 21:23:40 +08:00
commit 26ab6f663c
1 file changed
+152 -68
@@ -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 = (
<div className="mb-2 flex flex-wrap items-center gap-x-4 gap-y-1">
{series.map((s) => {
const latest = latestOf(s.values);
return (
<span key={s.name} className="flex items-center gap-1.5 text-xs text-[#5F6368] dark:text-slate-300">
<span className="inline-block h-[3px] w-4 rounded-full" style={{ backgroundColor: s.color }} />
{s.name}
{latest != null && (
<span className="font-semibold tabular-nums text-[#231815] dark:text-white">
{Number.isInteger(latest) ? latest : latest.toFixed(1)}
{unit}
</span>
)}
</span>
);
})}
</div>
);
const caption = note ? (
<p className="mt-2 text-[11px] leading-4 text-[#8B6B6B] dark:text-[#7FACF2]">{note}</p>
) : null;
if (n === 0) {
return (
<div className="flex h-[220px] items-center justify-center text-sm text-[#5F6368] dark:text-slate-400">
{emptyHint}
<div>
{legend}
<div className="flex h-[160px] items-center justify-center rounded-lg border border-dashed border-[#F1D6D6] text-sm text-[#5F6368] dark:border-[#213D5E] dark:text-slate-400">
{emptyHint}
</div>
{caption}
</div>
);
}
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 (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full" role="img" aria-label="时序折线图">
<defs>
<linearGradient id="cm-area" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={first?.color ?? "#C1121F"} stopOpacity="0.18" />
<stop offset="100%" stopColor={first?.color ?? "#C1121F"} stopOpacity="0.02" />
</linearGradient>
</defs>
{grid.map((r) => {
const y = pad.top + ch * r;
return (
<g key={r}>
<line
x1={pad.left}
x2={width - pad.right}
y1={y}
y2={y}
stroke="currentColor"
className="text-[#E5E7EB] dark:text-[#3F3F46]"
strokeWidth="1"
/>
<text x={6} y={y + 4} className="fill-[#9CA3AF] text-[10px]">
{Math.round(max * (1 - r))}
<div>
{legend}
<svg viewBox={`0 0 ${width} ${height}`} className="w-full" role="img" aria-label="时序折线图">
<defs>
<linearGradient id={gradId} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={first?.color ?? "#C1121F"} stopOpacity="0.18" />
<stop offset="100%" stopColor={first?.color ?? "#C1121F"} stopOpacity="0.02" />
</linearGradient>
</defs>
{ticks.map((t) => {
const y = yFor(t);
return (
<g key={t}>
<line
x1={pad.left}
x2={width - pad.right}
y1={y}
y2={y}
stroke="currentColor"
className="text-[#E5E7EB] dark:text-[#3F3F46]"
strokeWidth="1"
/>
<text x={pad.left - 6} y={y + 3} textAnchor="end" className="fill-[#9CA3AF] text-[10px]">
{fmtAxis(t, integer)}
</text>
</g>
);
})}
{areaPath && <path d={areaPath} fill={`url(#${gradId})`} />}
{series.map((s) => (
<path
key={s.name}
d={linePath(s.values)}
fill="none"
stroke={s.color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
))}
{labels.map((label, i) => {
if (i !== 0 && i !== n - 1 && i % labelStep !== 0) return null;
return (
<text key={`${label}-${i}`} x={xFor(i)} y={height - 8} textAnchor="middle" className="fill-[#6B7280] text-[10px]">
{label}
</text>
</g>
);
})}
{areaPath && <path d={areaPath} fill="url(#cm-area)" />}
{series.map((s) => (
<path
key={s.name}
d={linePath(s.values)}
fill="none"
stroke={s.color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
))}
{labels.map((label, i) => {
if (i !== 0 && i !== n - 1 && i % labelStep !== 0) return null;
return (
<text key={`${label}-${i}`} x={xFor(i)} y={height - 8} textAnchor="middle" className="fill-[#6B7280] text-[10px]">
{label}
</text>
);
})}
{/* hover dots with native tooltip */}
{series.map((s) =>
s.values.map((v, i) =>
v == null ? null : (
<circle key={`${s.name}-${i}`} cx={xFor(i)} cy={yFor(v)} r={2.5} fill={s.color}>
<title>{`${labels[i]} · ${s.name}: ${v}${unit}`}</title>
</circle>
);
})}
{series.map((s) =>
s.values.map((v, i) =>
v == null ? null : (
<circle key={`${s.name}-${i}`} cx={xFor(i)} cy={yFor(v)} r={2.5} fill={s.color}>
<title>{`${labels[i]} · ${s.name}: ${v}${unit}`}</title>
</circle>
),
),
),
)}
</svg>
)}
</svg>
{caption}
</div>
);
}
@@ -401,7 +482,7 @@ export function ConcurrencyMonitorPanel() {
{/* charts */}
<div className="grid gap-3 xl:grid-cols-2">
<ChartCard icon={Gauge} title="并发量(系统压力)" subtitle="峰值 / 平均并发调用数">
<ChartCard icon={Gauge} title="并发量(系统压力)" subtitle={`每${granularity === "hour" ? "小时" : "分钟"}一个点`}>
{concurrency.isLoading ? (
<div className="flex h-[220px] items-center justify-center text-sm text-[#5F6368] dark:text-slate-400">
<Loader2 className="mr-2 size-4 animate-spin" /> 加载中
@@ -410,12 +491,14 @@ export function ConcurrencyMonitorPanel() {
<LineChart
labels={concurrencyChart.labels}
series={concurrencyChart.series}
unit=""
emptyHint="暂无并发采样数据(采样关闭或时间窗口内无记录)"
unit=" 个"
integer
note="红线=该时段同时进行的最多调用数(峰值);橙线=该时段平均值。数值越高、越靠上,说明同时打大模型的请求越多、系统压力越大。"
emptyHint="暂无并发采样数据(需打开「后台采样」开关后才开始记录)"
/>
)}
</ChartCard>
<ChartCard icon={Zap} title="大模型出字速度" subtitle="tokens/秒(越低说明模型越慢)">
<ChartCard icon={Zap} title="大模型出字速度" subtitle={`每${granularity === "hour" ? "小时" : "分钟"}一个点`}>
{tokenSpeed.isLoading ? (
<div className="flex h-[220px] items-center justify-center text-sm text-[#5F6368] dark:text-slate-400">
<Loader2 className="mr-2 size-4 animate-spin" /> 加载中
@@ -425,6 +508,7 @@ export function ConcurrencyMonitorPanel() {
labels={tokenChart.labels}
series={tokenChart.series}
unit=" t/s"
note="蓝线=该时段所有大模型调用的平均出字速度(tokens/秒,越高越快);紫线=该时段最慢的一次调用。某段时间线明显下凹,说明那会儿模型/供应商变慢,是压力或瓶颈所在。"
emptyHint="暂无大模型调用记录"
/>
)}