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,233 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
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";
|
||||
|
||||
// 占位:路由已在 app/routes.ts 登记,由对应工作区负责实现
|
||||
export default function Page() {
|
||||
return <WorkspacePlaceholder />;
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user