Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
89232d7521
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;
|
||||
@@ -0,0 +1,159 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
|
||||
/** One in-flight model run as seen by the admin concurrency monitor. */
|
||||
export interface ActiveRunItem {
|
||||
run_id: string;
|
||||
thread_id: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
elapsed_seconds: number;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
agent_id: string;
|
||||
agent_name: string;
|
||||
thread_title: string;
|
||||
kind: string;
|
||||
multitask_strategy: string;
|
||||
}
|
||||
|
||||
export interface ActiveRunsResponse {
|
||||
count: number;
|
||||
server_time: string;
|
||||
runs: ActiveRunItem[];
|
||||
}
|
||||
|
||||
export interface ConcurrencyPoint {
|
||||
bucket: string;
|
||||
peak: number;
|
||||
avg: number;
|
||||
samples: number;
|
||||
}
|
||||
|
||||
export interface ConcurrencySeriesResponse {
|
||||
granularity: string;
|
||||
since: string;
|
||||
until: string;
|
||||
enabled: boolean;
|
||||
points: ConcurrencyPoint[];
|
||||
}
|
||||
|
||||
export interface TokenSpeedPoint {
|
||||
bucket: string;
|
||||
avg_tokens_per_sec: number;
|
||||
min_tokens_per_sec: number;
|
||||
max_tokens_per_sec: number;
|
||||
samples: number;
|
||||
}
|
||||
|
||||
export interface TokenSpeedSeriesResponse {
|
||||
granularity: string;
|
||||
since: string;
|
||||
until: string;
|
||||
points: TokenSpeedPoint[];
|
||||
}
|
||||
|
||||
export type Granularity = "minute" | "hour";
|
||||
|
||||
const base = () => `${getBackendBaseURL()}/api/admin/active-runs`;
|
||||
|
||||
async function parse<T>(res: Response, what: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `${what} (HTTP ${res.status})`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/** Live snapshot of all in-flight runs. Polled — intentionally NOT y-logged. */
|
||||
export async function loadActiveRuns(): Promise<ActiveRunsResponse> {
|
||||
return parse<ActiveRunsResponse>(await apiFetch(base()), "Failed to load active runs");
|
||||
}
|
||||
|
||||
/** Concurrency time-series (peak/avg per minute|hour). Polled — not y-logged. */
|
||||
export async function loadConcurrencySeries(
|
||||
granularity: Granularity,
|
||||
hours?: number,
|
||||
): Promise<ConcurrencySeriesResponse> {
|
||||
const params = new URLSearchParams({ granularity });
|
||||
if (hours) params.set("hours", String(hours));
|
||||
return parse<ConcurrencySeriesResponse>(
|
||||
await apiFetch(`${base()}/concurrency?${params}`),
|
||||
"Failed to load concurrency series",
|
||||
);
|
||||
}
|
||||
|
||||
/** Token-speed time-series (tokens/sec per minute|hour). Polled — not y-logged. */
|
||||
export async function loadTokenSpeedSeries(
|
||||
granularity: Granularity,
|
||||
hours?: number,
|
||||
): Promise<TokenSpeedSeriesResponse> {
|
||||
const params = new URLSearchParams({ granularity });
|
||||
if (hours) params.set("hours", String(hours));
|
||||
return parse<TokenSpeedSeriesResponse>(
|
||||
await apiFetch(`${base()}/token-speed?${params}`),
|
||||
"Failed to load token speed series",
|
||||
);
|
||||
}
|
||||
|
||||
/** Truly stop one in-flight run (cancels the live model stream). */
|
||||
export async function cancelActiveRun(runId: string): Promise<{ run_id: string; cancelled: boolean }> {
|
||||
const url = `${base()}/${encodeURIComponent(runId)}/cancel`;
|
||||
const result = await parse<{ run_id: string; cancelled: boolean }>(
|
||||
await apiFetch(url, { method: "POST" }),
|
||||
"Failed to cancel run",
|
||||
);
|
||||
writeYLog({
|
||||
optType: OptType.other,
|
||||
optDetail: "CM助手-全量-并发监控-停止对话调用",
|
||||
url,
|
||||
reqParam: { runId },
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Stop every in-flight run at once. */
|
||||
export async function cancelAllActiveRuns(): Promise<{ cancelled_count: number }> {
|
||||
const url = `${base()}/cancel-all`;
|
||||
const result = await parse<{ cancelled_count: number }>(
|
||||
await apiFetch(url, { method: "POST" }),
|
||||
"Failed to cancel all runs",
|
||||
);
|
||||
writeYLog({
|
||||
optType: OptType.other,
|
||||
optDetail: "CM助手-全量-并发监控-全部停止",
|
||||
url,
|
||||
returnParam: result,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Read the background-sampler on/off switch. */
|
||||
export async function getMonitorSettings(): Promise<{ enabled: boolean }> {
|
||||
return parse<{ enabled: boolean }>(
|
||||
await apiFetch(`${base()}/settings`),
|
||||
"Failed to load monitor settings",
|
||||
);
|
||||
}
|
||||
|
||||
/** Toggle the background concurrency sampler (admin only, no restart). */
|
||||
export async function updateMonitorSettings(enabled: boolean): Promise<{ enabled: boolean }> {
|
||||
const url = `${base()}/settings`;
|
||||
const result = await parse<{ enabled: boolean }>(
|
||||
await apiFetch(url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
"Failed to update monitor settings",
|
||||
);
|
||||
writeYLog({
|
||||
optType: enabled ? OptType.enabled : OptType.disabled,
|
||||
optDetail: `CM助手-全量-并发监控-${enabled ? "开启" : "关闭"}采样`,
|
||||
url,
|
||||
reqParam: { enabled },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -17,6 +17,96 @@ import {
|
||||
updateAdminV1Fact,
|
||||
updateAdminV1Section,
|
||||
} from "./api";
|
||||
import {
|
||||
cancelActiveRun,
|
||||
cancelAllActiveRuns,
|
||||
getMonitorSettings,
|
||||
loadActiveRuns,
|
||||
loadConcurrencySeries,
|
||||
loadTokenSpeedSeries,
|
||||
updateMonitorSettings,
|
||||
type Granularity,
|
||||
} from "./active-runs";
|
||||
|
||||
// ── 实时并发监控 (admin) ─────────────────────────────────────────────────────
|
||||
|
||||
/** Live snapshot of in-flight runs. Polls every `intervalMs` while `enabled`. */
|
||||
export function useActiveRuns(enabled = true, intervalMs = 4000) {
|
||||
return useQuery({
|
||||
queryKey: ["admin-active-runs", "live"],
|
||||
queryFn: loadActiveRuns,
|
||||
enabled,
|
||||
refetchInterval: enabled ? intervalMs : false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** 并发量时序 (peak/avg per minute|hour). */
|
||||
export function useConcurrencySeries(
|
||||
granularity: Granularity,
|
||||
hours: number | undefined,
|
||||
enabled = true,
|
||||
intervalMs = 15000,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["admin-active-runs", "concurrency", granularity, hours ?? null],
|
||||
queryFn: () => loadConcurrencySeries(granularity, hours),
|
||||
enabled,
|
||||
refetchInterval: enabled ? intervalMs : false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** 大模型 token 出字速度时序 (tokens/sec per minute|hour). */
|
||||
export function useTokenSpeedSeries(
|
||||
granularity: Granularity,
|
||||
hours: number | undefined,
|
||||
enabled = true,
|
||||
intervalMs = 15000,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["admin-active-runs", "token-speed", granularity, hours ?? null],
|
||||
queryFn: () => loadTokenSpeedSeries(granularity, hours),
|
||||
enabled,
|
||||
refetchInterval: enabled ? intervalMs : false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCancelActiveRun() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (runId: string) => cancelActiveRun(runId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-active-runs", "live"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCancelAllActiveRuns() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => cancelAllActiveRuns(),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin-active-runs", "live"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useMonitorSettings(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["admin-active-runs", "settings"],
|
||||
queryFn: getMonitorSettings,
|
||||
enabled,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateMonitorSettings() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (nextEnabled: boolean) => updateMonitorSettings(nextEnabled),
|
||||
onSuccess: (data) => {
|
||||
qc.setQueryData(["admin-active-runs", "settings"], data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserLeaderboard(
|
||||
range: {
|
||||
|
||||
@@ -219,6 +219,19 @@ function isRunNotFoundError(message: string): boolean {
|
||||
return /run\s+[\w-]{8,}\s+not\s+found/i.test(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一会话上一轮还没生成完,又提交了新问题 → 后端返回 409
|
||||
* 「already has an active run」。匹配 HTTP 409、英文兜底文案,或后端已中文化
|
||||
* 的提示,统一映射成一句友好的中文。
|
||||
*/
|
||||
function isActiveRunConflictError(message: string): boolean {
|
||||
return (
|
||||
/\b409\b/.test(message) ||
|
||||
/already has an active run/i.test(message) ||
|
||||
/还在生成回答/.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
function buildCurrentUserLabel(): string {
|
||||
const auth = getStoredAuth();
|
||||
if (!auth) {
|
||||
@@ -555,6 +568,12 @@ export function useThreadStream({
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 上一轮还没结束又发了新问题(后端 409):给一句明确的中文提示,而不是
|
||||
// 把原始的英文/HTTP 错误抛给用户。
|
||||
if (isActiveRunConflictError(message)) {
|
||||
toast.info("当前对话还在生成回答,请等待本轮回复结束,或先点「停止」再发送新的问题。");
|
||||
return;
|
||||
}
|
||||
toast.error(message);
|
||||
},
|
||||
onFinish(state) {
|
||||
|
||||
@@ -46,6 +46,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { ConcurrencyMonitorPanel } from "@/components/workspace/admin/concurrency-monitor";
|
||||
import type {
|
||||
AdminLeaderboardDimension,
|
||||
AdminLeaderboardOverview,
|
||||
@@ -1389,6 +1390,8 @@ export default function AdminUserLeaderboardPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 p-6">
|
||||
<ConcurrencyMonitorPanel />
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center text-sm text-[#5F6368] dark:text-slate-400">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
Reference in new issue
Block a user