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:
1 parent
158d157af4
commit
26f8010a57
17 files changed
+1488
-1
No files matched your search
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user