feat(web): 日志页——任务 / 定时与自动 / 登录 / 审计合一,实时追加与暂停,勾选后问智能体;计划并入「定时任务」页签,侧栏系统组只留日志、运行状态、设置
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
2ce1841628
commit
805ba33ddf
21 files changed
+1693
-846
No files matched your search
@@ -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"]);
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user