import type { EngineRunDetail, EngineStepAttempt } from "@umt/contracts"; import { Ban, ChevronDown, ExternalLink, Repeat, ScrollText } from "lucide-react"; import { type ReactNode, useState } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; import { ENGINE_TERMINAL, useCancelEngineRun, useEngineRun, useRetryEngineRun } from "~/api/engine.ts"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; import { DetailSection } from "~/components/console/detail-sheet.tsx"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { JsonView } from "~/components/console/json-view.tsx"; import { KvList } from "~/components/console/kv-list.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { Timeline, type TimelineItem } from "~/components/console/timeline.tsx"; import { Button } from "~/components/ui/button.tsx"; import { errorMessage } from "~/lib/api.ts"; import { formatDuration, formatRelative, spanSec } from "~/lib/labels.ts"; import { cn, formatDateTime } from "~/lib/utils.ts"; import { BUSINESS_KIND_LABEL, businessLink, engineRunStatus, runStatus, SOURCE_LABEL, sourceOf, workflowLabel, } from "./labels.ts"; // 任务详情:业务摘要 → 时间线 → 引擎步骤(折叠)→ 操作。任务页抽屉与 /tasks/:id 共用。 const KIND_LABEL: Record = { function: "函数", sleep: "休眠", workflow: "子工作流", "signal-send": "发送信号", "signal-wait": "等待信号", }; function stepTone(s: string) { if (s === "completed" || s === "succeeded") return "success" as const; if (s === "failed") return "destructive" as const; return "neutral" as const; } /** 错误对象里的一句话 */ export function errorText(e: unknown): string | null { if (e === null || e === undefined) return null; if (typeof e === "string") return e; const m = (e as { message?: unknown }).message; return typeof m === "string" ? m : JSON.stringify(e).slice(0, 300); } /** 引擎步骤尝试(原引擎面板详情) */ function stepItems(steps: EngineStepAttempt[], onOpenRun?: (id: string) => void): TimelineItem[] { const seen = new Map(); return steps.map((st) => { const n = (seen.get(st.stepName) ?? 0) + 1; seen.set(st.stepName, n); const hasError = st.error !== null && st.error !== undefined; return { id: st.id, at: st.startedAt ?? st.createdAt, tone: stepTone(st.status), title: ( {st.stepName} {KIND_LABEL[st.kind] ?? st.kind} · {runStatus(st.status).label} {n > 1 ? ` · 第 ${n} 次` : ""} ), meta: formatDuration(spanSec(st)), description: ( <> {st.waitSignal ? (

等待信号 {st.waitSignal} {st.timeoutAt ? ` · 超时于 ${formatDateTime(st.timeoutAt)}` : ""}

) : null} {st.childWorkflowRunId ? (

子任务{" "}

) : null}

{st.id}

), children: hasError ? ( ) : st.output !== null && st.output !== undefined ? ( ) : undefined, }; }); } /** 业务时间线:创建 → 开始 → 等待 / 失败的步骤 → 结束 */ function businessTimeline(r: EngineRunDetail): TimelineItem[] { const src = sourceOf(r.business?.triggeredBy); const items: TimelineItem[] = [ { id: "created", at: r.createdAt, title: `创建${src ? `(${SOURCE_LABEL[src]}触发)` : ""}`, tone: "neutral" }, ]; if (r.startedAt) items.push({ id: "started", at: r.startedAt, title: r.attempts > 1 ? `开始执行 · 第 ${r.attempts} 次尝试` : "开始执行", tone: "neutral", }); for (const st of r.stepAttempts) { if (st.waitSignal && !st.finishedAt) items.push({ id: `w-${st.id}`, at: st.startedAt ?? st.createdAt, title: `等待:${st.stepName}`, tone: "warning", }); else if (st.status === "failed") items.push({ id: `f-${st.id}`, at: st.finishedAt ?? st.startedAt ?? st.createdAt, title: `步骤失败:${st.stepName}`, description: errorText(st.error), tone: "destructive", }); } if (r.finishedAt) { const s = engineRunStatus(r); items.push({ id: "finished", at: r.finishedAt, title: s.label, // 与最后一个失败步骤相同的错误不再重复 description: r.status === "failed" && !r.stepAttempts.some((st) => errorText(st.error) === errorText(r.error)) ? errorText(r.error) : undefined, tone: s.tone, meta: formatDuration(spanSec(r)), }); } else if (r.startedAt) { items.push({ id: "now", title: "进行中", tone: "neutral", meta: `已 ${formatDuration(spanSec(r))}` }); } return items; } /** 取消 / 重跑 */ export function RunActions({ r, onOpenRun, size = "sm", }: { r: EngineRunDetail; onOpenRun?: (id: string) => void; size?: "sm" | "xs"; }) { const cancel = useCancelEngineRun(); const retry = useRetryEngineRun(); if (!ENGINE_TERMINAL.has(r.status)) return ( 取消任务 } title="取消这个任务?" description="正在执行的步骤会在下一个检查点退出,已完成的步骤不回滚。" confirmLabel="取消任务" cancelLabel="不取消" destructive onConfirm={async () => { try { const res = await cancel.mutateAsync({ id: r.id, reason: "控制台手动取消" }); if (res.canceled) toast.success("已取消"); else toast.warning("任务已结束"); } catch (err) { toast.error(errorMessage(err, "取消失败")); throw err; } }} /> ); return ( 重跑 } title="重跑这个任务?" description="用原始输入新建一条任务,原记录不变。已完成的文件操作不会重复执行。" confirmLabel="重跑" cancelLabel="不重跑" onConfirm={async () => { try { const res = await retry.mutateAsync({ id: r.id }); toast.success(`已重新排队(第 ${res.attempt} 次重跑)`); onOpenRun?.(res.runId); } catch (err) { toast.error(errorMessage(err, "重跑失败")); throw err; } }} /> ); } function Collapsible({ title, children }: { title: string; children: ReactNode }) { const [open, setOpen] = useState(false); return (
{open ?
{children}
: null}
); } /** 详情主体(不含外框);抽屉和独立页都用 */ export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun?: (id: string) => void }) { const b = r.business; const link = businessLink(b, r.input); const src = sourceOf(b?.triggeredBy); const hasError = r.error !== null && r.error !== undefined; const audits = [ { to: `/console/audit?targetType=engine_run&targetId=${encodeURIComponent(r.id)}`, label: "本任务的操作记录" }, ...(b?.kind === "schedule" ? [ { to: `/console/audit?targetType=scheduled_job&targetId=${encodeURIComponent(b.refId)}`, label: "定时任务的修改记录", }, ] : []), ]; return ( <> {BUSINESS_KIND_LABEL[b.kind]} · {b.title ?? b.refId} ) : ( `${BUSINESS_KIND_LABEL[b.kind]} · ${b.title ?? b.refId}` ) ) : ( "无" ), wide: true, }, { label: "做什么", value: workflowLabel(r.workflowName) }, { label: "来源", value: src ? SOURCE_LABEL[src] : "未知" }, { label: "开始", value: r.startedAt ? `${formatDateTime(r.startedAt)}(${formatRelative(r.startedAt)})` : "未开始", }, { label: "耗时", value: formatDuration(spanSec(r)) }, ]} />
    {audits.map((a) => (
  • {a.label}
  • ))}
{r.stepAttempts.length === 0 ? ( ) : ( )} {hasError ? : null} {!hasError ? : null} {r.signals.length ? (

信号({r.signals.length})

{r.signals.map((sg) => (

{sg.signal} {formatDateTime(sg.createdAt)}

))}
) : null}
); } /** 头部一行:状态 + 业务类型 */ export function TaskStatusLine({ r }: { r: EngineRunDetail }) { const s = engineRunStatus(r); return ( {s.label} {workflowLabel(r.workflowName)} {r.business ? · {BUSINESS_KIND_LABEL[r.business.kind]} : null} ); } /** 按 id 取数并渲染(抽屉用) */ export function TaskDetail({ runId, onOpenRun }: { runId: string; onOpenRun?: (id: string) => void }) { const q = useEngineRun(runId); if (q.isPending) return ; if (q.isError) return q.refetch()} />; return ; }