Merge branch 'ji' into master
完善并发监控图表(整数刻度Y轴 + 图例 + 说明)
This commit is contained in:
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="暂无大模型调用记录"
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user