259 lines
9.6 KiB
TypeScript
259 lines
9.6 KiB
TypeScript
import type { EngineStepAttempt } from "@umt/contracts";
|
|
import { ArrowLeft, Ban, Repeat, RotateCw } from "lucide-react";
|
|
import { Link, useNavigate, useParams } 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 { consolePath } from "~/components/console/nav.ts";
|
|
import { PageHeader } from "~/components/console/page-header.tsx";
|
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
|
import { Timeline, type TimelineItem } from "~/components/console/timeline.tsx";
|
|
import { Button, buttonVariants } from "~/components/ui/button.tsx";
|
|
import { errorMessage } from "~/lib/api.ts";
|
|
import { engineRunLabel, formatDuration, formatRelative, spanSec as runDurationSec } from "~/lib/labels.ts";
|
|
import { formatDateTime } from "~/lib/utils.ts";
|
|
|
|
export const meta = () => [{ title: "运行详情 · 工作流引擎" }];
|
|
|
|
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;
|
|
}
|
|
|
|
function stepItems(steps: EngineStepAttempt[]): 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 status =
|
|
st.status === "running"
|
|
? "运行中"
|
|
: st.status === "failed"
|
|
? "失败"
|
|
: st.status === "completed" || st.status === "succeeded"
|
|
? "完成"
|
|
: st.status;
|
|
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} · {status}
|
|
{n > 1 ? ` · 第 ${n} 次` : ""}
|
|
</span>
|
|
</span>
|
|
),
|
|
meta: formatDuration(runDurationSec(st)),
|
|
description: (
|
|
<>
|
|
{st.waitSignal ? (
|
|
<p>
|
|
等待信号 <code className="font-mono">{st.waitSignal}</code>
|
|
{st.timeoutAt ? ` · 超时于 ${formatDateTime(st.timeoutAt)}` : ""}
|
|
</p>
|
|
) : null}
|
|
{st.childWorkflowRunId ? (
|
|
<p>
|
|
子工作流{" "}
|
|
<Link to={consolePath("engine", st.childWorkflowRunId)} className="font-mono underline">
|
|
{st.childWorkflowRunId}
|
|
</Link>
|
|
</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,
|
|
};
|
|
});
|
|
}
|
|
|
|
export default function EngineRunPage() {
|
|
const { runId } = useParams();
|
|
const q = useEngineRun(runId);
|
|
const cancel = useCancelEngineRun();
|
|
const retry = useRetryEngineRun();
|
|
const navigate = useNavigate();
|
|
|
|
const back = (
|
|
<Link
|
|
to={consolePath("engine")}
|
|
className={buttonVariants({ variant: "ghost", size: "sm", className: "-ml-2 mb-2" })}
|
|
>
|
|
<ArrowLeft />
|
|
返回列表
|
|
</Link>
|
|
);
|
|
|
|
if (q.isPending) {
|
|
return (
|
|
<>
|
|
{back}
|
|
<LoadingState rows={8} />
|
|
</>
|
|
);
|
|
}
|
|
if (q.isError) {
|
|
return (
|
|
<>
|
|
{back}
|
|
<ErrorState title="加载失败" error={q.error} onRetry={() => q.refetch()} />
|
|
</>
|
|
);
|
|
}
|
|
const r = q.data;
|
|
const st = engineRunLabel(r.status);
|
|
const terminal = ENGINE_TERMINAL.has(r.status);
|
|
const hasError = r.error !== null && r.error !== undefined;
|
|
|
|
return (
|
|
<>
|
|
{back}
|
|
<PageHeader
|
|
title={r.workflowName}
|
|
description={
|
|
<span className="flex flex-wrap items-center gap-2">
|
|
<StatusBadge tone={st.tone}>{st.label}</StatusBadge>
|
|
{r.version ? <span>{r.version}</span> : null}
|
|
<span className="font-mono text-meta break-all">{r.id}</span>
|
|
</span>
|
|
}
|
|
actions={
|
|
<>
|
|
<Button variant="ghost" size="sm" onClick={() => q.refetch()} disabled={q.isFetching}>
|
|
<RotateCw className={q.isFetching ? "animate-spin" : undefined} />
|
|
刷新
|
|
</Button>
|
|
{!terminal ? (
|
|
<ConfirmDialog
|
|
trigger={
|
|
<Button variant="destructive" size="sm">
|
|
<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;
|
|
}
|
|
}}
|
|
/>
|
|
) : (
|
|
<ConfirmDialog
|
|
trigger={
|
|
<Button variant="outline" size="sm" disabled={retry.isPending}>
|
|
<Repeat />
|
|
重跑
|
|
</Button>
|
|
}
|
|
title="重跑这次运行?"
|
|
description="用原始输入新建一次运行,原记录不变。已完成的文件操作不会重复执行。"
|
|
confirmLabel="重跑"
|
|
cancelLabel="不重跑"
|
|
onConfirm={async () => {
|
|
try {
|
|
const res = await retry.mutateAsync({ id: r.id });
|
|
toast.success(`已重新入队(第 ${res.attempt} 次重跑)`);
|
|
navigate(consolePath("engine", res.runId));
|
|
} catch (err) {
|
|
toast.error(errorMessage(err, "重跑失败"));
|
|
throw err;
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
}
|
|
/>
|
|
|
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
|
|
<div className="min-w-0 space-y-4">
|
|
<section className="pg-slab rounded-xl p-5">
|
|
<DetailSection title="概览">
|
|
<KvList
|
|
items={[
|
|
{
|
|
label: "开始",
|
|
value: r.startedAt ? `${formatDateTime(r.startedAt)}(${formatRelative(r.startedAt)})` : "未开始",
|
|
},
|
|
{ label: "耗时", value: formatDuration(runDurationSec(r)) },
|
|
{ label: "结束", value: formatDateTime(r.finishedAt) },
|
|
{ label: "尝试次数", value: r.attempts },
|
|
{ label: "可执行于", value: formatDateTime(r.availableAt) },
|
|
{ label: "截止", value: formatDateTime(r.deadlineAt) },
|
|
{ label: "Worker", value: r.workerId, mono: true },
|
|
{ label: "父步骤", value: r.parentStepAttemptId, mono: true },
|
|
]}
|
|
/>
|
|
</DetailSection>
|
|
</section>
|
|
<section className="pg-slab space-y-4 rounded-xl p-5">
|
|
{hasError ? <JsonView label="错误" value={r.error} tone="destructive" /> : null}
|
|
<JsonView label="输入" value={r.input} defaultOpen={!hasError} />
|
|
{!hasError ? <JsonView label="输出" value={r.output} /> : null}
|
|
</section>
|
|
<section className="pg-slab rounded-xl p-5">
|
|
<DetailSection title={`信号(${r.signals.length})`}>
|
|
{r.signals.length === 0 ? (
|
|
<p className="text-caption text-muted-foreground">没有信号。</p>
|
|
) : (
|
|
<ul className="space-y-3">
|
|
{r.signals.map((sg) => (
|
|
<li 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" />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</DetailSection>
|
|
</section>
|
|
</div>
|
|
<section className="pg-slab min-w-0 rounded-xl p-5">
|
|
<DetailSection title={`步骤尝试(${r.stepAttempts.length})`}>
|
|
{r.stepAttempts.length === 0 ? (
|
|
<EmptyState plain title="还没有步骤" className="py-8" />
|
|
) : (
|
|
<Timeline items={stepItems(r.stepAttempts)} />
|
|
)}
|
|
</DetailSection>
|
|
</section>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|