feat(web): 日志页——任务 / 定时与自动 / 登录 / 审计合一,实时追加与暂停,勾选后问智能体;计划并入「定时任务」页签,侧栏系统组只留日志、运行状态、设置

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 18:09:56 +08:00
1 parent 2ce1841628
commit 805ba33ddf
21 files changed
+1693 -846

No files matched your search

+1 -15
View File
@@ -15,7 +15,6 @@ export const BUSINESS_KIND_LABEL: Record<EngineBusinessKind, string> = {
decision: "待处理事项",
media: "作品",
};
export const BUSINESS_KINDS = Object.keys(BUSINESS_KIND_LABEL) as EngineBusinessKind[];
/** 来源:四类(定时/事件/手动/Agent);API Key 调用算手动 */
export type TaskSource = "schedule" | "event" | "manual" | "agent";
@@ -25,13 +24,6 @@ export const SOURCE_LABEL: Record<TaskSource, string> = {
manual: "手动",
agent: "智能体",
};
/** 来源 → 服务端 triggeredBy 筛选值(逗号分隔多值) */
export const SOURCE_TRIGGERED_BY: Record<TaskSource, string> = {
schedule: "schedule",
event: "event",
manual: "user,api_key",
agent: "agent",
};
export function sourceOf(t: EngineTriggeredBy | null | undefined): TaskSource | null {
if (!t) return null;
if (t === "user" || t === "api_key") return "manual";
@@ -86,10 +78,6 @@ export function engineRunStatus(r: Pick<EngineRun, "status" | "business">) {
return OUTCOME_STATUS[outcome];
return runStatus(r.status);
}
export const RUN_STATUS_OPTIONS = ["pending", "running", "completed", "failed", "canceled"].map((value) => ({
value,
label: runStatus(value).label,
}));
/** 行标题:业务对象名 > 业务类型 + 短 id > 工作流做什么 */
export function runTitle(r: Pick<EngineRun, "business" | "workflowName">): string {
@@ -118,7 +106,7 @@ export function businessLink(b: EngineRun["business"], input?: unknown): { to: s
case "agent":
return { to: `/console/agent/${id}`, label: "智能体会话" };
case "schedule":
return { to: `/console/schedules?job=${id}`, label: "定时任务" };
return { to: `/console/logs?tab=schedules&job=${id}`, label: "定时任务" };
case "feed":
// RSS 属于站点(IA-2):旧 RSS 页按 ?feed= 查所属站点后跳到站点的 RSS Tab
return { to: `${consolePath("sources")}?feed=${encodeURIComponent(id)}`, label: "RSS" };
@@ -133,5 +121,3 @@ export function businessLink(b: EngineRun["business"], input?: unknown): { to: s
return null;
}
}
export const ENGINE_ACTIVE = new Set(["pending", "running", "sleeping"]);
+30
View File
@@ -0,0 +1,30 @@
import { useQueryClient } from "@tanstack/react-query";
import { useRef } from "react";
import { qk } from "~/lib/query-keys.ts";
import { useSse } from "~/lib/sse.ts";
/** 引擎本身不发事件;业务事件(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<ReturnType<typeof setTimeout> | null>(null);
useSse(TASK_EVENTS, () => {
if (timer.current) return;
timer.current = setTimeout(() => {
timer.current = null;
qc.invalidateQueries({ queryKey: qk.all("engine") });
}, 2000);
});
}
@@ -11,6 +11,7 @@ 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 { BUSINESS_OBJECT_TYPE, objectLogsPath } from "~/components/logs/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatDuration, formatRelative, spanSec } from "~/lib/labels.ts";
@@ -240,13 +241,14 @@ export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun
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: objectLogsPath("engine_run", r.id), label: "本任务的操作记录" },
...(b && BUSINESS_OBJECT_TYPE[b.kind]
? [
{
to: `/console/audit?targetType=scheduled_job&targetId=${encodeURIComponent(b.refId)}`,
label: "定时任务的修改记录",
to: objectLogsPath(BUSINESS_OBJECT_TYPE[b.kind]!, b.refId),
label: b.kind === "schedule" ? "定时任务的全部日志" : "关联对象的全部日志",
},
]
: []),
@@ -287,7 +289,7 @@ export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun
<Timeline items={businessTimeline(r)} />
</DetailSection>
<DetailSection title="相关审计">
<DetailSection title="相关日志">
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-caption">
{audits.map((a) => (
<li key={a.to}>
@@ -1,307 +0,0 @@
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 { formatRelative } 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<ReturnType<typeof setTimeout> | 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;
/** "1" = 显示系统任务 */
system: string;
};
function SourceBadge({ r }: { r: EngineRun }) {
const s = sourceOf(r.business?.triggeredBy);
return s ? <span className="text-caption">{SOURCE_LABEL[s]}</span> : <span className="text-muted-foreground">—</span>;
}
/** 定时任务筛选时,页头给出定时任务自己的运行记录(含跳过的,引擎里没有) */
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<ScheduledJob | null>(null);
return (
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-xl border border-border/70 px-3 py-2 text-caption">
<CalendarClock className="h-4 w-4 text-muted-foreground" />
<span className="min-w-0 truncate">
定时任务 <span className="font-medium text-foreground">{job?.name ?? jobId}</span> 触发的任务
</span>
<span className="ml-auto flex items-center gap-1">
<Button variant="ghost" size="xs" disabled={!job} onClick={() => setOpen(job)}>
<History />
运行记录
</Button>
<Button asChild variant="ghost" size="xs">
<Link to={`/console/schedules?job=${encodeURIComponent(jobId)}`}>
<ExternalLink />
计划
</Link>
</Button>
<Button variant="ghost" size="icon-xs" aria-label="清除定时任务筛选" onClick={onClear}>
<X />
</Button>
</span>
<RunsSheet job={open} onClose={() => setOpen(null)} />
</div>
);
}
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 all = list.data?.pages.flatMap((p) => p.items);
// 系统任务(没有业务对象的引擎步骤、定时任务的成功运行)默认不列;按对象 / 定时任务筛选或勾选时显示
const showSystem = filters.system === "1" || !!filters.ref || filters.kind === "schedule" || !!filters.workflow;
const isSystem = (r: EngineRun) => !r.business || (r.business.kind === "schedule" && r.status === "completed");
const rows = showSystem ? all : all?.filter((r) => !isSystem(r));
const hidden = (all?.length ?? 0) - (rows?.length ?? 0);
const filtered = Object.entries(filters).some(([k, v]) => k !== "system" && !!v);
return (
<>
{filters.kind === "schedule" && filters.ref ? (
<ScheduleBanner
jobId={filters.ref}
onClear={() => {
setFilter("ref", "");
setFilter("kind", "");
}}
/>
) : filters.ref ? (
<div className="mb-3 flex items-center gap-2 text-caption text-muted-foreground">
只看对象 <code className="font-mono text-foreground">{filters.ref}</code>
<Button variant="ghost" size="icon-xs" aria-label="清除对象筛选" onClick={() => setFilter("ref", "")}>
<X />
</Button>
</div>
) : null}
<FilterBar onReset={filtered ? resetFilters : undefined}>
<FilterSelect
label="来源"
value={filters.source}
onChange={(v) => setFilter("source", v)}
options={(Object.keys(SOURCE_LABEL) as TaskSource[]).map((value) => ({ value, label: SOURCE_LABEL[value] }))}
/>
<FilterSelect
label="业务"
value={filters.kind}
onChange={(v) => setFilter("kind", v)}
options={BUSINESS_KINDS.map((value) => ({ value, label: BUSINESS_KIND_LABEL[value] }))}
/>
{tab === "all" ? (
<FilterSelect
label="状态"
value={filters.status}
onChange={(v) => setFilter("status", v)}
options={RUN_STATUS_OPTIONS}
/>
) : null}
<FilterSelect
label="时间"
value={filters.since}
onChange={(v) => setFilter("since", v)}
options={SINCE}
allLabel="不限"
/>
<Button
variant={filters.system === "1" ? "secondary" : "ghost"}
size="sm"
aria-pressed={filters.system === "1"}
onClick={() => setFilter("system", filters.system === "1" ? "" : "1")}
>
{filters.system === "1" ? "隐藏系统任务" : hidden ? `显示系统任务(${hidden})` : "显示系统任务"}
</Button>
{filters.workflow ? (
<Button variant="outline" size="sm" onClick={() => setFilter("workflow", "")}>
{workflowLabel(filters.workflow)}
<X />
</Button>
) : null}
</FilterBar>
<DataTable<EngineRun>
paginationKey={JSON.stringify({ tab, filters })}
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) => (
<div className="min-w-0 max-w-[15rem] sm:max-w-sm">
<p className="truncate font-medium text-foreground">{runTitle(r)}</p>
{r.business?.title ? (
<p className="truncate text-meta text-muted-foreground">{workflowLabel(r.workflowName)}</p>
) : null}
</div>
),
},
{ key: "source", header: "来源", hideBelow: "md", cell: (r) => <SourceBadge r={r} /> },
{
key: "status",
header: "状态",
cell: (r) => {
const s = engineRunStatus(r);
return <StatusBadge tone={s.tone}>{s.label}</StatusBadge>;
},
},
{
key: "time",
header: "开始",
hideBelow: "sm",
cell: (r) => (
<div className="whitespace-nowrap">
<p className="text-muted-foreground" title={r.startedAt ?? r.createdAt}>
{r.startedAt
? formatRelative(r.startedAt)
: ENGINE_ACTIVE.has(r.status)
? `排队 ${formatRelative(r.createdAt)}`
: `未开始 · ${formatRelative(r.createdAt)}`}
</p>
</div>
),
},
{
key: "link",
header: "关联",
hideBelow: "lg",
cell: (r) => {
const l = businessLink(r.business);
return l ? (
<Link
to={l.to}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 text-caption text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
{l.label}
<ExternalLink className="h-3 w-3" />
</Link>
) : (
<span className="text-muted-foreground">—</span>
);
},
},
]}
/>
</>
);
}