Files
UHD_Media_Tool/apps/web/app/routes/console/engine.$runId.tsx
T
rootandClaude Opus 5.5 0dec0854df feat(web): W3-1 login, session shell, home dashboard, settings, engine panel and audit pages
- /login: setup/login/TOTP states driven by server error codes, fixed-height error slot
- console layout: GET /auth/session gate, redirect to /login, account popover (change password, logout)
- home: six dashboard sections with SSE-driven refresh, automation switch and read-only reconcile toggle
- settings tabs: downloaders, media servers, library roots, path mappings, site credentials,
  API keys (one-time plaintext), Pi upstream, cache/organize/retention/budget, automation
- engine: runs list (status/name filters, cursor paging, stats tiles), run detail (step timeline,
  json payloads, signals, cancel)
- audit: filterable list with detail sheet
- vite: allow repo root in server.fs so deno-installed deps under node_modules/.deno load in dev

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 20:40:41 +08:00

234 lines
8.7 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 { EngineStepAttempt } from "@umt/contracts";
import { ArrowLeft, Ban, RotateCw } from "lucide-react";
import { Link, useParams } from "react-router";
import { toast } from "sonner";
import { ENGINE_TERMINAL, useCancelEngineRun, useEngineRun } 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: "Run 详情 · 工作流引擎" }];
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 back = (
<Link
to={consolePath("engine")}
className={buttonVariants({ variant: "ghost", size: "sm", className: "-ml-2 mb-2" })}
>
<ArrowLeft />
返回 runs
</Link>
);
if (q.isPending) {
return (
<>
{back}
<LoadingState rows={8} />
</>
);
}
if (q.isError) {
return (
<>
{back}
<ErrorState title="加载 run 失败" 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 />
取消 run
</Button>
}
title="取消这个 run?"
description="先标记业务侧取消,再通知引擎停止;正在执行的步骤会在下一个检查点退出。"
confirmLabel="取消 run"
cancelLabel="不取消"
destructive
onConfirm={async () => {
try {
const res = await cancel.mutateAsync({ id: r.id, reason: "控制台手动取消" });
if (res.canceled) toast.success("已取消");
else toast.warning("run 已结束,无需取消");
} catch (err) {
toast.error(errorMessage(err, "取消失败"));
throw err;
}
}}
/>
) : null}
</>
}
/>
<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">没有投递到这个 run 的信号。</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>
</>
);
}