- /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]>
219 lines
7.8 KiB
TypeScript
219 lines
7.8 KiB
TypeScript
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 ? "没有符合条件的 run" : "引擎还没有运行记录",
|
||
description: status || workflowName ? "换个筛选条件试试。" : "有任务入队后会出现在这里。",
|
||
}}
|
||
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>,
|
||
},
|
||
]}
|
||
/>
|
||
</>
|
||
);
|
||
}
|