Files
UHD_Media_Tool/apps/web/app/routes/console/engine.tsx
T
2026-09-28 02:09:39 +08:00

219 lines
7.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 { useQueryClient } from "@tanstack/react-query";
import type { EngineRun } from "@umt/contracts";
import { Ban, CheckCircle2, Clock, Cpu, LoaderCircle, RotateCw, XCircle } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { useEngineRuns, useEngineStats } from "~/api/engine.ts";
import { DataTable } from "~/components/console/data-table.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatTile } from "~/components/console/stat-tile.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { engineRunLabel, formatDuration, formatRelative, spanSec as runDurationSec } from "~/lib/labels.ts";
import { qk } from "~/lib/query-keys.ts";
import { useSse } from "~/lib/sse.ts";
export const meta = () => [{ title: "工作流引擎 · UHD 媒体自动化控制台" }];
const STAT_ORDER = [
{ status: "pending", label: "待执行", icon: Clock, tone: "neutral" },
{ status: "running", label: "运行中", icon: LoaderCircle, tone: "neutral" },
{ status: "completed", label: "完成", icon: CheckCircle2, tone: "success" },
{ status: "failed", label: "失败", icon: XCircle, tone: "destructive" },
{ status: "canceled", label: "已取消", icon: Ban, tone: "warning" },
] as const;
const STATUS_OPTIONS = ["pending", "running", "completed", "failed", "canceled", "sleeping", "succeeded"].map((s) => ({
value: s,
label: engineRunLabel(s).label,
}));
/** 持续任务事件到达时节流刷新引擎视图(引擎本身不发事件,业务任务事件足以代表它在动) */
const ENGINE_EVENTS = [
"task.created",
"task.status_changed",
"download.status_changed",
"organization.changed",
"agent.run_status",
];
function useDebounced<T>(value: T, ms = 350): T {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return v;
}
export default function EnginePage() {
const navigate = useNavigate();
const qc = useQueryClient();
const [params, setParams] = useSearchParams();
const status = params.get("status") ?? "";
const [name, setName] = useState(params.get("workflow") ?? "");
const workflowName = useDebounced(name.trim());
useEffect(() => {
setParams(
(p) => {
if (workflowName) p.set("workflow", workflowName);
else p.delete("workflow");
return p;
},
{ replace: true },
);
}, [workflowName, setParams]);
const setStatus = (s: string) =>
setParams(
(p) => {
if (s) p.set("status", s);
else p.delete("status");
return p;
},
{ replace: true },
);
const runs = useEngineRuns({ status: status || undefined, workflowName: workflowName || undefined });
const stats = useEngineStats();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useSse(ENGINE_EVENTS, () => {
if (timer.current) return;
timer.current = setTimeout(() => {
timer.current = null;
qc.invalidateQueries({ queryKey: qk.all("engine") });
}, 2000);
});
const rows = runs.data?.pages.flatMap((p) => p.items);
const s = stats.data;
return (
<>
<PageHeader
title="工作流引擎"
description="引擎的运行记录,只读。业务进度看「下载与任务」。"
actions={
<Button
variant="outline"
size="sm"
onClick={() => qc.invalidateQueries({ queryKey: qk.all("engine") })}
disabled={runs.isFetching}
>
<RotateCw className={runs.isFetching ? "animate-spin" : undefined} />
刷新
</Button>
}
/>
<div className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{STAT_ORDER.map((t) => (
<StatTile
key={t.status}
label={t.label}
icon={t.icon}
tone={(s?.byStatus[t.status] ?? 0) > 0 ? t.tone : "neutral"}
value={stats.isPending ? "…" : stats.isError ? "—" : (s?.byStatus[t.status] ?? 0)}
active={status === t.status}
onClick={() => setStatus(status === t.status ? "" : t.status)}
/>
))}
</div>
<div className="mb-5 flex flex-wrap gap-x-5 gap-y-1 text-meta text-muted-foreground">
<span>
最老待执行:
{s?.oldestPending ? (
<button
type="button"
className="cursor-pointer text-foreground underline-offset-2 hover:underline"
onClick={() => navigate(consolePath("engine", s.oldestPending?.id ?? ""))}
>
{s.oldestPending.workflowName} · 已等 {formatDuration(s.oldestPending.ageSec)}
</button>
) : (
"无"
)}
</span>
<span className="inline-flex items-center gap-1.5">
<Cpu className="h-3.5 w-3.5" />
持有租约的 worker:
{s && s.activeWorkers.length > 0
? s.activeWorkers.map((w) => `${w.workerId}(${w.running})`).join("、")
: "无"}
<span className="text-muted-foreground/80">(快照)</span>
</span>
</div>
<FilterBar
search={{ value: name, onChange: setName, placeholder: "按工作流名称筛选" }}
onReset={
status || name
? () => {
setName("");
setStatus("");
}
: undefined
}
>
<FilterSelect label="状态" value={status} onChange={setStatus} options={STATUS_OPTIONS} />
</FilterBar>
<DataTable<EngineRun>
rows={rows}
rowKey={(r) => r.id}
loading={runs.isPending}
error={runs.error}
onRetry={() => runs.refetch()}
empty={{
title: status || workflowName ? "没有符合条件的运行" : "引擎还没有运行记录",
description: status || workflowName ? undefined : "任务入队后出现在这里。",
}}
onRowClick={(r) => navigate(consolePath("engine", r.id))}
hasMore={!!runs.hasNextPage}
onLoadMore={() => runs.fetchNextPage()}
loadingMore={runs.isFetchingNextPage}
columns={[
{
key: "status",
header: "状态",
cell: (r) => {
const l = engineRunLabel(r.status);
return <StatusBadge tone={l.tone}>{l.label}</StatusBadge>;
},
},
{
key: "name",
header: "工作流",
cell: (r) => (
<div className="min-w-0 max-w-[16rem] sm:max-w-none">
<p className="truncate font-medium">
{r.workflowName}
{r.version ? <span className="ml-1.5 text-meta text-muted-foreground">{r.version}</span> : null}
</p>
<p className="truncate font-mono text-meta text-muted-foreground">{r.id}</p>
</div>
),
},
{ key: "attempts", header: "尝试", hideBelow: "md", align: "right", cell: (r) => r.attempts },
{
key: "dur",
header: "耗时",
hideBelow: "sm",
cell: (r) => <span className="tabular-nums">{formatDuration(runDurationSec(r))}</span>,
},
{
key: "created",
header: "创建",
hideBelow: "md",
cell: (r) => <span className="whitespace-nowrap text-muted-foreground">{formatRelative(r.createdAt)}</span>,
},
]}
/>
</>
);
}