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:
Blizzard
2026-07-20 14:07:02 +08:00
parent 84463394d4
commit e4ec74893f
8 changed files with 440 additions and 4 deletions
+28
View File
@@ -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>;
}
+14 -2
View File
@@ -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>
);
}
@@ -2,6 +2,7 @@ package handler
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
@@ -332,6 +333,25 @@ func (h *Handler) AdminTasks(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"tasks": rows, "counts": h.db.TaskStatusCounts(ctx)})
}
// AdminTaskDetail: GET /api/v1/admin/tasks/:id —— 任务下钻(跨租户)。
// 不复用用户面的 /tasks/:id/replay:那条走请求 ctx,受租户插件过滤,管理员看别的租户
// 的任务会静默拿到空输出/空轨迹(不报错),排查时极具误导性。
func (h *Handler) AdminTaskDetail(c *gin.Context) {
d := h.db.TaskDetail(c.Request.Context(), c.Param("id"))
if d == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "任务不存在"})
return
}
// trace 是收尾时落库的事件数组原文;空串是正常情况(任务未跑完/早于该功能上线)。
exec := []json.RawMessage{}
if d.Trace != "" {
if err := json.Unmarshal([]byte(d.Trace), &exec); err != nil {
exec = []json.RawMessage{} // 脏数据不该让整个下钻 500
}
}
c.JSON(http.StatusOK, gin.H{"task": d, "exec": exec})
}
// AdminSpaces: GET /api/v1/admin/spaces?limit= —— 全平台空间观测(跨租户)。
func (h *Handler) AdminSpaces(c *gin.Context) {
limit := 200
@@ -162,6 +162,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册
admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康
admin.GET("/tasks", h.AdminTasks) // 全平台任务/运行观测(状态/租户筛 + HITL 待审批)
admin.GET("/tasks/:id", h.AdminTaskDetail) // 任务下钻:DSL/输出/轨迹/评测(跨租户,走 WithoutTenant
admin.GET("/spaces", h.AdminSpaces) // 全平台空间观测(跨租户 Space + 成员数)
admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额
admin.GET("/evals", h.AdminEvals) // 自动评测观测:质量趋势 + 计数 + 错题本(真数据)
@@ -0,0 +1,105 @@
package store
import (
"context"
"testing"
)
// 管理端任务下钻必须跨租户读。Task/Eval 都在租户插件作用域内,若用请求 ctx 直接查,
// 管理员看别的租户的任务不会报错,而是**静默返回空**——UI 上表现为"这任务没产出",
// 比报错难查得多。这组测试把"跨租户可读"钉死。
func seedTask(t *testing.T, p *Postgres, tenantID, taskID string) {
t.Helper()
task := &Task{
BaseModel: BaseModel{ID: taskID},
TenantID: tenantID, Owner: "u-" + tenantID, TaskID: taskID,
Status: "done", Output: "最终输出内容",
Trace: `[{"node":"n1","type":"llm","msg":"跑了一步"}]`,
Graph: `{"topic":"测试主题"}`,
}
// 显式 WithoutTenant 建数据:插件会按 ctx 覆写 tenant_id,不绕开就种不进指定租户。
if err := p.db.WithContext(WithoutTenant(context.Background())).Create(task).Error; err != nil {
t.Fatalf("建任务失败: %v", err)
}
}
// 前提验证:先证明租户过滤在测试环境里真的生效(用租户作用域的读去查别人的任务应查不到)。
// 否则下面"跨租户能读到"的断言可能只是因为插件压根没装,属于假过。
func TestTaskDetail_TenantScopeIsActuallyOn(t *testing.T) {
p := newTestStore(t)
seedTenant(t, p, "t1")
seedTask(t, p, "t1", "task_a")
var got Task
err := p.db.WithContext(WithTenant(context.Background(), "t2")).
Where("task_id = ?", "task_a").First(&got).Error
if err == nil {
t.Fatal("租户过滤没生效:t2 的作用域竟能读到 t1 的任务,本测试文件的其余断言都不可信")
}
}
func TestTaskDetail_ReadsAcrossTenants(t *testing.T) {
p := newTestStore(t)
seedTenant(t, p, "t1")
seedTask(t, p, "t1", "task_a")
// 以「另一个租户」的 ctx 调用——模拟管理员本人属于 t2、却要看 t1 的任务。
d := p.TaskDetail(WithTenant(context.Background(), "t2"), "task_a")
if d == nil {
t.Fatal("跨租户下钻取不到任务(TaskDetail 少了 WithoutTenant?)")
}
if d.Output != "最终输出内容" {
t.Fatalf("输出应完整读出,得 %q", d.Output)
}
if d.Trace == "" {
t.Fatal("轨迹不该为空")
}
if d.Topic != "测试主题" {
t.Fatalf("topic 应从 graph 里取出,得 %q", d.Topic)
}
}
func TestTaskDetail_MissingReturnsNil(t *testing.T) {
p := newTestStore(t)
if d := p.TaskDetail(context.Background(), "task_不存在"); d != nil {
t.Fatalf("不存在的任务应返回 nil,得 %+v", d)
}
}
func TestTaskDetail_EvalAttachedWhenPresent(t *testing.T) {
p := newTestStore(t)
seedTenant(t, p, "t1")
seedTask(t, p, "t1", "task_a")
ctx := WithoutTenant(context.Background())
if err := p.db.WithContext(ctx).Create(&Eval{
BaseModel: BaseModel{ID: "ev1"}, TenantID: "t1", TaskID: "task_a",
Overall: 0.82, Level: "ok", Reason: "还行", Sources: 3,
}).Error; err != nil {
t.Fatalf("建评测失败: %v", err)
}
d := p.TaskDetail(WithTenant(context.Background(), "t2"), "task_a")
if d == nil || d.Eval == nil {
t.Fatal("有评测时应带出评测明细")
}
if d.Eval.Level != "ok" || d.Eval.Sources != 3 {
t.Fatalf("评测字段没读对: %+v", d.Eval)
}
}
// 没有评测的任务,eval 应为 nil 而不是零值——否则前端会把 0 分当成"评了 0 分"。
func TestTaskDetail_NoEvalIsNil(t *testing.T) {
p := newTestStore(t)
seedTenant(t, p, "t1")
seedTask(t, p, "t1", "task_a")
d := p.TaskDetail(context.Background(), "task_a")
if d == nil {
t.Fatal("应能取到任务")
}
if d.Eval != nil {
t.Fatalf("无评测时 eval 应为 nil,得 %+v", d.Eval)
}
}
@@ -73,3 +73,60 @@ func (p *Postgres) TaskStatusCounts(ctx context.Context) map[string]int64 {
}
return out
}
// AdminTaskDetail 是管理端任务下钻:基本信息 + 持久化的输出/轨迹 + 评测。
// 轨迹与输出取自 sundynix_task 的收尾落库列(Redis 流只有 10min TTL,历史任务只能靠它们)。
type AdminTaskDetail struct {
AdminTaskRow
Graph string `json:"graph"` // 提交时的 DSL 原文
Output string `json:"output"` // 最终模型输出
Trace string `json:"trace"` // 执行轨迹事件 JSON 数组(原文透传,前端解析)
// gorm:"-":这是查完再单独填的组合字段,不是关联;不标记的话 Scan 会当成关系报错。
Eval *AdminEval `gorm:"-" json:"eval"` // 无评测时为 null
}
// AdminEval 是下钻里的评测明细(比列表页的 level/overall 多出评语与命中项)。
type AdminEval struct {
Overall float64 `json:"overall"`
Rule float64 `json:"rule"`
LLM float64 `json:"llm"`
Faithful float64 `json:"faithful"`
Level string `json:"level"`
Flags string `json:"flags"`
Reason string `json:"reason"`
Sources int `json:"sources"`
Corrected bool `json:"corrected"`
}
// TaskDetail 按 task_id 取单条任务的完整下钻数据(管理端,跨租户)。
// 必须 WithoutTenantTask/Eval 都在租户插件作用域内,用请求 ctx 查别的租户的任务
// 不会报错,而是静默返回空——看起来像"这任务没产出",比报错更难排查。
func (p *Postgres) TaskDetail(ctx context.Context, taskID string) *AdminTaskDetail {
if p.db == nil || taskID == "" {
return nil
}
ctx = WithoutTenant(ctx)
var d AdminTaskDetail
err := p.db.WithContext(ctx).Table("sundynix_task as t").
Select("t.task_id, t.tenant_id, t.owner, t.status, t.detail, t.created_at as at, "+
"t.output, t.trace, coalesce(cast(t.graph as text),'') as graph, "+
"coalesce(tn.name,'') as tenant_name, coalesce(u.email,'') as owner_email, "+
"coalesce(e.level,'') as eval_level, coalesce(e.overall,0) as eval_overall, "+
"coalesce(t.graph->>'topic','') as topic").
Joins("left join sundynix_tenant tn on tn.id = t.tenant_id").
Joins("left join sundynix_user u on u.id = t.owner").
Joins("left join sundynix_eval e on e.task_id = t.task_id").
Where("t.task_id = ? and t.deleted_at is null", taskID).
Scan(&d).Error
if err != nil || d.TaskID == "" {
return nil
}
var e Eval
if p.db.WithContext(ctx).Where("task_id = ?", taskID).First(&e).Error == nil {
d.Eval = &AdminEval{
Overall: e.Overall, Rule: e.Rule, LLM: e.LLM, Faithful: e.Faithful,
Level: e.Level, Flags: e.Flags, Reason: e.Reason, Sources: e.Sources, Corrected: e.Corrected,
}
}
return &d
}
@@ -7,6 +7,7 @@ import (
"github.com/glebarez/sqlite" // 纯 Go sqlite(无 CGO):DB 背书的单测在 CI ubuntu 无 Postgres 服务时也能跑
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
// newTestStore 起一个内存 sqlite,迁移同款模型 + 建那道支付幂等兜底的部分唯一索引 +
@@ -15,7 +16,12 @@ import (
func newTestStore(t *testing.T) *Postgres {
t.Helper()
// 静音 gorm 日志:计费路径故意查 pricing/setting 取不到时回退默认,属预期空查询,别刷屏。
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
// 必须与 OpenPostgres 用同一套命名策略:多数模型有显式 TableName() 碰巧对得上,
// 但 Task 这类没有的会退化成 "tasks",于是写裸 SQL 的查询在测试里查无此表。
NamingStrategy: schema.NamingStrategy{TablePrefix: "sundynix_", SingularTable: true},
})
if err != nil {
t.Fatalf("打开内存 sqlite 失败: %v", err)
}
@@ -28,7 +34,7 @@ func newTestStore(t *testing.T) *Postgres {
if err := db.AutoMigrate(
&User{}, &Tenant{}, &TenantMember{}, &CreditLedger{}, &PaymentOrder{},
&RedeemCode{}, &CreditPack{}, &UsageEvent{}, &UsageRollup{}, &Setting{}, &Pricing{}, &LLMModel{},
&AuditLog{},
&AuditLog{}, &Task{}, &Eval{},
&KB{}, // 租户作用域模型,验证隔离插件
); err != nil {
t.Fatalf("AutoMigrate 失败: %v", err)