feat(admin): 任务观测支持下钻 —— 轨迹/输出/评测/DSL
全平台任务页此前只能看列表,点进去什么都没有。现在点一行开抽屉,四个页签:
执行轨迹、最终输出、评测明细、提交时的 DSL。不含审批操作——审批是客户端
用户的行为(桌面端 ApprovalBar),管理端只做观测。
不复用用户面的 /tasks/:id/replay:Task/Eval 都在租户插件作用域内,用请求 ctx
查别的租户的任务不会报错,而是静默返回空输出/空轨迹,UI 上表现为"这任务没
产出",比报错难查得多。新增 admin 端点走 WithoutTenant。
数据取自 sundynix_task 收尾落库的 output/trace 列,不依赖 Redis 流(10min TTL)。
所以这是复盘视图,运行中的任务轨迹为空——UI 里明确写出来,免得被当成轨迹丢了。
修的两处与测试环境失真有关(写测试时暴露的):
- 测试库没配 NamingStrategy,与 OpenPostgres 不一致:多数模型有显式
TableName() 碰巧对得上,但 Task 这类没有的会退化成 "tasks",导致写裸
SQL 的查询在测试里查无此表。现已对齐 sundynix_ 前缀 + 单数表名。
- graph 的 ::text 换成标准 cast(... as text):前者是 Postgres 专有,
换掉后这条查询才能被内存库覆盖。
新增 5 组测试,其中一组专门先证明"租户过滤在测试环境里确实开着"——否则
"跨租户能读到"的断言可能只是因为插件没装,属于假过。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -361,6 +361,34 @@ export interface AdminTask {
|
||||
eval_overall: number;
|
||||
}
|
||||
|
||||
export interface AdminEval {
|
||||
overall: number;
|
||||
rule: number;
|
||||
llm: number;
|
||||
faithful: number;
|
||||
level: string;
|
||||
flags: string; // JSON 数组字符串
|
||||
reason: string;
|
||||
sources: number;
|
||||
corrected: boolean;
|
||||
}
|
||||
|
||||
export interface AdminTaskDetail extends AdminTask {
|
||||
graph: string;
|
||||
output: string;
|
||||
trace: string;
|
||||
eval: AdminEval | null;
|
||||
}
|
||||
|
||||
// 任务下钻。走 /admin 而非用户面的 /tasks/:id/replay —— 后者受租户插件过滤,
|
||||
// 管理员看别的租户的任务会静默拿到空输出/空轨迹(不报错),排查时极具误导性。
|
||||
export async function adminTaskDetail(id: string): Promise<{ task: AdminTaskDetail; exec: unknown[] }> {
|
||||
const res = guard(await fetch(`${ADMIN}/tasks/${encodeURIComponent(id)}`, { headers: authHeaders() }));
|
||||
const d = (await res.json().catch(() => ({}))) as { task?: AdminTaskDetail; exec?: unknown[]; error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `task detail failed: ${res.status}`);
|
||||
return { task: d.task as AdminTaskDetail, exec: d.exec ?? [] };
|
||||
}
|
||||
|
||||
// adminTasks 全平台任务流 + 状态计数。status 空=全部;tenant 空=全租户;含 HITL 待审批(status=waiting)。
|
||||
export async function adminTasks(
|
||||
status = "",
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { adminTaskDetail, type AdminTask, type AdminTaskDetail } from "../api";
|
||||
|
||||
// 任务下钻:点列表一行看这一次运行到底发生了什么 —— 轨迹、最终输出、评测明细。
|
||||
//
|
||||
// 数据全部取自 sundynix_task 收尾时落库的列,不依赖 Redis 流(那个只有 10min TTL,
|
||||
// 历史任务早没了)。所以这里看到的是"复盘"而非"实时",正在跑的任务轨迹会是空的——
|
||||
// 这一点在 UI 上要讲清楚,否则会被当成"轨迹丢了"。
|
||||
//
|
||||
// 不含审批操作:审批是客户端用户的行为(桌面端 ApprovalBar),管理端只做观测。
|
||||
|
||||
type ExecEvent = { node?: string; type?: string; msg?: string; at?: string; [k: string]: unknown };
|
||||
|
||||
export function TaskDetailDrawer({ task, onClose }: { task: AdminTask; onClose: () => void }) {
|
||||
const [d, setD] = useState<AdminTaskDetail | null>(null);
|
||||
const [exec, setExec] = useState<ExecEvent[]>([]);
|
||||
const [err, setErr] = useState("");
|
||||
const [tab, setTab] = useState<"trace" | "output" | "eval" | "graph">("trace");
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
adminTaskDetail(task.task_id)
|
||||
.then((r) => {
|
||||
if (!alive) return;
|
||||
setD(r.task);
|
||||
setExec(r.exec as ExecEvent[]);
|
||||
})
|
||||
.catch((e) => alive && setErr((e as Error).message));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [task.task_id]);
|
||||
|
||||
const flags: string[] = (() => {
|
||||
try {
|
||||
return d?.eval?.flags ? (JSON.parse(d.eval.flags) as string[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-gray-900/30" onClick={onClose}>
|
||||
<div
|
||||
className="flex h-full w-full max-w-2xl flex-col bg-white shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="border-b border-gray-100 px-5 py-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-sm font-semibold text-gray-800">{task.topic || "(无主题)"}</h3>
|
||||
<p className="mt-0.5 font-mono text-[11px] text-gray-400">{task.task_id}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="shrink-0 text-xs text-gray-400 hover:text-gray-600">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-x-6 gap-y-1 text-[11px]">
|
||||
<Row k="租户" v={task.tenant_name || task.tenant_id || "—"} />
|
||||
<Row k="提交人" v={task.owner_email || task.owner || "—"} />
|
||||
<Row k="状态" v={task.status} />
|
||||
<Row k="提交时间" v={new Date(task.at).toLocaleString("zh-CN")} />
|
||||
{task.detail && <Row k="失败原因" v={task.detail} span />}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* 页签 */}
|
||||
<div className="flex gap-1 border-b border-gray-100 px-5 pt-3">
|
||||
{(
|
||||
[
|
||||
["trace", `执行轨迹${exec.length ? ` (${exec.length})` : ""}`],
|
||||
["output", "最终输出"],
|
||||
["eval", "评测"],
|
||||
["graph", "DSL"],
|
||||
] as const
|
||||
).map(([k, label]) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setTab(k)}
|
||||
className={`rounded-t px-3 py-1.5 text-xs ${
|
||||
tab === k ? "border-b-2 border-violet-500 font-medium text-violet-600" : "text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="flex-1 overflow-auto px-5 py-4">
|
||||
{err && <p className="text-xs text-rose-500">{err}</p>}
|
||||
{!d && !err && <p className="text-xs text-gray-400">加载中…</p>}
|
||||
|
||||
{d && tab === "trace" && (
|
||||
exec.length === 0 ? (
|
||||
<Empty
|
||||
text={
|
||||
task.status === "running" || task.status === "submitted"
|
||||
? "任务还在跑,轨迹要等收尾才落库(这里是复盘视图,不是实时流)"
|
||||
: "这次运行没有留下轨迹(可能早于轨迹落库功能上线)"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ol className="space-y-2">
|
||||
{exec.map((e, i) => (
|
||||
<li key={i} className="rounded-lg border border-gray-100 bg-gray-50/60 p-2.5 text-[11px]">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="shrink-0 text-gray-300">#{i + 1}</span>
|
||||
{e.node && <span className="font-medium text-gray-700">{e.node}</span>}
|
||||
{e.type && <span className="rounded bg-white px-1.5 py-0.5 text-[10px] text-violet-600">{e.type}</span>}
|
||||
{e.at && <span className="ml-auto shrink-0 text-gray-300">{e.at}</span>}
|
||||
</div>
|
||||
{e.msg && <p className="mt-1 whitespace-pre-wrap break-words text-gray-600">{e.msg}</p>}
|
||||
{!e.msg && !e.node && (
|
||||
<pre className="mt-1 overflow-x-auto text-[10px] text-gray-500">{JSON.stringify(e)}</pre>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
)}
|
||||
|
||||
{d && tab === "output" && (
|
||||
d.output ? (
|
||||
<pre className="whitespace-pre-wrap break-words text-[11px] leading-relaxed text-gray-700">{d.output}</pre>
|
||||
) : (
|
||||
<Empty text="没有最终输出(任务未完成或失败)" />
|
||||
)
|
||||
)}
|
||||
|
||||
{d && tab === "eval" && (
|
||||
d.eval ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<Score label="综合" v={d.eval.overall} />
|
||||
<Score label="规则" v={d.eval.rule} />
|
||||
<Score label="质量" v={d.eval.llm} />
|
||||
<Score label="忠实度" v={d.eval.faithful} hint={d.eval.sources === 0 ? "无检索来源,未评" : undefined} />
|
||||
</div>
|
||||
<dl className="space-y-1 rounded-lg bg-gray-50 p-3 text-[11px]">
|
||||
<Row k="分级" v={d.eval.level} />
|
||||
<Row k="检索来源数" v={String(d.eval.sources)} />
|
||||
<Row k="是否纠偏重生成" v={d.eval.corrected ? "是(低分自动纠偏后采纳)" : "否"} />
|
||||
</dl>
|
||||
{flags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{flags.map((f) => (
|
||||
<span key={f} className="rounded bg-amber-50 px-2 py-0.5 text-[10px] text-amber-700">
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{d.eval.reason && (
|
||||
<p className="whitespace-pre-wrap rounded-lg border border-gray-100 p-3 text-[11px] leading-relaxed text-gray-600">
|
||||
{d.eval.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Empty text="尚无评测结果(任务未完成,或评测仍在进行)" />
|
||||
)
|
||||
)}
|
||||
|
||||
{d && tab === "graph" && (
|
||||
d.graph ? (
|
||||
<pre className="overflow-x-auto text-[10px] leading-relaxed text-gray-600">
|
||||
{(() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(d.graph), null, 2);
|
||||
} catch {
|
||||
return d.graph; // 解析不了就原样显示,别把排查线索吞掉
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
) : (
|
||||
<Empty text="没有记录提交时的 DSL" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ k, v, span }: { k: string; v: string; span?: boolean }) {
|
||||
return (
|
||||
<div className={`flex items-baseline gap-2 ${span ? "col-span-2" : ""}`}>
|
||||
<dt className="shrink-0 text-gray-400">{k}</dt>
|
||||
<dd className="min-w-0 break-words text-gray-700">{v}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Score({ label, v, hint }: { label: string; v: number; hint?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-100 p-2 text-center" title={hint}>
|
||||
<div className="text-[10px] text-gray-400">{label}</div>
|
||||
<div className="mt-0.5 text-sm font-semibold tabular-nums text-gray-800">{(v * 100).toFixed(0)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty({ text }: { text: string }) {
|
||||
return <p className="py-10 text-center text-[11px] leading-relaxed text-gray-400">{text}</p>;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { TaskDetailDrawer } from "../components/TaskDetailDrawer";
|
||||
import { adminTasks, listTenants, type AdminTask, type TenantRow } from "../api";
|
||||
|
||||
// 全平台任务/运行观测:跨租户看所有任务(状态分布 + 列表 + 提交人/租户/评测)。
|
||||
@@ -31,6 +32,7 @@ export function TasksPage() {
|
||||
const [filter, setFilter] = useState("");
|
||||
const [tenantFilter, setTenantFilter] = useState("");
|
||||
const [tenants, setTenants] = useState<TenantRow[]>([]);
|
||||
const [drill, setDrill] = useState<AdminTask | null>(null); // 正在下钻的任务
|
||||
const [copiedId, setCopiedId] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -124,13 +126,21 @@ export function TasksPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((t) => (
|
||||
<tr key={t.task_id} className="border-b border-gray-50 last:border-0 align-top">
|
||||
<tr
|
||||
key={t.task_id}
|
||||
onClick={() => setDrill(t)}
|
||||
className="cursor-pointer border-b border-gray-50 align-top last:border-0 hover:bg-violet-50/40"
|
||||
title="点击查看执行轨迹 / 输出 / 评测"
|
||||
>
|
||||
<td className="py-2 pr-3 text-xs text-gray-500 whitespace-nowrap">{new Date(t.at).toLocaleString("zh-CN")}</td>
|
||||
<td className="py-2 pr-3">
|
||||
<div className="text-gray-800 flex items-center gap-1.5">
|
||||
<span>{t.topic || <code className="text-[11px] text-gray-500">{t.task_id.slice(0, 8)}…</code>}</span>
|
||||
<button
|
||||
onClick={() => handleCopy(t.task_id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // 复制不应顺带打开下钻
|
||||
handleCopy(t.task_id);
|
||||
}}
|
||||
className="text-[10px] text-gray-400 hover:text-violet-600 transition"
|
||||
title="复制任务 ID"
|
||||
>
|
||||
@@ -174,6 +184,8 @@ export function TasksPage() {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{drill && <TaskDetailDrawer task={drill} onClose={() => setDrill(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user