diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index 9fa22f4..dc70ba2 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -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 = "", diff --git a/sundynix-admin/src/components/TaskDetailDrawer.tsx b/sundynix-admin/src/components/TaskDetailDrawer.tsx new file mode 100644 index 0000000..30603c4 --- /dev/null +++ b/sundynix-admin/src/components/TaskDetailDrawer.tsx @@ -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(null); + const [exec, setExec] = useState([]); + 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 ( +
+
e.stopPropagation()} + > + {/* 头部 */} +
+
+
+

{task.topic || "(无主题)"}

+

{task.task_id}

+
+ +
+
+ + + + + {task.detail && } +
+
+ + {/* 页签 */} +
+ {( + [ + ["trace", `执行轨迹${exec.length ? ` (${exec.length})` : ""}`], + ["output", "最终输出"], + ["eval", "评测"], + ["graph", "DSL"], + ] as const + ).map(([k, label]) => ( + + ))} +
+ + {/* 内容 */} +
+ {err &&

{err}

} + {!d && !err &&

加载中…

} + + {d && tab === "trace" && ( + exec.length === 0 ? ( + + ) : ( +
    + {exec.map((e, i) => ( +
  1. +
    + #{i + 1} + {e.node && {e.node}} + {e.type && {e.type}} + {e.at && {e.at}} +
    + {e.msg &&

    {e.msg}

    } + {!e.msg && !e.node && ( +
    {JSON.stringify(e)}
    + )} +
  2. + ))} +
+ ) + )} + + {d && tab === "output" && ( + d.output ? ( +
{d.output}
+ ) : ( + + ) + )} + + {d && tab === "eval" && ( + d.eval ? ( +
+
+ + + + +
+
+ + + +
+ {flags.length > 0 && ( +
+ {flags.map((f) => ( + + {f} + + ))} +
+ )} + {d.eval.reason && ( +

+ {d.eval.reason} +

+ )} +
+ ) : ( + + ) + )} + + {d && tab === "graph" && ( + d.graph ? ( +
+                {(() => {
+                  try {
+                    return JSON.stringify(JSON.parse(d.graph), null, 2);
+                  } catch {
+                    return d.graph; // 解析不了就原样显示,别把排查线索吞掉
+                  }
+                })()}
+              
+ ) : ( + + ) + )} +
+
+
+ ); +} + +function Row({ k, v, span }: { k: string; v: string; span?: boolean }) { + return ( +
+
{k}
+
{v}
+
+ ); +} + +function Score({ label, v, hint }: { label: string; v: number; hint?: string }) { + return ( +
+
{label}
+
{(v * 100).toFixed(0)}
+
+ ); +} + +function Empty({ text }: { text: string }) { + return

{text}

; +} diff --git a/sundynix-admin/src/pages/TasksPage.tsx b/sundynix-admin/src/pages/TasksPage.tsx index af69c02..7d2cabf 100644 --- a/sundynix-admin/src/pages/TasksPage.tsx +++ b/sundynix-admin/src/pages/TasksPage.tsx @@ -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([]); + const [drill, setDrill] = useState(null); // 正在下钻的任务 const [copiedId, setCopiedId] = useState(""); const [err, setErr] = useState(""); const [loading, setLoading] = useState(false); @@ -124,13 +126,21 @@ export function TasksPage() { {tasks.map((t) => ( - + setDrill(t)} + className="cursor-pointer border-b border-gray-50 align-top last:border-0 hover:bg-violet-50/40" + title="点击查看执行轨迹 / 输出 / 评测" + > {new Date(t.at).toLocaleString("zh-CN")}
{t.topic || {t.task_id.slice(0, 8)}…}
+ + {drill && setDrill(null)} />} ); } diff --git a/sundynix-gateway/internal/handler/billing_pay.go b/sundynix-gateway/internal/handler/billing_pay.go index c1a14fc..c57ed8c 100644 --- a/sundynix-gateway/internal/handler/billing_pay.go +++ b/sundynix-gateway/internal/handler/billing_pay.go @@ -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 diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 74d0841..0fba8b2 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -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) // 自动评测观测:质量趋势 + 计数 + 错题本(真数据) diff --git a/sundynix-gateway/internal/store/task_detail_test.go b/sundynix-gateway/internal/store/task_detail_test.go new file mode 100644 index 0000000..66a6cef --- /dev/null +++ b/sundynix-gateway/internal/store/task_detail_test.go @@ -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) + } +} diff --git a/sundynix-gateway/internal/store/task_query.go b/sundynix-gateway/internal/store/task_query.go index 1cf77f4..3bf63d5 100644 --- a/sundynix-gateway/internal/store/task_query.go +++ b/sundynix-gateway/internal/store/task_query.go @@ -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 取单条任务的完整下钻数据(管理端,跨租户)。 +// 必须 WithoutTenant:Task/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 +} diff --git a/sundynix-gateway/internal/store/testdb_test.go b/sundynix-gateway/internal/store/testdb_test.go index 6e56e40..1666eca 100644 --- a/sundynix-gateway/internal/store/testdb_test.go +++ b/sundynix-gateway/internal/store/testdb_test.go @@ -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)