Files
UHD_Media_Tool/apps/web/app/components/tasks/task-detail.tsx
T

368 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { BUSINESS_OBJECT_TYPE, objectLogsPath } from "~/components/logs/labels.ts";
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<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 = 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 (
<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: objectLogsPath("engine_run", r.id), label: "本任务的操作记录" },
...(b && BUSINESS_OBJECT_TYPE[b.kind]
? [
{
to: objectLogsPath(BUSINESS_OBJECT_TYPE[b.kind]!, b.refId),
label: b.kind === "schedule" ? "定时任务的全部日志" : "关联对象的全部日志",
},
]
: []),
];
return (
<>
<DetailSection title="概况">
<KvList
items={[
{
label: "关联对象",
value: b ? (
link ? (
<Link to={link.to} className="inline-flex items-center gap-1 underline underline-offset-4">
{b.title ? `${BUSINESS_KIND_LABEL[b.kind]} · ${b.title}` : BUSINESS_KIND_LABEL[b.kind]}
<ExternalLink className="h-3 w-3" />
</Link>
) : (
`${BUSINESS_KIND_LABEL[b.kind]}${b.title ? ` · ${b.title}` : ""}`
)
) : (
"无"
),
wide: true,
},
{ label: "做什么", value: workflowLabel(r.workflowName) },
{ label: "来源", value: src ? SOURCE_LABEL[src] : "未知" },
{
label: "开始",
value: r.startedAt ? `${formatDateTime(r.startedAt)}(${formatRelative(r.startedAt)})` : "未开始",
},
]}
/>
</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: formatDuration(spanSec(r)) },
{ label: "关联对象 ID", value: b?.refId ?? null, mono: true },
{ 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 = engineRunStatus(r);
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} />;
}