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 } 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, engineRunStatus, RUN_STATUS_OPTIONS, runTitle, SOURCE_LABEL, SOURCE_TRIGGERED_BY, sourceOf, type TaskSource, workflowLabel, } from "./labels.ts"; export type StreamTab = "all" | "active" | "failed"; /** 引擎本身不发事件;业务事件(outbox → SSE 桥)足以代表任务在动,2 秒节流刷新 */ const TASK_EVENTS = [ "task.created", "task.status_changed", "download.status_changed", "download.threshold", "organization.changed", "feed.fetched", "decision.created", "decision.updated", "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, triggeredBy: SOURCE_TRIGGERED_BY[filters.source as TaskSource] ?? undefined, from, }; const active = tab === "active"; // 进行中 = 进行中 + 排队(status 多值,服务端分页);来源筛选同样在服务端做 const list = useEngineRuns( { ...base, status: active ? "running,pending" : tab === "failed" ? "failed" : filters.status || undefined, }, true, active ? 10_000 : false, ); const rows = list.data?.pages.flatMap((p) => p.items); 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={list.isPending} error={list.error} onRetry={() => void list.refetch()} onRowClick={(r) => onOpen(r.id)} selectedKey={selectedId} hasMore={!!list.hasNextPage} onLoadMore={() => list.fetchNextPage()} loadingMore={list.isFetchingNextPage} empty={{ icon: ListTodo, title: filtered ? "没有符合条件的任务" : tab === "active" ? "没有进行中的任务" : tab === "failed" ? "没有失败的任务" : "还没有任务", description: filters.source && list.hasNextPage ? "按来源筛选时一页可能较少,可加载更多继续查找。" : 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 = engineRunStatus(r); 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} ) : ( — ); }, }, ]} /> ); }