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]>
This commit is contained in:
1 parent
ef73abe3df
commit
0dec0854df
21 files changed
+3905
-57
No files matched your search
@@ -1,6 +1,218 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
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";
|
||||
|
||||
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
|
||||
export default function Page() {
|
||||
return <WorkspacePlaceholder />;
|
||||
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>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user