diff --git a/apps/web/app/api/engine.ts b/apps/web/app/api/engine.ts index 02d16e75..b0cf33b6 100644 --- a/apps/web/app/api/engine.ts +++ b/apps/web/app/api/engine.ts @@ -1,15 +1,24 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { CursorPage, EngineRun, EngineRunDetail, EngineStats } from "@umt/contracts"; +import type { CursorPage, EngineBusinessKind, EngineRun, EngineRunDetail, EngineStats } from "@umt/contracts"; import { apiData } from "~/lib/api.ts"; import { qk } from "~/lib/query-keys.ts"; +import { errorCode } from "./parse.ts"; -export type EngineRunFilters = { status?: string; workflowName?: string }; +export type EngineRunFilters = { + status?: string; + workflowName?: string; + businessKind?: EngineBusinessKind; + businessRefId?: string; + /** created_at 下界(ISO) */ + from?: string; +}; /** 终态:不能再 cancel */ export const ENGINE_TERMINAL = new Set(["completed", "succeeded", "failed", "canceled"]); -export function useEngineRuns(filters: EngineRunFilters) { +export function useEngineRuns(filters: EngineRunFilters, enabled = true) { return useInfiniteQuery({ + enabled, queryKey: qk.list("engine", filters), initialPageParam: undefined as string | undefined, queryFn: ({ pageParam, signal }) => @@ -18,11 +27,28 @@ export function useEngineRuns(filters: EngineRunFilters) { }); } +/** 只取第一页(首页区块、进行中合并用) */ +export function useEngineRunsPage( + filters: EngineRunFilters, + limit = 20, + refetchMs: number | false = 30_000, + enabled = true, +) { + return useQuery({ + enabled, + queryKey: qk.list("engine", { ...filters, limit, page: 1 }), + queryFn: ({ signal }) => apiData>("/engine/runs", { params: { ...filters, limit }, signal }), + refetchInterval: refetchMs, + }); +} + export function useEngineRun(id: string | undefined) { return useQuery({ queryKey: qk.detail("engine", id ?? ""), queryFn: ({ signal }) => apiData("/engine/runs/{id}", { pathParams: { id: id ?? "" }, signal }), enabled: !!id, + // 不存在的 id(旧链接探测)不重试 + retry: (count, err) => errorCode(err) !== "not_found" && count < 2, // 非终态每 5 秒兜底刷新(SSE 事件驱动是主路径) refetchInterval: (q) => (q.state.data && !ENGINE_TERMINAL.has(q.state.data.status) ? 5_000 : false), }); diff --git a/apps/web/app/components/downloads/business-tasks.tsx b/apps/web/app/components/downloads/business-tasks.tsx index 333573ab..28791665 100644 --- a/apps/web/app/components/downloads/business-tasks.tsx +++ b/apps/web/app/components/downloads/business-tasks.tsx @@ -328,7 +328,7 @@ export function TaskSummary({ t }: { t: Task }) { { label: "引擎运行", value: t.engineRunId ? ( - + {t.engineRunId.slice(0, 12)} ) : null, diff --git a/apps/web/app/components/schedules/schedule-board.tsx b/apps/web/app/components/schedules/schedule-board.tsx new file mode 100644 index 00000000..f17c67ca --- /dev/null +++ b/apps/web/app/components/schedules/schedule-board.tsx @@ -0,0 +1,145 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { SCHEDULE_CHANGED_EVENT, type ScheduledJob, type ScheduleGroup } from "@umt/contracts"; +import { AlertTriangle, CalendarClock, CircleCheck, LoaderCircle, Plus, XCircle } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router"; +import { useScheduleBoard } from "~/api/schedules.ts"; +import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; +import { StatTile } from "~/components/console/stat-tile.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { qk } from "~/lib/query-keys.ts"; +import { useSse } from "~/lib/sse.ts"; +import { AgentCheckDialog } from "./agent-check-dialog.tsx"; +import { GROUP_LABEL, GROUP_ORDER } from "./labels.ts"; +import { ScheduleCard } from "./schedule-card.tsx"; + +// 定时任务看板(W7-C),IA-3 起是任务页的"计划"页签。卡片"运行记录"跳到任务流按该定时任务筛选。 + +/** 每秒一跳的当前时间(倒计时用) */ +export function useNow(ms = 1000) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), ms); + return () => clearInterval(t); + }, [ms]); + return now; +} + +/** SSE:调度器每次开始/结束一次运行都会发 schedule.changed;1 秒节流 */ +export function useLiveSchedules() { + const qc = useQueryClient(); + const timer = useRef | null>(null); + useSse([SCHEDULE_CHANGED_EVENT], () => { + if (timer.current) return; + timer.current = setTimeout(() => { + timer.current = null; + qc.invalidateQueries({ queryKey: qk.all("schedules") }); + }, 1000); + }); + useEffect( + () => () => { + if (timer.current) clearTimeout(timer.current); + }, + [], + ); + return useScheduleBoard(); +} + +/** 任务流里某个定时任务的筛选地址 */ +export const scheduleStreamPath = (id: string) => `/console/tasks?tab=all&kind=schedule&ref=${encodeURIComponent(id)}`; + +export function ScheduleBoard({ highlightJob }: { highlightJob?: string }) { + const _qc = useQueryClient(); + const navigate = useNavigate(); + const board = useLiveSchedules(); + const now = useNow(); + const [editing, setEditing] = useState<{ job: ScheduledJob | null; key: number } | null>(null); + + const data = board.data; + const items = data?.items ?? []; + const byGroup = new Map(); + for (const g of GROUP_ORDER) byGroup.set(g, []); + for (const j of items) byGroup.get(j.group)?.push(j); + const stats = data?.stats; + const val = (n: number | undefined) => (board.isPending ? "…" : board.isError ? "—" : (n ?? 0)); + + return ( + <> +
+

周期任务与 Agent 巡检的开关、间隔和下次运行。

+
+ +
+
+ +
+ + 0 ? "destructive" : "neutral"} + value={val(stats?.failed)} + /> + + +
+ + {data && !data.schedulerOnline ? ( +

+ + 调度器未在线,任务不会按时运行。 +

+ ) : ( +
+ )} + + {board.isPending ? ( + + ) : board.isError ? ( + board.refetch()} /> + ) : items.length === 0 ? ( + + ) : ( +
+ {GROUP_ORDER.map((g) => { + const jobs = byGroup.get(g) ?? []; + return ( +
+

+ {GROUP_LABEL[g]} + {jobs.length} +

+ {jobs.length === 0 ? ( +

+ {g === "agent" ? "还没有巡检。" : "还没有定时任务。"} +

+ ) : ( + jobs.map((j) => ( + navigate(scheduleStreamPath(job.id))} + onEdit={(job) => setEditing({ job, key: Date.now() })} + /> + )) + )} +
+ ); + })} +
+ )} + + {editing ? ( + !o && setEditing(null)} /> + ) : null} + + ); +} diff --git a/apps/web/app/components/schedules/schedule-card.tsx b/apps/web/app/components/schedules/schedule-card.tsx index 1bb40d94..18b85e82 100644 --- a/apps/web/app/components/schedules/schedule-card.tsx +++ b/apps/web/app/components/schedules/schedule-card.tsx @@ -1,5 +1,6 @@ import type { AgentCheckConfig, ScheduledJob, ScheduleSpecInput } from "@umt/contracts"; import { History, LoaderCircle, Pencil, Play, RotateCcw, SkipForward, Trash2 } from "lucide-react"; +import { useEffect, useRef } from "react"; import { toast } from "sonner"; import { useDeleteSchedule, useRunScheduleNow, useSkipSchedule, useUpdateSchedule } from "~/api/schedules.ts"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; @@ -8,6 +9,7 @@ import { Button } from "~/components/ui/button.tsx"; import { Switch } from "~/components/ui/switch.tsx"; import { errorMessage } from "~/lib/api.ts"; import { formatRelative } from "~/lib/labels.ts"; +import { cn } from "~/lib/utils.ts"; import { IntervalEditor } from "./interval-editor.tsx"; import { CHECK_TRIGGER_LABEL, countdown, formatMs, RUN_STATUS } from "./labels.ts"; @@ -54,16 +56,23 @@ export function ScheduleCard({ now, onHistory, onEdit, + highlight = false, }: { job: ScheduledJob; now: number; onHistory: (job: ScheduledJob) => void; onEdit: (job: ScheduledJob) => void; + /** 从任务流跳来时高亮并滚到可见 */ + highlight?: boolean; }) { const update = useUpdateSchedule(); const runNow = useRunScheduleNow(); const skip = useSkipSchedule(); const del = useDeleteSchedule(); + const ref = useRef(null); + useEffect(() => { + if (highlight) ref.current?.scrollIntoView({ block: "center" }); + }, [highlight]); const busy = !!job.runningSince || !!job.runRequestedAt; const last = job.lastStatus ? RUN_STATUS[job.lastStatus] : null; @@ -74,7 +83,15 @@ export function ScheduleCard({ ); return ( -
+

diff --git a/apps/web/app/components/tasks/labels.ts b/apps/web/app/components/tasks/labels.ts new file mode 100644 index 00000000..f9b9b65d --- /dev/null +++ b/apps/web/app/components/tasks/labels.ts @@ -0,0 +1,111 @@ +import type { EngineBusinessKind, EngineRun, EngineTriggeredBy } from "@umt/contracts"; +import { consolePath } from "~/components/console/nav.ts"; +import type { StatusTone } from "~/components/console/status-badge.tsx"; + +// 统一任务流的标签:引擎 run + business(W9-I)。一条 run = 一条任务。 + +export const BUSINESS_KIND_LABEL: Record = { + subscription: "订阅", + download: "下载", + organization: "整理", + agent: "Agent", + schedule: "定时任务", + feed: "Feed", + site: "站点", + decision: "待处理事项", + media: "作品", +}; +export const BUSINESS_KINDS = Object.keys(BUSINESS_KIND_LABEL) as EngineBusinessKind[]; + +/** 来源:四类(定时/事件/手动/Agent);API Key 调用算手动 */ +export type TaskSource = "schedule" | "event" | "manual" | "agent"; +export const SOURCE_LABEL: Record = { + schedule: "定时", + event: "事件", + manual: "手动", + agent: "Agent", +}; +export function sourceOf(t: EngineTriggeredBy | null | undefined): TaskSource | null { + if (!t) return null; + if (t === "user" || t === "api_key") return "manual"; + return t; +} + +/** 引擎工作流名 → 做什么 */ +export const WORKFLOW_LABEL: Record = { + ingestFeed: "抓取 Feed", + resolveObservation: "识别资源", + evaluateAcquisition: "评估候选", + submitDownload: "提交下载", + verifyAndImport: "核验入库", + refreshMetadata: "刷新元数据", + reconcileLibrary: "资产对账", + cleanupDownloadCache: "清理缓存", + resolveDecision: "处理待处理事项", + runAgent: "Agent 会话", + snapshotSite: "站点快照", + refreshSiteAccount: "刷新站点账号", + searchSubscription: "订阅搜索", + purgeTrash: "清理回收站", + refreshPiTokens: "刷新上游登录", +}; +export const workflowLabel = (name: string) => WORKFLOW_LABEL[name] ?? name; + +/** 引擎状态 → 任务状态 */ +const RUN_STATUS: Record = { + pending: { label: "排队", tone: "neutral" }, + running: { label: "进行中", tone: "neutral" }, + sleeping: { label: "等待", tone: "neutral" }, + completed: { label: "完成", tone: "success" }, + succeeded: { label: "完成", tone: "success" }, + failed: { label: "失败", tone: "destructive" }, + canceled: { label: "已取消", tone: "warning" }, +}; +export const runStatus = (s: string) => RUN_STATUS[s] ?? { label: s, tone: "neutral" as StatusTone }; +export const RUN_STATUS_OPTIONS = ["pending", "running", "completed", "failed", "canceled"].map((value) => ({ + value, + label: runStatus(value).label, +})); + +/** 行标题:业务对象名 > 业务类型 + 短 id > 工作流做什么 */ +export function runTitle(r: Pick): string { + const b = r.business; + if (b?.title) return b.title; + if (b) return `${BUSINESS_KIND_LABEL[b.kind]} ${b.refId.length > 12 ? b.refId.slice(0, 8) : b.refId}`; + return workflowLabel(r.workflowName); +} + +/** 业务对象的"家"页面 */ +export function businessLink(b: EngineRun["business"], input?: unknown): { to: string; label: string } | null { + if (!b) return null; + const id = encodeURIComponent(b.refId); + switch (b.kind) { + case "subscription": + return { to: consolePath("subscriptions"), label: "订阅" }; + case "download": { + // 计划还没提交时 refId 是下载计划 + const i = (input ?? {}) as { downloadTaskId?: unknown; acquisitionPlanId?: unknown }; + if (!i.downloadTaskId && i.acquisitionPlanId === b.refId) + return { to: consolePath("downloads", "plans"), label: "下载计划" }; + return { to: `/console/downloads/${id}`, label: "下载任务" }; + } + case "organization": + return { to: `/console/organize/${id}`, label: "整理记录" }; + case "agent": + return { to: `/console/agent/${id}`, label: "Agent 会话" }; + case "schedule": + return { to: `/console/tasks?tab=plan&job=${id}`, label: "定时任务" }; + case "feed": + return { to: consolePath("sources"), label: "Feed" }; + case "site": + return { to: `/console/sites/${id}`, label: "站点" }; + case "decision": + return { to: consolePath("inbox"), label: "待处理" }; + case "media": + return { to: `/console/library/${id}`, label: "作品" }; + default: + return null; + } +} + +export const ENGINE_ACTIVE = new Set(["pending", "running", "sleeping"]); diff --git a/apps/web/app/components/tasks/task-detail.tsx b/apps/web/app/components/tasks/task-detail.tsx new file mode 100644 index 00000000..65c85f42 --- /dev/null +++ b/apps/web/app/components/tasks/task-detail.tsx @@ -0,0 +1,351 @@ +import type { EngineRunDetail, EngineStepAttempt } from "@umt/contracts"; +import { Ban, ChevronDown, ExternalLink, Repeat, ScrollText } from "lucide-react"; +import { type ReactNode, useState } from "react"; +import { Link } from "react-router"; +import { toast } from "sonner"; +import { ENGINE_TERMINAL, useCancelEngineRun, useEngineRun, useRetryEngineRun } 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 { StatusBadge } from "~/components/console/status-badge.tsx"; +import { Timeline, type TimelineItem } from "~/components/console/timeline.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { formatDuration, formatRelative, spanSec } from "~/lib/labels.ts"; +import { cn, formatDateTime } from "~/lib/utils.ts"; +import { BUSINESS_KIND_LABEL, businessLink, runStatus, SOURCE_LABEL, sourceOf, workflowLabel } from "./labels.ts"; + +// 任务详情:业务摘要 → 时间线 → 引擎步骤(折叠)→ 操作。任务页抽屉与 /tasks/:id 共用。 + +const KIND_LABEL: Record = { + 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; +} + +/** 错误对象里的一句话 */ +export function errorText(e: unknown): string | null { + if (e === null || e === undefined) return null; + if (typeof e === "string") return e; + const m = (e as { message?: unknown }).message; + return typeof m === "string" ? m : JSON.stringify(e).slice(0, 300); +} + +/** 引擎步骤尝试(原引擎面板详情) */ +function stepItems(steps: EngineStepAttempt[], onOpenRun?: (id: string) => void): TimelineItem[] { + const seen = new Map(); + return steps.map((st) => { + const n = (seen.get(st.stepName) ?? 0) + 1; + seen.set(st.stepName, n); + const hasError = st.error !== null && st.error !== undefined; + return { + id: st.id, + at: st.startedAt ?? st.createdAt, + tone: stepTone(st.status), + title: ( + + {st.stepName} + + {KIND_LABEL[st.kind] ?? st.kind} · {runStatus(st.status).label} + {n > 1 ? ` · 第 ${n} 次` : ""} + + + ), + meta: formatDuration(spanSec(st)), + description: ( + <> + {st.waitSignal ? ( +

+ 等待信号 {st.waitSignal} + {st.timeoutAt ? ` · 超时于 ${formatDateTime(st.timeoutAt)}` : ""} +

+ ) : null} + {st.childWorkflowRunId ? ( +

+ 子任务{" "} + +

+ ) : null} +

{st.id}

+ + ), + children: hasError ? ( + + ) : st.output !== null && st.output !== undefined ? ( + + ) : undefined, + }; + }); +} + +/** 业务时间线:创建 → 开始 → 等待 / 失败的步骤 → 结束 */ +function businessTimeline(r: EngineRunDetail): TimelineItem[] { + const src = sourceOf(r.business?.triggeredBy); + const items: TimelineItem[] = [ + { id: "created", at: r.createdAt, title: `创建${src ? `(${SOURCE_LABEL[src]}触发)` : ""}`, tone: "neutral" }, + ]; + if (r.startedAt) + items.push({ + id: "started", + at: r.startedAt, + title: r.attempts > 1 ? `开始执行 · 第 ${r.attempts} 次尝试` : "开始执行", + tone: "neutral", + }); + for (const st of r.stepAttempts) { + if (st.waitSignal && !st.finishedAt) + items.push({ id: `w-${st.id}`, at: st.startedAt ?? st.createdAt, title: `等待:${st.stepName}`, tone: "warning" }); + else if (st.status === "failed") + items.push({ + id: `f-${st.id}`, + at: st.finishedAt ?? st.startedAt ?? st.createdAt, + title: `步骤失败:${st.stepName}`, + description: errorText(st.error), + tone: "destructive", + }); + } + if (r.finishedAt) { + const s = runStatus(r.status); + items.push({ + id: "finished", + at: r.finishedAt, + title: s.label, + // 与最后一个失败步骤相同的错误不再重复 + description: + r.status === "failed" && !r.stepAttempts.some((st) => errorText(st.error) === errorText(r.error)) + ? errorText(r.error) + : undefined, + tone: s.tone, + meta: formatDuration(spanSec(r)), + }); + } else if (r.startedAt) { + items.push({ id: "now", title: "进行中", tone: "neutral", meta: `已 ${formatDuration(spanSec(r))}` }); + } + return items; +} + +/** 取消 / 重跑 */ +export function RunActions({ + r, + onOpenRun, + size = "sm", +}: { + r: EngineRunDetail; + onOpenRun?: (id: string) => void; + size?: "sm" | "xs"; +}) { + const cancel = useCancelEngineRun(); + const retry = useRetryEngineRun(); + if (!ENGINE_TERMINAL.has(r.status)) + return ( + + + 取消任务 + + } + title="取消这个任务?" + description="正在执行的步骤会在下一个检查点退出,已完成的步骤不回滚。" + confirmLabel="取消任务" + cancelLabel="不取消" + destructive + onConfirm={async () => { + try { + const res = await cancel.mutateAsync({ id: r.id, reason: "控制台手动取消" }); + if (res.canceled) toast.success("已取消"); + else toast.warning("任务已结束"); + } catch (err) { + toast.error(errorMessage(err, "取消失败")); + throw err; + } + }} + /> + ); + return ( + + + 重跑 + + } + title="重跑这个任务?" + description="用原始输入新建一条任务,原记录不变。已完成的文件操作不会重复执行。" + confirmLabel="重跑" + cancelLabel="不重跑" + onConfirm={async () => { + try { + const res = await retry.mutateAsync({ id: r.id }); + toast.success(`已重新排队(第 ${res.attempt} 次重跑)`); + onOpenRun?.(res.runId); + } catch (err) { + toast.error(errorMessage(err, "重跑失败")); + throw err; + } + }} + /> + ); +} + +function Collapsible({ title, children }: { title: string; children: ReactNode }) { + const [open, setOpen] = useState(false); + return ( +
+ + {open ?
{children}
: null} +
+ ); +} + +/** 详情主体(不含外框);抽屉和独立页都用 */ +export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun?: (id: string) => void }) { + const b = r.business; + const link = businessLink(b, r.input); + const src = sourceOf(b?.triggeredBy); + const hasError = r.error !== null && r.error !== undefined; + const audits = [ + { to: `/console/audit?targetType=engine_run&targetId=${encodeURIComponent(r.id)}`, label: "本任务的操作记录" }, + ...(b?.kind === "schedule" + ? [ + { + to: `/console/audit?targetType=scheduled_job&targetId=${encodeURIComponent(b.refId)}`, + label: "定时任务的修改记录", + }, + ] + : []), + ]; + + return ( + <> + + + {BUSINESS_KIND_LABEL[b.kind]} · {b.title ?? b.refId} + + + ) : ( + `${BUSINESS_KIND_LABEL[b.kind]} · ${b.title ?? b.refId}` + ) + ) : ( + "无" + ), + wide: true, + }, + { label: "做什么", value: workflowLabel(r.workflowName) }, + { label: "来源", value: src ? SOURCE_LABEL[src] : "未知" }, + { + label: "开始", + value: r.startedAt ? `${formatDateTime(r.startedAt)}(${formatRelative(r.startedAt)})` : "未开始", + }, + { label: "耗时", value: formatDuration(spanSec(r)) }, + ]} + /> + + + + + + + +
    + {audits.map((a) => ( +
  • + + + {a.label} + +
  • + ))} +
+
+ + + + {r.stepAttempts.length === 0 ? ( + + ) : ( + + )} + {hasError ? : null} + + {!hasError ? : null} + {r.signals.length ? ( +
+

信号({r.signals.length})

+ {r.signals.map((sg) => ( +
+

+ {sg.signal} + {formatDateTime(sg.createdAt)} +

+ +
+ ))} +
+ ) : null} +
+ + ); +} + +/** 头部一行:状态 + 业务类型 */ +export function TaskStatusLine({ r }: { r: EngineRunDetail }) { + const s = runStatus(r.status); + return ( + + {s.label} + {workflowLabel(r.workflowName)} + {r.business ? · {BUSINESS_KIND_LABEL[r.business.kind]} : null} + + ); +} + +/** 按 id 取数并渲染(抽屉用) */ +export function TaskDetail({ runId, onOpenRun }: { runId: string; onOpenRun?: (id: string) => void }) { + const q = useEngineRun(runId); + if (q.isPending) return ; + if (q.isError) return q.refetch()} />; + return ; +} diff --git a/apps/web/app/components/tasks/task-stream.tsx b/apps/web/app/components/tasks/task-stream.tsx new file mode 100644 index 00000000..27b5b9e0 --- /dev/null +++ b/apps/web/app/components/tasks/task-stream.tsx @@ -0,0 +1,300 @@ +import { useQueryClient } from "@tanstack/react-query"; +import type { EngineBusinessKind, EngineRun, ScheduledJob } from "@umt/contracts"; +import { CalendarClock, ExternalLink, History, ListTodo, X } from "lucide-react"; +import { useMemo, useRef, useState } from "react"; +import { Link } from "react-router"; +import { useEngineRuns, useEngineRunsPage } from "~/api/engine.ts"; +import { useScheduleBoard } from "~/api/schedules.ts"; +import { DataTable } from "~/components/console/data-table.tsx"; +import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { RunsSheet } from "~/components/schedules/runs-sheet.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { formatDuration, formatRelative, spanSec } from "~/lib/labels.ts"; +import { qk } from "~/lib/query-keys.ts"; +import { useSse } from "~/lib/sse.ts"; +import { + BUSINESS_KIND_LABEL, + BUSINESS_KINDS, + businessLink, + ENGINE_ACTIVE, + RUN_STATUS_OPTIONS, + runStatus, + runTitle, + SOURCE_LABEL, + sourceOf, + type TaskSource, + workflowLabel, +} from "./labels.ts"; + +export type StreamTab = "all" | "active" | "failed"; + +/** 引擎本身不发事件;业务事件足以代表任务在动,2 秒节流刷新 */ +const TASK_EVENTS = [ + "task.created", + "task.status_changed", + "download.status_changed", + "organization.changed", + "agent.run_status", + "schedule.changed", +]; + +export function useLiveTasks() { + const qc = useQueryClient(); + const timer = useRef | null>(null); + useSse(TASK_EVENTS, () => { + if (timer.current) return; + timer.current = setTimeout(() => { + timer.current = null; + qc.invalidateQueries({ queryKey: qk.all("engine") }); + }, 2000); + }); +} + +const SINCE: { value: string; label: string; ms: number }[] = [ + { value: "1h", label: "1 小时内", ms: 3600_000 }, + { value: "24h", label: "24 小时内", ms: 86400_000 }, + { value: "7d", label: "7 天内", ms: 7 * 86400_000 }, + { value: "30d", label: "30 天内", ms: 30 * 86400_000 }, +]; + +export type StreamFilters = { + status: string; + kind: string; + ref: string; + source: string; + since: string; + workflow: string; +}; + +function SourceBadge({ r }: { r: EngineRun }) { + const s = sourceOf(r.business?.triggeredBy); + return s ? {SOURCE_LABEL[s]} : —; +} + +/** 定时任务筛选时,页头给出定时任务自己的运行记录(含跳过的,引擎里没有) */ +function ScheduleBanner({ jobId, onClear }: { jobId: string; onClear: () => void }) { + const board = useScheduleBoard(); + const job = board.data?.items.find((j) => j.id === jobId) ?? null; + const [open, setOpen] = useState(null); + return ( +
+ + + 定时任务 {job?.name ?? jobId} 触发的任务 + + + + + + + setOpen(null)} /> +
+ ); +} + +export function TaskStream({ + tab, + filters, + setFilter, + resetFilters, + onOpen, + selectedId, +}: { + tab: StreamTab; + filters: StreamFilters; + setFilter: (k: keyof StreamFilters, v: string) => void; + resetFilters: () => void; + onOpen: (id: string) => void; + selectedId: string | null; +}) { + useLiveTasks(); + // 时间下界按分钟取整,查询 key 稳定 + const from = useMemo(() => { + const ms = SINCE.find((s) => s.value === filters.since)?.ms; + return ms ? new Date(Math.floor((Date.now() - ms) / 60_000) * 60_000).toISOString() : undefined; + }, [filters.since]); + const base = { + businessKind: (filters.kind || undefined) as EngineBusinessKind | undefined, + businessRefId: filters.ref || undefined, + workflowName: filters.workflow || undefined, + from, + }; + const list = useEngineRuns( + { + ...base, + status: tab === "failed" ? "failed" : filters.status || undefined, + }, + tab !== "active", + ); + // 进行中 = 进行中 + 排队(两次取第一页合并) + const running = useEngineRunsPage({ ...base, status: "running" }, 100, 10_000, tab === "active"); + const pending = useEngineRunsPage({ ...base, status: "pending" }, 100, 10_000, tab === "active"); + + const active = tab === "active"; + const loaded = active + ? running.data && pending.data + ? [...running.data.items, ...pending.data.items] + : undefined + : list.data?.pages.flatMap((p) => p.items); + const rows = filters.source ? loaded?.filter((r) => sourceOf(r.business?.triggeredBy) === filters.source) : loaded; + const filtered = Object.values(filters).some(Boolean); + + return ( + <> + {filters.kind === "schedule" && filters.ref ? ( + { + setFilter("ref", ""); + setFilter("kind", ""); + }} + /> + ) : filters.ref ? ( +
+ 只看对象 {filters.ref} + +
+ ) : null} + + + setFilter("source", v)} + options={(Object.keys(SOURCE_LABEL) as TaskSource[]).map((value) => ({ value, label: SOURCE_LABEL[value] }))} + /> + setFilter("kind", v)} + options={BUSINESS_KINDS.map((value) => ({ value, label: BUSINESS_KIND_LABEL[value] }))} + /> + {tab === "all" ? ( + setFilter("status", v)} + options={RUN_STATUS_OPTIONS} + /> + ) : null} + setFilter("since", v)} + options={SINCE} + allLabel="不限" + /> + {filters.workflow ? ( + + ) : null} + + + + rows={rows} + rowKey={(r) => r.id} + loading={active ? running.isPending || pending.isPending : list.isPending} + error={active ? (running.error ?? pending.error) : list.error} + onRetry={() => { + if (active) { + void running.refetch(); + void pending.refetch(); + } else void list.refetch(); + }} + onRowClick={(r) => onOpen(r.id)} + selectedKey={selectedId} + hasMore={!active && !!list.hasNextPage} + onLoadMore={() => list.fetchNextPage()} + loadingMore={list.isFetchingNextPage} + empty={{ + icon: ListTodo, + title: filtered + ? "没有符合条件的任务" + : tab === "active" + ? "没有进行中的任务" + : tab === "failed" + ? "没有失败的任务" + : "还没有任务", + description: filters.source && loaded?.length ? "来源筛选只作用于已加载的记录,可加载更多再看。" : undefined, + }} + columns={[ + { + key: "title", + header: "任务", + cell: (r) => ( +
+

{runTitle(r)}

+

+ {r.business + ? `${workflowLabel(r.workflowName)} · ${BUSINESS_KIND_LABEL[r.business.kind]}` + : `未关联业务对象 · ${r.id.slice(0, 8)}`} +

+
+ ), + }, + { key: "source", header: "来源", hideBelow: "md", cell: (r) => }, + { + key: "status", + header: "状态", + cell: (r) => { + const s = runStatus(r.status); + return {s.label}; + }, + }, + { + key: "time", + header: "开始 / 耗时", + hideBelow: "sm", + cell: (r) => ( +
+

+ {r.startedAt + ? formatRelative(r.startedAt) + : ENGINE_ACTIVE.has(r.status) + ? `排队 ${formatRelative(r.createdAt)}` + : `未开始 · ${formatRelative(r.createdAt)}`} +

+

{formatDuration(spanSec(r))}

+
+ ), + }, + { + key: "link", + header: "关联", + hideBelow: "lg", + cell: (r) => { + const l = businessLink(r.business); + return l ? ( + e.stopPropagation()} + className="inline-flex items-center gap-1 text-caption text-muted-foreground underline-offset-4 hover:text-foreground hover:underline" + > + {l.label} + + + ) : ( + — + ); + }, + }, + ]} + /> + + ); +} diff --git a/apps/web/app/routes/console/audit.tsx b/apps/web/app/routes/console/audit.tsx index e3892a3a..ae65c316 100644 --- a/apps/web/app/routes/console/audit.tsx +++ b/apps/web/app/routes/console/audit.tsx @@ -1,4 +1,5 @@ import { AuditAction, type AuditEvent } from "@umt/contracts"; +import { X } from "lucide-react"; import { useEffect, useState } from "react"; import { useSearchParams } from "react-router"; import { useAuditEvents } from "~/api/audit.ts"; @@ -8,10 +9,11 @@ import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; import { JsonView } from "~/components/console/json-view.tsx"; import { KvList } from "~/components/console/kv-list.tsx"; import { PageHeader } from "~/components/console/page-header.tsx"; +import { Button } from "~/components/ui/button.tsx"; import { ACTOR_TYPES, actorTypeLabel, formatRelative } from "~/lib/labels.ts"; import { formatDateTime } from "~/lib/utils.ts"; -export const meta = () => [{ title: "审计日志 · UHD 媒体自动化控制台" }]; +export const meta = () => [{ title: "审计 · UHD 媒体自动化控制台" }]; const actorLabel = actorTypeLabel; @@ -30,14 +32,14 @@ const ACTION_LABEL: Record = { "secret.rotate": "轮换凭证", "secret.delete": "删除凭证", "site_account.credentials_set": "录入站点凭证", - "engine.run_cancel": "取消引擎运行", + "engine.run_cancel": "取消任务", "pi_upstream.oauth_start": "开始订阅登录", "pi_upstream.oauth_login": "完成订阅登录", "pi_upstream.oauth_logout": "退出订阅登录", "schedule.run_now": "立即执行定时任务", "schedule.skip": "跳过定时任务", "acquisition_plan.manual_submit": "手动提交下载计划", - "engine.run_retry": "重跑引擎运行", + "engine.run_retry": "重跑任务", "capability.invoke": "Agent 调用能力", "site.created": "登记站点", "site.updated": "修改站点", @@ -88,6 +90,7 @@ export default function AuditPage() { const [params, setParams] = useSearchParams(); const actorType = params.get("actorType") ?? ""; const action = params.get("action") ?? ""; + const targetId = params.get("targetId") ?? ""; const [target, setTarget] = useState(params.get("targetType") ?? ""); const [targetType, setTargetType] = useState(target); const [selected, setSelected] = useState(null); @@ -122,13 +125,14 @@ export default function AuditPage() { actorType: actorType || undefined, action: action || undefined, targetType: targetType || undefined, + targetId: targetId || undefined, }); const rows = q.data?.pages.flatMap((p) => p.items); - const filtered = !!(actorType || action || targetType); + const filtered = !!(actorType || action || targetType || targetId); return ( <> - + setParam("action", v)} options={ACTION_OPTIONS} /> + {targetId ? ( + + ) : null} rows={rows} diff --git a/apps/web/app/routes/console/tasks.$id.tsx b/apps/web/app/routes/console/tasks.$id.tsx index 8bcc995e..ebdb47ca 100644 --- a/apps/web/app/routes/console/tasks.$id.tsx +++ b/apps/web/app/routes/console/tasks.$id.tsx @@ -1,8 +1,9 @@ import { useQuery } from "@tanstack/react-query"; import { AssetOperation, DownloadTask, Task } from "@umt/contracts"; import { ArrowLeft } from "lucide-react"; -import { Link, Navigate, useParams } from "react-router"; +import { Link, Navigate, useNavigate, useParams } from "react-router"; import { z } from "zod"; +import { useEngineRun } from "~/api/engine.ts"; import { apiParsed, isMissingEndpoint } from "~/api/parse.ts"; import { DetailSection } from "~/components/console/detail-sheet.tsx"; import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; @@ -10,11 +11,18 @@ import { consolePath } from "~/components/console/nav.ts"; import { PageHeader } from "~/components/console/page-header.tsx"; import type { StatusTone } from "~/components/console/status-badge.tsx"; import { Timeline } from "~/components/console/timeline.tsx"; -import { TASK_KIND_LABEL, TaskActions, TaskStatusLine, TaskSummary } from "~/components/downloads/business-tasks.tsx"; +import { + TASK_KIND_LABEL, + TaskActions, + TaskStatusLine as TaskStatusLine2, + TaskSummary, +} from "~/components/downloads/business-tasks.tsx"; +import { runTitle } from "~/components/tasks/labels.ts"; +import { RunActions, TaskDetailBody, TaskStatusLine } from "~/components/tasks/task-detail.tsx"; import { Button } from "~/components/ui/button.tsx"; import { TTL_SECOND } from "~/lib/api.ts"; -export const meta = () => [{ title: "业务任务 · UHD 媒体自动化控制台" }]; +export const meta = () => [{ title: "任务 · UHD 媒体自动化控制台" }]; const OP_TONE: Record = { planned: "neutral", @@ -59,8 +67,9 @@ const OP_KIND: Record = { other: "其他", }; -function useTask(id: string) { +function useTask(id: string, enabled: boolean) { return useQuery({ + enabled, queryKey: ["tasks", "one", id], retry: false, queryFn: ({ signal }) => apiParsed(Task, "/api/v1/tasks/:id", { pathParams: { id }, signal }), @@ -113,33 +122,74 @@ function Operations({ taskId }: { taskId: string }) { ); } -export default function BusinessTaskPage() { +const back = ( + +); + +/** + * /console/tasks/:id:id 先当引擎运行查(统一任务);查不到再当旧的业务任务 ID + * (有引擎运行就跳过去,没有就显示旧视图),最后当下载任务 ID 跳到下载详情。 + */ +export default function TaskPage() { const { id = "" } = useParams(); - const task = useTask(id); + const navigate = useNavigate(); + const run = useEngineRun(id); + const runMissing = !!run.error && isMissingEndpoint(run.error); + const task = useTask(id, runMissing); const notFound = !!task.error && isMissingEndpoint(task.error); const legacy = useLegacyDownload(id, notFound); + if (run.data) { + const r = run.data; + const open = (rid: string) => navigate(`/console/tasks/${encodeURIComponent(rid)}`); + return ( + <> + } + actions={ + <> + {back} + + + } + /> +
+ +
+ + ); + } + if (run.error && !runMissing) + return ( + <> + + void run.refetch()} /> + + ); + if (task.data?.engineRunId) + return ; if (notFound && legacy.data) return ; const t = task.data; return ( <> : undefined} + title={t ? (TASK_KIND_LABEL[t.kind] ?? t.kind) : "任务"} + description={t ? : undefined} actions={ <> - + {back} {t ? : null} } /> - {task.isPending || (notFound && legacy.isPending) ? ( + {run.isPending || task.isPending || (notFound && legacy.isPending) ? (
diff --git a/apps/web/app/routes/console/tasks.tsx b/apps/web/app/routes/console/tasks.tsx index 700915c4..fd987f83 100644 --- a/apps/web/app/routes/console/tasks.tsx +++ b/apps/web/app/routes/console/tasks.tsx @@ -1,17 +1,166 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { ExternalLink, RotateCw } from "lucide-react"; +import { Link, useSearchParams } from "react-router"; +import { useEngineRun, useEngineStats } from "~/api/engine.ts"; +import { DetailSheet } from "~/components/console/detail-sheet.tsx"; +import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { PageHeader } from "~/components/console/page-header.tsx"; -import { BusinessTasks } from "~/components/downloads/business-tasks.tsx"; +import { ScheduleBoard } from "~/components/schedules/schedule-board.tsx"; +import { runTitle } from "~/components/tasks/labels.ts"; +import { RunActions, TaskDetailBody, TaskStatusLine } from "~/components/tasks/task-detail.tsx"; +import { type StreamFilters, type StreamTab, TaskStream } from "~/components/tasks/task-stream.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx"; +import { qk } from "~/lib/query-keys.ts"; import { legacyRedirectLoader } from "./redirects.ts"; -export const meta = () => [{ title: "业务任务 · UHD 媒体自动化控制台" }]; +export const meta = () => [{ title: "任务 · UHD 媒体自动化控制台" }]; -// 旧的 ?tab=downloads / plans 跳到下载队列 / 下载计划 +// 旧的 ?tab=downloads / plans / business 跳到下载面板 / 下载计划 / 任务流 export const clientLoader = legacyRedirectLoader; +type Tab = StreamTab | "plan"; +const TABS: Tab[] = ["all", "active", "failed", "plan"]; +const FILTER_KEYS: (keyof StreamFilters)[] = ["status", "kind", "ref", "source", "since", "workflow"]; + +function Count({ n }: { n: number | undefined }) { + if (!n) return null; + return {n > 999 ? "999+" : n}; +} + +function RunSheet({ + runId, + onOpen, + onClose, +}: { + runId: string | null; + onOpen: (id: string) => void; + onClose: () => void; +}) { + const q = useEngineRun(runId ?? undefined); + const r = q.data; + return ( + !o && onClose()} + width="lg" + title={r ? runTitle(r) : "任务"} + description={r ? : undefined} + footer={ + r ? ( + <> + + + + ) : null + } + > + {q.isPending ? ( + + ) : q.isError ? ( + q.refetch()} /> + ) : r ? ( + + ) : null} + + ); +} + export default function TasksPage() { + const qc = useQueryClient(); + const [params, setParams] = useSearchParams(); + const raw = params.get("tab") as Tab | null; + const tab: Tab = raw && TABS.includes(raw) ? raw : "all"; + const runId = params.get("run"); + const filters = Object.fromEntries(FILTER_KEYS.map((k) => [k, params.get(k) ?? ""])) as StreamFilters; + const stats = useEngineStats(); + const by = stats.data?.byStatus; + + const update = (fn: (p: URLSearchParams) => void) => + setParams( + (prev) => { + const next = new URLSearchParams(prev); + fn(next); + return next; + }, + { replace: true }, + ); + const setFilter = (k: keyof StreamFilters, v: string) => + update((p) => { + if (v) p.set(k, v); + else p.delete(k); + }); + return ( <> - - + { + qc.invalidateQueries({ queryKey: qk.all("engine") }); + qc.invalidateQueries({ queryKey: qk.all("schedules") }); + }} + > + + 刷新 + + } + /> + + update((p) => { + p.set("tab", v); + p.delete("job"); + if (v !== "all") p.delete("status"); + }) + } + > + + 全部 + + 进行中 + + + + 失败 + + + 计划 + + +
+ {tab === "plan" ? ( + + ) : ( + + update((p) => { + for (const k of FILTER_KEYS) p.delete(k); + }) + } + onOpen={(id) => update((p) => p.set("run", id))} + selectedId={runId} + /> + )} +
+ update((p) => p.set("run", id))} + onClose={() => update((p) => p.delete("run"))} + /> ); }