feat(监控): 管理员排行榜实时并发监控 + 真正停止对话 + 409 中文化

- 排行榜页顶部新增「实时并发监控」面板(admin-only):当前并发大模型调用数 +
  调用列表(用户/智能体/类型/已运行时长),每行「停止」+「全部停止」经
  RunManager.cancel 真正中断 LLM 流式调用,减轻大模型压力
- 并发量折线图(峰值/均值,按分钟/小时):新增轻量后台采样器写入新表
  concurrency_samples(create_all 自动建表),可按分/时聚合
- 大模型出字速度折线图(tokens/秒,按分钟/小时):复用 llm_call_metrics 聚合,
  便于定位是模型/供应商慢
- 后台采样可开关、默认关闭(opt-in)、运行时切换免重启、对线上零影响
  (单条极小 INSERT、保留期清理、查询有上限、全程 best-effort)
- 409 中文化:上一轮未结束又发新问题时返回中文提示,前端 onError 友好映射

后端:runtime/runs/manager.py(list_active)、persistence/concurrency(新模块)、
config/system_settings(ConcurrencyMonitorSettings)、gateway/routers/admin_active_runs、
app.py(采样后台任务)、services.py(409)。前端:core/admin/{active-runs,hooks}、
components/workspace/admin/concurrency-monitor、AdminUserLeaderboardPage、threads/hooks。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-26 20:29:26 +08:00
1 parent 158d157af4
commit 26f8010a57
17 files changed
+1488 -1

No files matched your search

@@ -0,0 +1,472 @@
"use client";
import {
Activity,
Gauge,
Loader2,
OctagonX,
RefreshCw,
Square,
Zap,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import type {
ActiveRunItem,
ConcurrencyPoint,
Granularity,
TokenSpeedPoint,
} from "@/core/admin/active-runs";
import {
useActiveRuns,
useCancelActiveRun,
useCancelAllActiveRuns,
useConcurrencySeries,
useMonitorSettings,
useTokenSpeedSeries,
useUpdateMonitorSettings,
} from "@/core/admin/hooks";
import { cn } from "@/lib/utils";
/** lookback window (hours) per granularity. */
const LOOKBACK: Record<Granularity, number> = { minute: 3, hour: 48 };
function fmtBucket(iso: string, granularity: Granularity): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
const hh = String(date.getHours()).padStart(2, "0");
const mi = String(date.getMinutes()).padStart(2, "0");
return granularity === "hour" ? `${mm}-${dd} ${hh}:00` : `${hh}:${mi}`;
}
function fmtElapsed(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m < 60) return `${m}m${s.toString().padStart(2, "0")}s`;
const h = Math.floor(m / 60);
return `${h}h${(m % 60).toString().padStart(2, "0")}m`;
}
interface LineSeries {
name: string;
color: string;
values: (number | null)[];
}
/** Minimal dependency-free SVG line chart (1–2 series + area under the first). */
function LineChart({
labels,
series,
unit,
emptyHint,
}: {
labels: string[];
series: LineSeries[];
unit: string;
emptyHint: string;
}) {
const width = 760;
const height = 220;
const pad = { top: 18, right: 16, bottom: 26, left: 40 };
const cw = width - pad.left - pad.right;
const ch = height - pad.top - pad.bottom;
const allValues = series.flatMap((s) => s.values.filter((v): v is number => v != null));
const max = Math.max(1, ...allValues);
const n = labels.length;
if (n === 0) {
return (
<div className="flex h-[220px] items-center justify-center text-sm text-[#5F6368] dark:text-slate-400">
{emptyHint}
</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 linePath = (vals: (number | null)[]) => {
let d = "";
let started = false;
vals.forEach((v, i) => {
if (v == null) {
started = false;
return;
}
d += `${started ? "L" : "M"} ${xFor(i).toFixed(1)} ${yFor(v).toFixed(1)} `;
started = true;
});
return d.trim();
};
const first = series[0];
const areaPath = (() => {
if (!first) return "";
const line = linePath(first.values);
if (!line) return "";
return `${line} L ${xFor(n - 1).toFixed(1)} ${(pad.top + ch).toFixed(1)} L ${xFor(0).toFixed(1)} ${(pad.top + ch).toFixed(1)} Z`;
})();
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))}
</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>
),
),
)}
</svg>
);
}
function ChartCard({
icon: Icon,
title,
subtitle,
children,
}: {
icon: typeof Activity;
title: string;
subtitle: string;
children: React.ReactNode;
}) {
return (
<section className="rounded-lg border border-[#F1D6D6] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-3 flex items-center gap-2">
<Icon className="size-4 text-[#C1121F] dark:text-[#7FACF2]" />
<h3 className="font-semibold text-[#231815] dark:text-white">{title}</h3>
<span className="ml-auto text-xs text-[#8B6B6B] dark:text-[#7FACF2]">{subtitle}</span>
</div>
{children}
</section>
);
}
function ActiveRunRow({
run,
onStop,
stopping,
}: {
run: ActiveRunItem;
onStop: (runId: string) => void;
stopping: boolean;
}) {
return (
<div className="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1.2fr)_72px_64px_64px_72px] items-center gap-3 border-b border-[#F1D6D6] px-3 py-2 text-sm last:border-b-0 dark:border-[#213D5E]">
<div className="min-w-0">
<div className="truncate font-medium text-[#202124] dark:text-white">{run.user_name || "未知用户"}</div>
<div className="truncate text-xs text-[#5F6368] dark:text-slate-400" title={run.thread_title}>
{run.thread_title || run.thread_id}
</div>
</div>
<div className="min-w-0 truncate text-[#202124] dark:text-slate-100">{run.agent_name}</div>
<Badge variant="outline" className="justify-center">
{run.kind}
</Badge>
<span
className={cn(
"text-center text-xs font-medium",
run.status === "running" ? "text-[#0F766E] dark:text-teal-300" : "text-[#B45309] dark:text-amber-300",
)}
>
{run.status === "running" ? "运行中" : "排队中"}
</span>
<span className="text-center text-xs tabular-nums text-[#5F6368] dark:text-slate-400">
{fmtElapsed(run.elapsed_seconds)}
</span>
<Button
size="sm"
variant="outline"
className="h-7 border-[#C1121F]/40 text-[#C1121F] hover:bg-[#FFF1F1] dark:border-rose-400/40 dark:text-rose-200"
disabled={stopping}
onClick={() => onStop(run.run_id)}
>
{stopping ? <Loader2 className="size-3.5 animate-spin" /> : <Square className="size-3.5" />}
停止
</Button>
</div>
);
}
export function ConcurrencyMonitorPanel() {
const [open, setOpen] = useState(true);
const [granularity, setGranularity] = useState<Granularity>("minute");
const { data: live, isLoading: liveLoading, refetch, isRefetching } = useActiveRuns(open);
const concurrency = useConcurrencySeries(granularity, LOOKBACK[granularity], open);
const tokenSpeed = useTokenSpeedSeries(granularity, LOOKBACK[granularity], open);
const { data: settings } = useMonitorSettings();
const updateSettings = useUpdateMonitorSettings();
const cancelOne = useCancelActiveRun();
const cancelAll = useCancelAllActiveRuns();
const [stoppingId, setStoppingId] = useState<string | null>(null);
const count = live?.count ?? 0;
const runs = live?.runs ?? [];
const samplerOn = settings?.enabled ?? false;
const concurrencyChart = useMemo(() => {
const points: ConcurrencyPoint[] = concurrency.data?.points ?? [];
return {
labels: points.map((p) => fmtBucket(p.bucket, granularity)),
series: [
{ name: "峰值并发", color: "#C1121F", values: points.map((p) => p.peak) },
{ name: "平均并发", color: "#F59E0B", values: points.map((p) => p.avg) },
] as LineSeries[],
};
}, [concurrency.data, granularity]);
const tokenChart = useMemo(() => {
const points: TokenSpeedPoint[] = tokenSpeed.data?.points ?? [];
return {
labels: points.map((p) => fmtBucket(p.bucket, granularity)),
series: [
{ name: "平均速度", color: "#2563EB", values: points.map((p) => p.avg_tokens_per_sec) },
{ name: "最慢", color: "#9333EA", values: points.map((p) => p.min_tokens_per_sec) },
] as LineSeries[],
};
}, [tokenSpeed.data, granularity]);
const handleStop = async (runId: string) => {
setStoppingId(runId);
try {
const res = await cancelOne.mutateAsync(runId);
toast[res.cancelled ? "success" : "info"](res.cancelled ? "已停止该对话调用" : "该调用已结束或不存在");
} catch (e) {
toast.error(e instanceof Error ? e.message : "停止失败");
} finally {
setStoppingId(null);
}
};
const handleStopAll = async () => {
if (runs.length === 0) return;
if (!window.confirm(`确定要停止当前全部 ${count} 个并发调用吗?这会中断这些用户正在生成的回答。`)) return;
try {
const res = await cancelAll.mutateAsync();
toast.success(`已停止 ${res.cancelled_count} 个调用`);
} catch (e) {
toast.error(e instanceof Error ? e.message : "全部停止失败");
}
};
const handleToggleSampler = async (next: boolean) => {
try {
await updateSettings.mutateAsync(next);
toast.success(next ? "已开启并发采样" : "已关闭并发采样(折线图不再新增历史点)");
} catch (e) {
toast.error(e instanceof Error ? e.message : "切换失败");
}
};
return (
<section className="space-y-4 rounded-xl border border-[#F1D6D6] bg-[#FFF7F7] p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#011226]">
{/* header */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<span className="flex size-9 items-center justify-center rounded-lg bg-[#C1121F] text-white dark:bg-[#2563EB]">
<Activity className="size-5" />
</span>
<div>
<div className="font-semibold text-[#231815] dark:text-white">实时并发监控</div>
<div className="text-xs text-[#8B6B6B] dark:text-[#7FACF2]">
监控大模型并发压力,可直接停止任意对话调用
</div>
</div>
</div>
<div className="ml-auto flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2 rounded-lg border border-[#F1D6D6] bg-white px-3 py-1.5 dark:border-[#213D5E] dark:bg-[#02244C]">
<span className="text-xs text-[#5F6368] dark:text-slate-300">当前并发</span>
<span
className={cn(
"text-2xl font-semibold tabular-nums",
count > 0 ? "text-[#C1121F] dark:text-[#7FACF2]" : "text-[#202124] dark:text-white",
)}
>
{count}
</span>
</div>
<div className="flex items-center gap-2 rounded-lg border border-[#F1D6D6] bg-white px-3 py-1.5 dark:border-[#213D5E] dark:bg-[#02244C]">
<span className="text-xs text-[#5F6368] dark:text-slate-300">后台采样</span>
<Switch checked={samplerOn} onCheckedChange={handleToggleSampler} disabled={updateSettings.isPending} />
</div>
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isRefetching}>
<RefreshCw className={cn("mr-1 size-4", isRefetching && "animate-spin")} />
刷新
</Button>
<Button
variant="outline"
size="sm"
className="border-[#C1121F]/50 text-[#C1121F] hover:bg-[#FFF1F1] dark:border-rose-400/40 dark:text-rose-200"
onClick={handleStopAll}
disabled={cancelAll.isPending || runs.length === 0}
>
<OctagonX className="mr-1 size-4" />
全部停止
</Button>
<Button variant="ghost" size="sm" onClick={() => setOpen((v) => !v)}>
{open ? "收起" : "展开"}
</Button>
</div>
</div>
{open && (
<>
{!samplerOn && (
<div className="rounded-lg border border-amber-300/60 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-300/30 dark:bg-amber-300/10 dark:text-amber-100">
后台采样默认关闭:实时并发数与「停止」功能始终可用。需要并发量历史折线图时,打开右上角「后台采样」开关即可开始记录(对线上服务影响极小,可随时关闭)。
</div>
)}
{/* granularity toggle */}
<div className="flex items-center gap-2">
<span className="text-xs text-[#5F6368] dark:text-slate-300">统计粒度</span>
{(["minute", "hour"] as Granularity[]).map((g) => (
<Button
key={g}
size="sm"
variant={granularity === g ? "default" : "outline"}
className={cn("h-7", granularity === g && "bg-[#C1121F] hover:bg-[#A50F1A] dark:bg-[#2563EB]")}
onClick={() => setGranularity(g)}
>
{g === "minute" ? "按分钟" : "按小时"}
</Button>
))}
<span className="text-xs text-[#8B6B6B] dark:text-[#7FACF2]">
{granularity === "minute" ? "近 3 小时" : "近 48 小时"}
</span>
</div>
{/* charts */}
<div className="grid gap-3 xl:grid-cols-2">
<ChartCard icon={Gauge} title="并发量(系统压力)" subtitle="峰值 / 平均并发调用数">
{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" /> 加载中
</div>
) : (
<LineChart
labels={concurrencyChart.labels}
series={concurrencyChart.series}
unit=""
emptyHint="暂无并发采样数据(采样关闭或时间窗口内无记录)"
/>
)}
</ChartCard>
<ChartCard icon={Zap} title="大模型出字速度" subtitle="tokens/秒(越低说明模型越慢)">
{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" /> 加载中
</div>
) : (
<LineChart
labels={tokenChart.labels}
series={tokenChart.series}
unit=" t/s"
emptyHint="暂无大模型调用记录"
/>
)}
</ChartCard>
</div>
{/* active run list */}
<section className="rounded-lg border border-[#F1D6D6] bg-white dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="flex items-center gap-2 border-b border-[#F1D6D6] px-3 py-2 dark:border-[#213D5E]">
<span className="font-semibold text-[#231815] dark:text-white">正在进行的调用</span>
<Badge variant="outline">{count}</Badge>
{liveLoading && <Loader2 className="size-3.5 animate-spin text-[#8B6B6B]" />}
</div>
{runs.length === 0 ? (
<div className="py-10 text-center text-sm text-[#5F6368] dark:text-slate-400">
当前没有正在进行的大模型调用
</div>
) : (
<div>
<div className="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1.2fr)_72px_64px_64px_72px] gap-3 border-b border-[#F1D6D6] px-3 py-2 text-xs text-[#8B6B6B] dark:border-[#213D5E] dark:text-[#7FACF2]">
<span>用户 / 会话</span>
<span>智能体</span>
<span className="text-center">类型</span>
<span className="text-center">状态</span>
<span className="text-center">已运行</span>
<span className="text-center">操作</span>
</div>
{runs.map((run) => (
<ActiveRunRow
key={run.run_id}
run={run}
onStop={handleStop}
stopping={stoppingId === run.run_id}
/>
))}
</div>
)}
</section>
</>
)}
</section>
);
}
export default ConcurrencyMonitorPanel;