Tasks page merges engine runs (with W9-I business fields) into one stream: all / active / failed / plan tabs, filters for source, business kind, status and time; detail drawer with business summary, timeline, collapsed engine steps, cancel/retry and related audit links. The schedule board moves into the plan tab; card history jumps to the stream filtered by schedule key. Co-Authored-By: Claude Opus 5.5 <[email protected]>
352 lines
13 KiB
TypeScript
352 lines
13 KiB
TypeScript
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, runStatus, SOURCE_LABEL, sourceOf, workflowLabel } from "./labels.ts";
|
|
|
|
// 任务详情:业务摘要 → 时间线 → 引擎步骤(折叠)→ 操作。任务页抽屉与 /tasks/:id 共用。
|
|
|
|
const KIND_LABEL: Record<string, string> = {
|
|
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<string, number>();
|
|
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: (
|
|
<span>
|
|
{st.stepName}
|
|
<span className="ml-2 text-meta font-normal text-muted-foreground">
|
|
{KIND_LABEL[st.kind] ?? st.kind} · {runStatus(st.status).label}
|
|
{n > 1 ? ` · 第 ${n} 次` : ""}
|
|
</span>
|
|
</span>
|
|
),
|
|
meta: formatDuration(spanSec(st)),
|
|
description: (
|
|
<>
|
|
{st.waitSignal ? (
|
|
<p>
|
|
等待信号 <code className="font-mono">{st.waitSignal}</code>
|
|
{st.timeoutAt ? ` · 超时于 ${formatDateTime(st.timeoutAt)}` : ""}
|
|
</p>
|
|
) : null}
|
|
{st.childWorkflowRunId ? (
|
|
<p>
|
|
子任务{" "}
|
|
<button
|
|
type="button"
|
|
className="cursor-pointer font-mono underline"
|
|
onClick={() => onOpenRun?.(st.childWorkflowRunId ?? "")}
|
|
>
|
|
{st.childWorkflowRunId}
|
|
</button>
|
|
</p>
|
|
) : null}
|
|
<p className="font-mono text-meta">{st.id}</p>
|
|
</>
|
|
),
|
|
children: hasError ? (
|
|
<JsonView label="错误" value={st.error} tone="destructive" defaultOpen />
|
|
) : st.output !== null && st.output !== undefined ? (
|
|
<JsonView label="输出" value={st.output} defaultOpen={false} maxHeight="14rem" />
|
|
) : 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 = runStatus(r.status);
|
|
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 (
|
|
<ConfirmDialog
|
|
trigger={
|
|
<Button variant="destructive" size={size}>
|
|
<Ban />
|
|
取消任务
|
|
</Button>
|
|
}
|
|
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 (
|
|
<ConfirmDialog
|
|
trigger={
|
|
<Button variant="outline" size={size} disabled={retry.isPending}>
|
|
<Repeat />
|
|
重跑
|
|
</Button>
|
|
}
|
|
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 (
|
|
<section>
|
|
<button
|
|
type="button"
|
|
aria-expanded={open}
|
|
onClick={() => setOpen((v) => !v)}
|
|
className="flex w-full cursor-pointer items-center gap-2 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase hover:text-foreground"
|
|
>
|
|
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform", !open && "-rotate-90")} />
|
|
{title}
|
|
</button>
|
|
{open ? <div className="mt-3 space-y-4">{children}</div> : null}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/** 详情主体(不含外框);抽屉和独立页都用 */
|
|
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 (
|
|
<>
|
|
<DetailSection title="业务摘要">
|
|
<KvList
|
|
items={[
|
|
{
|
|
label: "关联对象",
|
|
value: b ? (
|
|
link ? (
|
|
<Link to={link.to} className="inline-flex items-center gap-1 underline underline-offset-4">
|
|
{BUSINESS_KIND_LABEL[b.kind]} · {b.title ?? b.refId}
|
|
<ExternalLink className="h-3 w-3" />
|
|
</Link>
|
|
) : (
|
|
`${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)) },
|
|
]}
|
|
/>
|
|
</DetailSection>
|
|
|
|
<DetailSection title="时间线">
|
|
<Timeline items={businessTimeline(r)} />
|
|
</DetailSection>
|
|
|
|
<DetailSection title="相关审计">
|
|
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-caption">
|
|
{audits.map((a) => (
|
|
<li key={a.to}>
|
|
<Link
|
|
to={a.to}
|
|
className="inline-flex items-center gap-1 text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
|
>
|
|
<ScrollText className="h-3.5 w-3.5" />
|
|
{a.label}
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</DetailSection>
|
|
|
|
<Collapsible title={`引擎步骤(${r.stepAttempts.length})`}>
|
|
<KvList
|
|
items={[
|
|
{ label: "工作流", value: `${r.workflowName}${r.version ? ` ${r.version}` : ""}`, mono: true },
|
|
{ label: "运行 ID", value: r.id, mono: true },
|
|
{ label: "尝试次数", value: r.attempts },
|
|
{ label: "Worker", value: r.workerId, mono: true },
|
|
{ label: "可执行于", value: formatDateTime(r.availableAt) },
|
|
{ label: "截止", value: formatDateTime(r.deadlineAt) },
|
|
{ label: "父步骤", value: r.parentStepAttemptId, mono: true },
|
|
]}
|
|
/>
|
|
{r.stepAttempts.length === 0 ? (
|
|
<EmptyState plain title="还没有步骤" className="py-6" />
|
|
) : (
|
|
<Timeline items={stepItems(r.stepAttempts, onOpenRun)} />
|
|
)}
|
|
{hasError ? <JsonView label="错误" value={r.error} tone="destructive" /> : null}
|
|
<JsonView label="输入" value={r.input} defaultOpen={false} />
|
|
{!hasError ? <JsonView label="输出" value={r.output} defaultOpen={false} /> : null}
|
|
{r.signals.length ? (
|
|
<div className="space-y-2">
|
|
<p className="text-meta text-muted-foreground">信号({r.signals.length})</p>
|
|
{r.signals.map((sg) => (
|
|
<div key={sg.id} className="min-w-0">
|
|
<p className="text-sm">
|
|
<code className="font-mono">{sg.signal}</code>
|
|
<span className="ml-2 text-meta text-muted-foreground">{formatDateTime(sg.createdAt)}</span>
|
|
</p>
|
|
<JsonView value={sg.data} maxHeight="10rem" className="mt-1" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</Collapsible>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** 头部一行:状态 + 业务类型 */
|
|
export function TaskStatusLine({ r }: { r: EngineRunDetail }) {
|
|
const s = runStatus(r.status);
|
|
return (
|
|
<span className="flex flex-wrap items-center gap-2">
|
|
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
|
<span>{workflowLabel(r.workflowName)}</span>
|
|
{r.business ? <span>· {BUSINESS_KIND_LABEL[r.business.kind]}</span> : null}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** 按 id 取数并渲染(抽屉用) */
|
|
export function TaskDetail({ runId, onOpenRun }: { runId: string; onOpenRun?: (id: string) => void }) {
|
|
const q = useEngineRun(runId);
|
|
if (q.isPending) return <LoadingState rows={6} />;
|
|
if (q.isError) return <ErrorState plain error={q.error} onRetry={() => q.refetch()} />;
|
|
return <TaskDetailBody r={q.data} onOpenRun={onOpenRun} />;
|
|
}
|