Merge branch 'ji' into master
完善并发监控图表(整数刻度Y轴 + 图例 + 说明)
This commit is contained in:
commit
26ab6f663c
1 file changed
+152
-68
@@ -9,7 +9,7 @@ import {
|
|||||||
Square,
|
Square,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useId, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -60,39 +60,117 @@ interface LineSeries {
|
|||||||
values: (number | null)[];
|
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({
|
function LineChart({
|
||||||
labels,
|
labels,
|
||||||
series,
|
series,
|
||||||
unit,
|
unit,
|
||||||
|
integer = false,
|
||||||
|
note,
|
||||||
emptyHint,
|
emptyHint,
|
||||||
}: {
|
}: {
|
||||||
labels: string[];
|
labels: string[];
|
||||||
series: LineSeries[];
|
series: LineSeries[];
|
||||||
unit: string;
|
unit: string;
|
||||||
|
integer?: boolean;
|
||||||
|
note?: string;
|
||||||
emptyHint: string;
|
emptyHint: string;
|
||||||
}) {
|
}) {
|
||||||
|
const gradId = `cm-area-${useId().replace(/:/g, "")}`;
|
||||||
const width = 760;
|
const width = 760;
|
||||||
const height = 220;
|
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 cw = width - pad.left - pad.right;
|
||||||
const ch = height - pad.top - pad.bottom;
|
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 allValues = series.flatMap((s) => s.values.filter((v): v is number => v != null));
|
||||||
const max = Math.max(1, ...allValues);
|
const dataMax = allValues.length ? Math.max(...allValues) : 0;
|
||||||
const n = labels.length;
|
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) {
|
if (n === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[220px] items-center justify-center text-sm text-[#5F6368] dark:text-slate-400">
|
<div>
|
||||||
{emptyHint}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const xFor = (i: number) => pad.left + (n <= 1 ? cw / 2 : (i / (n - 1)) * cw);
|
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 yFor = (v: number) => pad.top + ch - (v / axisMax) * ch;
|
||||||
const grid = [0, 0.25, 0.5, 0.75, 1];
|
|
||||||
|
|
||||||
const linePath = (vals: (number | null)[]) => {
|
const linePath = (vals: (number | null)[]) => {
|
||||||
let d = "";
|
let d = "";
|
||||||
@@ -119,63 +197,66 @@ function LineChart({
|
|||||||
const labelStep = Math.max(1, Math.ceil(n / 7));
|
const labelStep = Math.max(1, Math.ceil(n / 7));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full" role="img" aria-label="时序折线图">
|
<div>
|
||||||
<defs>
|
{legend}
|
||||||
<linearGradient id="cm-area" x1="0" x2="0" y1="0" y2="1">
|
<svg viewBox={`0 0 ${width} ${height}`} className="w-full" role="img" aria-label="时序折线图">
|
||||||
<stop offset="0%" stopColor={first?.color ?? "#C1121F"} stopOpacity="0.18" />
|
<defs>
|
||||||
<stop offset="100%" stopColor={first?.color ?? "#C1121F"} stopOpacity="0.02" />
|
<linearGradient id={gradId} x1="0" x2="0" y1="0" y2="1">
|
||||||
</linearGradient>
|
<stop offset="0%" stopColor={first?.color ?? "#C1121F"} stopOpacity="0.18" />
|
||||||
</defs>
|
<stop offset="100%" stopColor={first?.color ?? "#C1121F"} stopOpacity="0.02" />
|
||||||
{grid.map((r) => {
|
</linearGradient>
|
||||||
const y = pad.top + ch * r;
|
</defs>
|
||||||
return (
|
{ticks.map((t) => {
|
||||||
<g key={r}>
|
const y = yFor(t);
|
||||||
<line
|
return (
|
||||||
x1={pad.left}
|
<g key={t}>
|
||||||
x2={width - pad.right}
|
<line
|
||||||
y1={y}
|
x1={pad.left}
|
||||||
y2={y}
|
x2={width - pad.right}
|
||||||
stroke="currentColor"
|
y1={y}
|
||||||
className="text-[#E5E7EB] dark:text-[#3F3F46]"
|
y2={y}
|
||||||
strokeWidth="1"
|
stroke="currentColor"
|
||||||
/>
|
className="text-[#E5E7EB] dark:text-[#3F3F46]"
|
||||||
<text x={6} y={y + 4} className="fill-[#9CA3AF] text-[10px]">
|
strokeWidth="1"
|
||||||
{Math.round(max * (1 - r))}
|
/>
|
||||||
|
<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>
|
</text>
|
||||||
</g>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
{series.map((s) =>
|
||||||
{areaPath && <path d={areaPath} fill="url(#cm-area)" />}
|
s.values.map((v, i) =>
|
||||||
{series.map((s) => (
|
v == null ? null : (
|
||||||
<path
|
<circle key={`${s.name}-${i}`} cx={xFor(i)} cy={yFor(v)} r={2.5} fill={s.color}>
|
||||||
key={s.name}
|
<title>{`${labels[i]} · ${s.name}: ${v}${unit}`}</title>
|
||||||
d={linePath(s.values)}
|
</circle>
|
||||||
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>
|
|
||||||
),
|
),
|
||||||
),
|
)}
|
||||||
)}
|
</svg>
|
||||||
</svg>
|
{caption}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +482,7 @@ export function ConcurrencyMonitorPanel() {
|
|||||||
|
|
||||||
{/* charts */}
|
{/* charts */}
|
||||||
<div className="grid gap-3 xl:grid-cols-2">
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
<ChartCard icon={Gauge} title="并发量(系统压力)" subtitle="峰值 / 平均并发调用数">
|
<ChartCard icon={Gauge} title="并发量(系统压力)" subtitle={`每${granularity === "hour" ? "小时" : "分钟"}一个点`}>
|
||||||
{concurrency.isLoading ? (
|
{concurrency.isLoading ? (
|
||||||
<div className="flex h-[220px] items-center justify-center text-sm text-[#5F6368] dark:text-slate-400">
|
<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" /> 加载中
|
<Loader2 className="mr-2 size-4 animate-spin" /> 加载中
|
||||||
@@ -410,12 +491,14 @@ export function ConcurrencyMonitorPanel() {
|
|||||||
<LineChart
|
<LineChart
|
||||||
labels={concurrencyChart.labels}
|
labels={concurrencyChart.labels}
|
||||||
series={concurrencyChart.series}
|
series={concurrencyChart.series}
|
||||||
unit=""
|
unit=" 个"
|
||||||
emptyHint="暂无并发采样数据(采样关闭或时间窗口内无记录)"
|
integer
|
||||||
|
note="红线=该时段同时进行的最多调用数(峰值);橙线=该时段平均值。数值越高、越靠上,说明同时打大模型的请求越多、系统压力越大。"
|
||||||
|
emptyHint="暂无并发采样数据(需打开「后台采样」开关后才开始记录)"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
<ChartCard icon={Zap} title="大模型出字速度" subtitle="tokens/秒(越低说明模型越慢)">
|
<ChartCard icon={Zap} title="大模型出字速度" subtitle={`每${granularity === "hour" ? "小时" : "分钟"}一个点`}>
|
||||||
{tokenSpeed.isLoading ? (
|
{tokenSpeed.isLoading ? (
|
||||||
<div className="flex h-[220px] items-center justify-center text-sm text-[#5F6368] dark:text-slate-400">
|
<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" /> 加载中
|
<Loader2 className="mr-2 size-4 animate-spin" /> 加载中
|
||||||
@@ -425,6 +508,7 @@ export function ConcurrencyMonitorPanel() {
|
|||||||
labels={tokenChart.labels}
|
labels={tokenChart.labels}
|
||||||
series={tokenChart.series}
|
series={tokenChart.series}
|
||||||
unit=" t/s"
|
unit=" t/s"
|
||||||
|
note="蓝线=该时段所有大模型调用的平均出字速度(tokens/秒,越高越快);紫线=该时段最慢的一次调用。某段时间线明显下凹,说明那会儿模型/供应商变慢,是压力或瓶颈所在。"
|
||||||
emptyHint="暂无大模型调用记录"
|
emptyHint="暂无大模型调用记录"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in new issue
Block a user