Files
UHD_Media_Tool/apps/web/app/routes/console/engine.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

219 lines
7.8 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 ? "没有符合条件的 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>,
},
]}
/>
</>
);
}