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
@@ -0,0 +1,160 @@
|
|||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { LOG_STREAM_EVENT, type LogEntry, type LogPage } from "@umt/contracts";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { apiData, buildUrl } from "~/lib/api.ts";
|
||||||
|
import { qk } from "~/lib/query-keys.ts";
|
||||||
|
|
||||||
|
// 日志模块(界面第二轮 V 线):GET /logs 列表(游标"加载更多")与 GET /logs/stream(SSE,同一组筛选)。
|
||||||
|
|
||||||
|
export type LogParams = {
|
||||||
|
type?: string;
|
||||||
|
level?: string;
|
||||||
|
from?: string;
|
||||||
|
q?: string;
|
||||||
|
objectType?: string;
|
||||||
|
objectId?: string;
|
||||||
|
system?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useLogs(params: LogParams) {
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: qk.list("logs", params),
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
queryFn: ({ pageParam, signal }) =>
|
||||||
|
apiData<LogPage>("/logs", { params: { ...params, cursor: pageParam, limit: 50 }, signal }),
|
||||||
|
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LogStreamStatus = "off" | "connecting" | "open" | "reconnecting";
|
||||||
|
|
||||||
|
type EventSourceLike = Pick<EventSource, "addEventListener" | "close"> & { onerror: ((ev: Event) => unknown) | null };
|
||||||
|
export type EventSourceFactory = (url: string) => EventSourceLike;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志流连接(不依赖 React,便于测试):start 连上 /logs/stream(since = 已知最新一条的时间),
|
||||||
|
* 收到的条目交给 onEntry;stop 断开(暂停)。再 start 时从收到过的最新时间续上,服务端先补推这段时间的日志。
|
||||||
|
* 出错按指数退避重连。
|
||||||
|
*/
|
||||||
|
export class LogStreamClient {
|
||||||
|
private es: EventSourceLike | null = null;
|
||||||
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private retry = 0;
|
||||||
|
private running = false;
|
||||||
|
since: string | null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly params: LogParams,
|
||||||
|
private readonly handlers: { onEntry: (e: LogEntry) => void; onStatus: (s: LogStreamStatus) => void },
|
||||||
|
since: string | null | undefined,
|
||||||
|
private readonly factory: EventSourceFactory = (url) => new EventSource(url, { withCredentials: true }),
|
||||||
|
) {
|
||||||
|
this.since = since ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表加载出更新的条目时推进起点 */
|
||||||
|
advance(at: string | null | undefined) {
|
||||||
|
this.since = latest(this.since, at) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.running) return;
|
||||||
|
this.running = true;
|
||||||
|
this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.running = false;
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
this.es?.close();
|
||||||
|
this.es = null;
|
||||||
|
this.handlers.onStatus("off");
|
||||||
|
}
|
||||||
|
|
||||||
|
private connect() {
|
||||||
|
this.handlers.onStatus(this.retry ? "reconnecting" : "connecting");
|
||||||
|
const es = this.factory(buildUrl("/logs/stream", { params: { ...this.params, since: this.since ?? undefined } }));
|
||||||
|
this.es = es;
|
||||||
|
es.addEventListener("ready", () => {
|
||||||
|
this.retry = 0;
|
||||||
|
this.handlers.onStatus("open");
|
||||||
|
});
|
||||||
|
es.addEventListener(LOG_STREAM_EVENT, (ev) => {
|
||||||
|
let e: LogEntry;
|
||||||
|
try {
|
||||||
|
e = JSON.parse((ev as MessageEvent<string>).data) as LogEntry;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.advance(e.at);
|
||||||
|
this.handlers.onEntry(e);
|
||||||
|
});
|
||||||
|
es.onerror = () => {
|
||||||
|
es.close();
|
||||||
|
if (this.es !== es) return;
|
||||||
|
this.es = null;
|
||||||
|
if (!this.running) return;
|
||||||
|
this.retry += 1;
|
||||||
|
this.handlers.onStatus("reconnecting");
|
||||||
|
this.timer = setTimeout(() => this.connect(), Math.min(30_000, 1000 * 2 ** this.retry));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志流 hook:enabled 时连,收到的条目按 id 覆盖、新的在前;enabled=false 即暂停。筛选变化时清空重连。
|
||||||
|
*/
|
||||||
|
export function useLogStream(params: LogParams, opts: { enabled: boolean; since?: string | null }) {
|
||||||
|
const [entries, setEntries] = useState<LogEntry[]>([]);
|
||||||
|
const [status, setStatus] = useState<LogStreamStatus>("off");
|
||||||
|
const key = JSON.stringify(params);
|
||||||
|
const client = useRef<LogStreamClient | null>(null);
|
||||||
|
const sinceRef = useRef(opts.since);
|
||||||
|
sinceRef.current = opts.since;
|
||||||
|
client.current?.advance(opts.since);
|
||||||
|
|
||||||
|
// 筛选变了:换一个连接,之前推来的不再适用
|
||||||
|
useEffect(() => {
|
||||||
|
setEntries([]);
|
||||||
|
const c = new LogStreamClient(
|
||||||
|
JSON.parse(key) as LogParams,
|
||||||
|
{ onEntry: (e) => setEntries((prev) => upsertEntries(prev, [e])), onStatus: setStatus },
|
||||||
|
sinceRef.current,
|
||||||
|
);
|
||||||
|
client.current = c;
|
||||||
|
return () => {
|
||||||
|
c.stop();
|
||||||
|
if (client.current === c) client.current = null;
|
||||||
|
};
|
||||||
|
}, [key]);
|
||||||
|
|
||||||
|
// key:筛选变化后新建的连接也要按当前开关启动
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: 见上
|
||||||
|
useEffect(() => {
|
||||||
|
const c = client.current;
|
||||||
|
if (!c || typeof EventSource === "undefined") return;
|
||||||
|
if (opts.enabled) {
|
||||||
|
c.advance(sinceRef.current);
|
||||||
|
c.start();
|
||||||
|
} else c.stop();
|
||||||
|
}, [opts.enabled, key]);
|
||||||
|
|
||||||
|
return { entries, status, clear: () => setEntries([]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function latest(a: string | null | undefined, b: string | null | undefined): string | null | undefined {
|
||||||
|
if (!a) return b;
|
||||||
|
if (!b) return a;
|
||||||
|
return Date.parse(b) > Date.parse(a) ? b : a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 id 覆盖合并,时间倒序(同一时间按 id 倒序,与服务端一致) */
|
||||||
|
export function upsertEntries(base: LogEntry[], incoming: LogEntry[]): LogEntry[] {
|
||||||
|
const map = new Map(base.map((e) => [e.id, e]));
|
||||||
|
for (const e of incoming) map.set(e.id, e);
|
||||||
|
return [...map.values()].sort((a, b) => {
|
||||||
|
const d = Date.parse(b.at) - Date.parse(a.at);
|
||||||
|
return d !== 0 ? d : a.id < b.id ? 1 : a.id > b.id ? -1 : 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -24,24 +24,32 @@ const matchesRoute = (pathname: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("控制台导航", () => {
|
describe("控制台导航", () => {
|
||||||
it("IA-2:4 组,任务归系统,站点 / 订阅 / 规则合为「获取」,没有独立 RSS 项", () => {
|
it("IA-2:4 组,站点 / 订阅 / 规则合为「获取」,没有独立 RSS 项;系统组只有日志、运行状态、设置", () => {
|
||||||
expect(NAV_GROUPS.map((g) => g.title)).toEqual(["概览", ACQUIRE_GROUP_TITLE, "整理", "系统"]);
|
expect(NAV_GROUPS.map((g) => g.title)).toEqual(["概览", ACQUIRE_GROUP_TITLE, "整理", "系统"]);
|
||||||
expect(ACQUIRE_GROUP_TITLE).toBe("获取");
|
expect(ACQUIRE_GROUP_TITLE).toBe("获取");
|
||||||
expect(NAV_GROUPS.map((g) => g.items.map((i) => i.label))).toEqual([
|
expect(NAV_GROUPS.map((g) => g.items.map((i) => i.label))).toEqual([
|
||||||
["首页", "计划", "智能体会话"],
|
["首页", "智能体会话"],
|
||||||
["站点", "发现", "订阅", "规则"],
|
["站点", "发现", "订阅", "规则"],
|
||||||
["下载面板", "整理记录", "文件管理", "媒体库"],
|
["下载面板", "整理记录", "文件管理", "媒体库"],
|
||||||
["任务", "设置", "审计", "运行状态"],
|
["日志", "运行状态", "设置"],
|
||||||
]);
|
]);
|
||||||
expect(NAV_ITEMS).toHaveLength(15);
|
expect(NAV_ITEMS).toHaveLength(13);
|
||||||
expect(NAV_ITEMS.map((i) => i.to)).not.toContain("/console/sources");
|
expect(NAV_ITEMS.map((i) => i.to)).not.toContain("/console/sources");
|
||||||
expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(NAV_ITEMS.length);
|
expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(NAV_ITEMS.length);
|
||||||
expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true);
|
expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("取消的入口不在侧栏:没有待处理(问题回到发生处),引擎面板 / 身份并入别处", () => {
|
it("取消的入口不在侧栏:没有待处理(问题回到发生处),引擎面板 / 身份 / 任务 / 审计 / 计划并入别处", () => {
|
||||||
const paths = NAV_ITEMS.map((i) => i.to);
|
const paths = NAV_ITEMS.map((i) => i.to);
|
||||||
for (const p of ["/console/inbox", "/console/engine", "/console/identity"]) expect(paths).not.toContain(p);
|
for (const p of [
|
||||||
|
"/console/inbox",
|
||||||
|
"/console/engine",
|
||||||
|
"/console/identity",
|
||||||
|
"/console/tasks",
|
||||||
|
"/console/audit",
|
||||||
|
"/console/schedules",
|
||||||
|
])
|
||||||
|
expect(paths).not.toContain(p);
|
||||||
expect(NAV_ITEMS.map((i) => i.label)).not.toContain("待处理");
|
expect(NAV_ITEMS.map((i) => i.label)).not.toContain("待处理");
|
||||||
expect(pageTitle("/console/inbox")).toBeUndefined();
|
expect(pageTitle("/console/inbox")).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -61,8 +69,10 @@ describe("控制台导航", () => {
|
|||||||
expect(findNavItem("/console")?.label).toBe("首页");
|
expect(findNavItem("/console")?.label).toBe("首页");
|
||||||
expect(findNavItem("/console/")?.label).toBe("首页");
|
expect(findNavItem("/console/")?.label).toBe("首页");
|
||||||
expect(findNavItem("/console/library/abc")?.label).toBe("媒体库");
|
expect(findNavItem("/console/library/abc")?.label).toBe("媒体库");
|
||||||
expect(findNavItem("/console/tasks/r1")?.label).toBe("任务");
|
// 任务详情独立页归日志
|
||||||
expect(findNavItem("/console/schedules")?.label).toBe("计划");
|
expect(findNavItem("/console/tasks/r1")?.label).toBe("日志");
|
||||||
|
expect(pageTitle("/console/tasks/r1")).toBe("日志");
|
||||||
|
expect(findNavItem("/console/logs")?.label).toBe("日志");
|
||||||
expect(findNavItem("/console/downloads")?.label).toBe("下载面板");
|
expect(findNavItem("/console/downloads")?.label).toBe("下载面板");
|
||||||
expect(findNavItem("/console/downloads/plans")?.label).toBe("下载面板");
|
expect(findNavItem("/console/downloads/plans")?.label).toBe("下载面板");
|
||||||
expect(findNavItem("/console/downloads/abc")?.label).toBe("下载面板");
|
expect(findNavItem("/console/downloads/abc")?.label).toBe("下载面板");
|
||||||
@@ -97,10 +107,14 @@ describe("旧链接重定向", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("任务 / 审计 / 计划的旧地址一律落到日志页;任务详情独立页不跳", () => {
|
||||||
|
expect(legacyRedirect("/console/tasks", "?kind=submitDownload")).toBe("/console/logs?type=task");
|
||||||
|
expect(legacyRedirect("/console/tasks", "?tab=failed")).toBe("/console/logs?type=task&level=error");
|
||||||
|
expect(legacyRedirect("/console/tasks/r1", "")).toBeNull();
|
||||||
|
expect(legacyRedirect("/console/logs", "?type=task")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("不相关的 query 不跳转", () => {
|
it("不相关的 query 不跳转", () => {
|
||||||
expect(legacyRedirect("/console/tasks", "?kind=submitDownload")).toBeNull();
|
|
||||||
expect(legacyRedirect("/console/schedules", "?job=trash.purge")).toBeNull();
|
|
||||||
expect(legacyRedirect("/console/tasks", "?tab=failed")).toBeNull();
|
|
||||||
expect(legacyRedirect("/console/library", "?filter=unidentified")).toBeNull();
|
expect(legacyRedirect("/console/library", "?filter=unidentified")).toBeNull();
|
||||||
expect(legacyRedirect("/console/subscriptions", "")).toBeNull();
|
expect(legacyRedirect("/console/subscriptions", "")).toBeNull();
|
||||||
// 旧 RSS 页的 ?feed= 由 sources.tsx 查所属站点后跳转
|
// 旧 RSS 页的 ?feed= 由 sources.tsx 查所属站点后跳转
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
CalendarCog,
|
|
||||||
Compass,
|
Compass,
|
||||||
Download,
|
Download,
|
||||||
FileStack,
|
FileStack,
|
||||||
@@ -11,7 +10,6 @@ import {
|
|||||||
House,
|
House,
|
||||||
Library,
|
Library,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
ListTodo,
|
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -40,9 +38,10 @@ export function consolePath(...segments: (string | number)[]): string {
|
|||||||
export const ACQUIRE_GROUP_TITLE = "获取";
|
export const ACQUIRE_GROUP_TITLE = "获取";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 侧栏 4 组 14 项(IA-2,docs/design/console-ia.md):概览 → 获取 → 整理 → 系统。
|
* 侧栏 4 组 13 项(IA-2,docs/design/console-ia.md):概览 → 获取 → 整理 → 系统。
|
||||||
* 任务归系统;RSS 属于站点(站点详情的 RSS Tab),不再单独占一项。
|
* RSS 属于站点(站点详情的 RSS Tab),不再单独占一项。
|
||||||
* 没有「待处理」(2026-10-06 取消):问题在发生处处理,铃铛只放等你批准的智能体操作与系统健康;移动端抽屉用同一份表。
|
* 没有「待处理」(2026-10-06 取消):问题在发生处处理,铃铛只放等你批准的智能体操作与系统健康;移动端抽屉用同一份表。
|
||||||
|
* 系统组只有三项(界面第二轮):日志(任务、定时与自动、登录、审计合一,定时任务设置是其中一个页签)、运行状态、设置。
|
||||||
*/
|
*/
|
||||||
export const NAV_GROUPS: NavGroup[] = [
|
export const NAV_GROUPS: NavGroup[] = [
|
||||||
{
|
{
|
||||||
@@ -54,12 +53,6 @@ export const NAV_GROUPS: NavGroup[] = [
|
|||||||
icon: House,
|
icon: House,
|
||||||
description: "在忙什么、要我处理什么、最近入库了什么",
|
description: "在忙什么、要我处理什么、最近入库了什么",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
to: "/console/schedules",
|
|
||||||
label: "计划",
|
|
||||||
icon: CalendarCog,
|
|
||||||
description: "智能体巡检与自动任务的开关和间隔",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
to: "/console/agent",
|
to: "/console/agent",
|
||||||
label: "智能体会话",
|
label: "智能体会话",
|
||||||
@@ -110,10 +103,16 @@ export const NAV_GROUPS: NavGroup[] = [
|
|||||||
title: "系统",
|
title: "系统",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
to: "/console/tasks",
|
to: "/console/logs",
|
||||||
label: "任务",
|
label: "日志",
|
||||||
icon: ListTodo,
|
icon: ScrollText,
|
||||||
description: "最近处理了什么、有没有失败",
|
description: "任务、定时与自动任务、登录与操作留痕,可实时查看",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/console/status",
|
||||||
|
label: "运行状态",
|
||||||
|
icon: Gauge,
|
||||||
|
description: "服务是否正常、CPU 与内存、磁盘与数据库",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: "/console/settings",
|
to: "/console/settings",
|
||||||
@@ -121,22 +120,15 @@ export const NAV_GROUPS: NavGroup[] = [
|
|||||||
icon: Settings,
|
icon: Settings,
|
||||||
description: "下载器、媒体服务器、媒体库目录、站点账号、模型服务与自动化",
|
description: "下载器、媒体服务器、媒体库目录、站点账号、模型服务与自动化",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
to: "/console/audit",
|
|
||||||
label: "审计",
|
|
||||||
icon: ScrollText,
|
|
||||||
description: "登录、设置、密钥、任务操作的留痕",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: "/console/status",
|
|
||||||
label: "运行状态",
|
|
||||||
icon: Gauge,
|
|
||||||
description: "服务是否正常、磁盘与数据库",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 并进别的导航项的旧页面:按前缀归到对应项(任务详情独立页属于日志) */
|
||||||
|
const NAV_ALIASES: Record<string, string> = {
|
||||||
|
"/console/tasks": "/console/logs",
|
||||||
|
};
|
||||||
|
|
||||||
/** 不在侧栏但有页面的路由:顶栏标题用 */
|
/** 不在侧栏但有页面的路由:顶栏标题用 */
|
||||||
const EXTRA_TITLES: Record<string, string> = {
|
const EXTRA_TITLES: Record<string, string> = {
|
||||||
"/console/preferences": "偏好标签",
|
"/console/preferences": "偏好标签",
|
||||||
@@ -162,6 +154,8 @@ export function settingsPath(section: SettingsSection, anchor?: string): string
|
|||||||
|
|
||||||
/** 按路径找当前导航项(最长前缀匹配) */
|
/** 按路径找当前导航项(最长前缀匹配) */
|
||||||
export function findNavItem(pathname: string): NavItem | undefined {
|
export function findNavItem(pathname: string): NavItem | undefined {
|
||||||
|
const alias = Object.entries(NAV_ALIASES).find(([from]) => pathname === from || pathname.startsWith(`${from}/`));
|
||||||
|
if (alias) return NAV_ITEMS.find((i) => i.to === alias[1]);
|
||||||
return (
|
return (
|
||||||
NAV_ITEMS.find((i) => i.to === pathname.replace(/\/+$/, "")) ??
|
NAV_ITEMS.find((i) => i.to === pathname.replace(/\/+$/, "")) ??
|
||||||
NAV_ITEMS.filter((i) => i.to !== CONSOLE_BASE && pathname.startsWith(`${i.to}/`)).sort(
|
NAV_ITEMS.filter((i) => i.to !== CONSOLE_BASE && pathname.startsWith(`${i.to}/`)).sort(
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import {
|
||||||
|
CAPABILITY_CATALOG,
|
||||||
|
type EngineBusinessKind,
|
||||||
|
type KnownCapabilityId,
|
||||||
|
type LogEntry,
|
||||||
|
type LogLevel,
|
||||||
|
type LogType,
|
||||||
|
} from "@umt/contracts";
|
||||||
|
import type { StatusTone } from "~/components/console/status-badge.tsx";
|
||||||
|
import { BUSINESS_KIND_LABEL, workflowLabel } from "~/components/tasks/labels.ts";
|
||||||
|
import { actorTypeLabel } from "~/lib/labels.ts";
|
||||||
|
import { formatDateTime } from "~/lib/utils.ts";
|
||||||
|
|
||||||
|
// 日志页的标签与纯函数(界面第二轮 V 线):类型、级别、审计动作、标题 / 摘要、筛选参数、给智能体的上下文。
|
||||||
|
|
||||||
|
export const LOG_PATH = "/console/logs";
|
||||||
|
|
||||||
|
export const LOG_TYPE_LABEL: Record<LogType, string> = {
|
||||||
|
task: "任务",
|
||||||
|
auto: "定时与自动",
|
||||||
|
auth: "登录",
|
||||||
|
audit: "审计",
|
||||||
|
};
|
||||||
|
export const LOG_TYPES = Object.keys(LOG_TYPE_LABEL) as LogType[];
|
||||||
|
|
||||||
|
export const LOG_LEVEL: Record<LogLevel, { label: string; tone: StatusTone }> = {
|
||||||
|
running: { label: "进行中", tone: "neutral" },
|
||||||
|
success: { label: "成功", tone: "success" },
|
||||||
|
info: { label: "信息", tone: "neutral" },
|
||||||
|
warn: { label: "警告", tone: "warning" },
|
||||||
|
error: { label: "失败", tone: "destructive" },
|
||||||
|
};
|
||||||
|
export const LOG_LEVELS = Object.keys(LOG_LEVEL) as LogLevel[];
|
||||||
|
|
||||||
|
/** 时间范围预设 → 毫秒 */
|
||||||
|
export const LOG_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 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── 审计动作(原审计页) ──
|
||||||
|
|
||||||
|
const ACTION_LABEL: Record<string, string> = {
|
||||||
|
"admin.setup": "初始化管理员",
|
||||||
|
"admin.login_success": "登录",
|
||||||
|
"admin.login_failed": "登录失败",
|
||||||
|
"admin.logout": "退出登录",
|
||||||
|
"admin.password_change": "修改密码",
|
||||||
|
"admin.totp_enable": "启用二次验证",
|
||||||
|
"admin.totp_disable": "停用二次验证",
|
||||||
|
"admin.recovery_codes_regenerate": "重新生成恢复码",
|
||||||
|
"admin.recovery_code_login": "用恢复码登录",
|
||||||
|
"admin.passkey_register": "登记 Passkey",
|
||||||
|
"admin.passkey_rename": "重命名 Passkey",
|
||||||
|
"admin.passkey_remove": "删除 Passkey",
|
||||||
|
"admin.passkey_login": "用 Passkey 登录",
|
||||||
|
"api_key.create": "创建 API Key",
|
||||||
|
"api_key.update": "修改 API Key",
|
||||||
|
"api_key.revoke": "撤销 API Key",
|
||||||
|
"settings.update": "修改设置",
|
||||||
|
"secret.create": "新建凭证",
|
||||||
|
"secret.rotate": "轮换凭证",
|
||||||
|
"secret.delete": "删除凭证",
|
||||||
|
"secret.master_key_rotate": "轮换主密钥",
|
||||||
|
"site_account.credentials_set": "录入站点凭证",
|
||||||
|
"engine.run_cancel": "取消任务",
|
||||||
|
"engine.run_retry": "重跑任务",
|
||||||
|
"pi_upstream.oauth_start": "开始订阅登录",
|
||||||
|
"pi_upstream.oauth_login": "完成订阅登录",
|
||||||
|
"pi_upstream.oauth_logout": "退出订阅登录",
|
||||||
|
"schedule.run_now": "立即执行定时任务",
|
||||||
|
"schedule.skip": "跳过定时任务",
|
||||||
|
"schedule.update": "修改定时任务",
|
||||||
|
"storage.cleanup": "立即清理存储",
|
||||||
|
"acquisition_plan.manual_submit": "手动提交下载",
|
||||||
|
"acquisition_plan.cancel": "取消下载",
|
||||||
|
"capability.invoke": "智能体操作",
|
||||||
|
"capability.denied": "智能体操作被拒绝",
|
||||||
|
"site.created": "登记站点",
|
||||||
|
"site.updated": "修改站点",
|
||||||
|
"feed_source.created": "新建 RSS",
|
||||||
|
"identity.release_assigned": "指定资源身份",
|
||||||
|
"files.mkdir": "新建目录",
|
||||||
|
"files.rename": "重命名",
|
||||||
|
"files.move": "移动文件",
|
||||||
|
"files.copy": "复制文件",
|
||||||
|
"files.organize": "整理文件",
|
||||||
|
"files.delete": "删除文件",
|
||||||
|
"files.trash_restore": "从回收站还原",
|
||||||
|
};
|
||||||
|
const VERB: Record<string, string> = { create: "新建", update: "修改", delete: "删除", test: "测试", verify: "校验" };
|
||||||
|
const NOUN: Record<string, string> = {
|
||||||
|
library_root: "媒体库目录",
|
||||||
|
downloader_instance: "下载器",
|
||||||
|
media_server_instance: "媒体服务器",
|
||||||
|
path_mapping: "路径映射",
|
||||||
|
pi_upstream: "模型服务",
|
||||||
|
schedule: "定时任务",
|
||||||
|
automation_grant: "持续授权",
|
||||||
|
};
|
||||||
|
export const TARGET_LABEL: Record<string, string> = {
|
||||||
|
...NOUN,
|
||||||
|
admin_user: "管理员",
|
||||||
|
admin_session: "登录会话",
|
||||||
|
api_key: "API Key",
|
||||||
|
settings: "设置",
|
||||||
|
secret: "凭证",
|
||||||
|
site: "站点",
|
||||||
|
site_account: "站点账号",
|
||||||
|
feed_source: "RSS",
|
||||||
|
engine_run: "任务",
|
||||||
|
scheduled_job: "定时任务",
|
||||||
|
agent_run: "智能体会话",
|
||||||
|
acquisition_plan: "下载",
|
||||||
|
download_task: "下载任务",
|
||||||
|
media_entity: "作品",
|
||||||
|
source_release: "资源",
|
||||||
|
organization_record: "整理记录",
|
||||||
|
subscription: "订阅",
|
||||||
|
decision: "事项",
|
||||||
|
file: "文件",
|
||||||
|
capability: "能力",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function actionLabel(a: string): string {
|
||||||
|
if (ACTION_LABEL[a]) return ACTION_LABEL[a];
|
||||||
|
const [noun, verb] = a.split(".");
|
||||||
|
if (noun && verb && NOUN[noun] && VERB[verb]) return `${VERB[verb]}${NOUN[noun]}`;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对象文字:有展示名用展示名;能力显示中文名;其余显示对象类型 */
|
||||||
|
export function objectText(o: LogEntry["object"]): string | null {
|
||||||
|
if (!o) return null;
|
||||||
|
if (o.label) return o.label;
|
||||||
|
if (o.type === "capability" && o.id) return CAPABILITY_CATALOG[o.id as KnownCapabilityId]?.title ?? o.id;
|
||||||
|
return TARGET_LABEL[o.type] ?? o.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 标题、摘要、状态 ──
|
||||||
|
|
||||||
|
const SCHEDULE_STATUS: Record<string, { label: string; tone: StatusTone }> = {
|
||||||
|
ok: { label: "成功", tone: "success" },
|
||||||
|
failed: { label: "失败", tone: "destructive" },
|
||||||
|
skipped: { label: "已跳过", tone: "neutral" },
|
||||||
|
running: { label: "进行中", tone: "neutral" },
|
||||||
|
};
|
||||||
|
const AGENT_STATUS: Record<string, { label: string; tone: StatusTone }> = {
|
||||||
|
queued: { label: "排队", tone: "neutral" },
|
||||||
|
waiting: { label: "等待", tone: "neutral" },
|
||||||
|
cancelled: { label: "已停止", tone: "warning" },
|
||||||
|
budget_exceeded: { label: "超出预算", tone: "warning" },
|
||||||
|
};
|
||||||
|
const ENGINE_STATUS: Record<string, { label: string; tone: StatusTone }> = {
|
||||||
|
pending: { label: "排队", tone: "neutral" },
|
||||||
|
canceled: { label: "已取消", tone: "warning" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 行上的状态徽章:来源有更具体的说法时用来源的(已跳过、已取消、超出预算),否则用级别 */
|
||||||
|
export function logBadge(e: Pick<LogEntry, "source" | "status" | "level" | "extra">): {
|
||||||
|
label: string;
|
||||||
|
tone: StatusTone;
|
||||||
|
} {
|
||||||
|
const s = e.status ?? "";
|
||||||
|
if (e.source === "schedule_run") {
|
||||||
|
const agent = typeof e.extra.agentStatus === "string" ? AGENT_STATUS[e.extra.agentStatus] : undefined;
|
||||||
|
return agent ?? SCHEDULE_STATUS[s] ?? LOG_LEVEL[e.level];
|
||||||
|
}
|
||||||
|
if (e.source === "agent_run") return AGENT_STATUS[s] ?? LOG_LEVEL[e.level];
|
||||||
|
if (e.source === "engine_run") {
|
||||||
|
const outcome = typeof e.extra.outcome === "string" ? AGENT_STATUS[e.extra.outcome] : undefined;
|
||||||
|
return (e.level === "warn" ? outcome : undefined) ?? ENGINE_STATUS[s] ?? LOG_LEVEL[e.level];
|
||||||
|
}
|
||||||
|
return LOG_LEVEL[e.level];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一行标题:任务 = 业务对象名 > 业务类型 > 工作流做什么;审计 = 动作;其余用服务端标题 */
|
||||||
|
export function logTitle(e: LogEntry): string {
|
||||||
|
if (e.source === "engine_run") {
|
||||||
|
const x = e.extra as {
|
||||||
|
businessTitle?: string | null;
|
||||||
|
businessKind?: EngineBusinessKind | null;
|
||||||
|
workflowName?: string;
|
||||||
|
};
|
||||||
|
if (x.businessTitle) return x.businessTitle;
|
||||||
|
if (x.businessKind && BUSINESS_KIND_LABEL[x.businessKind]) return BUSINESS_KIND_LABEL[x.businessKind];
|
||||||
|
return workflowLabel(x.workflowName ?? e.title);
|
||||||
|
}
|
||||||
|
if (e.source === "audit_event") return actionLabel(e.status ?? e.title);
|
||||||
|
if (e.source === "agent_run") return firstLine(e.title);
|
||||||
|
return e.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 第二行:任务的工作流(有业务标题时)、错误或摘要 */
|
||||||
|
export function logSubtitle(e: LogEntry): string | null {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (e.source === "engine_run" && (e.extra as { businessTitle?: string }).businessTitle)
|
||||||
|
parts.push(workflowLabel(String(e.extra.workflowName ?? "")));
|
||||||
|
if (e.source === "schedule_run" && e.extra.jobKind === "agent_check") parts.push("智能体巡检");
|
||||||
|
if (e.source === "agent_run") parts.push("智能体后台调查");
|
||||||
|
if (e.message) parts.push(firstLine(e.message));
|
||||||
|
return parts.length ? parts.join(" · ") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 操作者 / 触发来源 */
|
||||||
|
export function actorLabel(e: LogEntry): string | null {
|
||||||
|
if (!e.actor) return null;
|
||||||
|
if (e.source === "schedule_run")
|
||||||
|
return e.actor.type === "manual" ? "手动" : e.actor.type === "skip" ? "跳过" : "定时";
|
||||||
|
if (e.source === "agent_run") return "事件";
|
||||||
|
return actorTypeLabel(e.actor.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstLine(s: string, max = 140): string {
|
||||||
|
const line = s.split("\n").find((l) => l.trim()) ?? s;
|
||||||
|
return line.length > max ? `${line.slice(0, max)}…` : line;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 筛选参数(URL query) ──
|
||||||
|
|
||||||
|
export const LOG_FILTER_KEYS = ["type", "level", "since", "q", "objectType", "objectId", "system"] as const;
|
||||||
|
export type LogFilterKey = (typeof LOG_FILTER_KEYS)[number];
|
||||||
|
export type LogFilters = Record<LogFilterKey, string>;
|
||||||
|
|
||||||
|
/** 时间预设下界按分钟取整,查询 key 稳定 */
|
||||||
|
export function sinceToFrom(since: string, now = Date.now()): string | undefined {
|
||||||
|
const ms = LOG_SINCE.find((s) => s.value === since)?.ms;
|
||||||
|
return ms ? new Date(Math.floor((now - ms) / 60_000) * 60_000).toISOString() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 日志页地址(其它页面链到日志用) */
|
||||||
|
export function logsPath(p: Partial<LogFilters> & { tab?: string; job?: string; log?: string } = {}): string {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(p)) if (v) q.set(k, v);
|
||||||
|
const s = q.toString();
|
||||||
|
return s ? `${LOG_PATH}?${s}` : LOG_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 某个对象的全部日志 */
|
||||||
|
export const objectLogsPath = (type: string, id: string) => logsPath({ objectType: type, objectId: id });
|
||||||
|
|
||||||
|
// ── 给智能体的上下文 ──
|
||||||
|
|
||||||
|
const GOAL_MAX = 6000;
|
||||||
|
|
||||||
|
/** 一条日志的文字版(给智能体看,带 id 方便它查) */
|
||||||
|
export function logLine(e: LogEntry): string {
|
||||||
|
const obj = e.object ? ` 对象 ${e.object.type}${e.object.id ? `=${e.object.id}` : ""}` : "";
|
||||||
|
const msg = e.message ? ` · ${firstLine(e.message, 300)}` : "";
|
||||||
|
return `- ${formatDateTime(e.at)} [${LOG_TYPE_LABEL[e.type]} / ${logBadge(e).label}] ${logTitle(e)}${msg}(${e.id}${obj})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选中的日志 → 问智能体的目标(按时间正序,超长截断) */
|
||||||
|
export function agentGoalFor(entries: LogEntry[]): string {
|
||||||
|
const head =
|
||||||
|
entries.length === 1
|
||||||
|
? "看看这条日志,说明发生了什么;有问题的话找出原因并给出处理建议。"
|
||||||
|
: `看看这 ${entries.length} 条日志,说明发生了什么;有问题的话找出原因并给出处理建议。`;
|
||||||
|
const lines: string[] = [];
|
||||||
|
let size = head.length;
|
||||||
|
const sorted = [...entries].sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
|
||||||
|
for (const e of sorted) {
|
||||||
|
const l = logLine(e);
|
||||||
|
if (size + l.length + 1 > GOAL_MAX) {
|
||||||
|
lines.push(`- …另有 ${sorted.length - lines.length} 条未列出`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
lines.push(l);
|
||||||
|
size += l.length + 1;
|
||||||
|
}
|
||||||
|
return `${head}\n${lines.join("\n")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 旧链接 ──
|
||||||
|
|
||||||
|
/** 旧任务页的 status / tab → 级别 */
|
||||||
|
export const RUN_STATUS_LEVEL: Record<string, LogLevel> = {
|
||||||
|
pending: "running",
|
||||||
|
running: "running",
|
||||||
|
completed: "success",
|
||||||
|
failed: "error",
|
||||||
|
canceled: "warn",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 业务对象 kind → 日志对象类型(与服务端 BUSINESS_OBJECT_TYPE 一致) */
|
||||||
|
export const BUSINESS_OBJECT_TYPE: Record<string, string> = {
|
||||||
|
download: "download_task",
|
||||||
|
organization: "organization_record",
|
||||||
|
agent: "agent_run",
|
||||||
|
subscription: "subscription",
|
||||||
|
feed: "feed_source",
|
||||||
|
site: "site",
|
||||||
|
decision: "decision",
|
||||||
|
media: "media_entity",
|
||||||
|
schedule: "scheduled_job",
|
||||||
|
};
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import type { LogEntry } from "@umt/contracts";
|
||||||
|
import { Bot, ExternalLink, History } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { useEngineRun } from "~/api/engine.ts";
|
||||||
|
import { type AgentContext, openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||||
|
import { DetailSection, DetailSheet } 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 { TechInfo } from "~/components/console/tech-info.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 { actorTypeLabel, formatDuration } from "~/lib/labels.ts";
|
||||||
|
import { formatDateTime } from "~/lib/utils.ts";
|
||||||
|
import {
|
||||||
|
actorLabel,
|
||||||
|
agentGoalFor,
|
||||||
|
LOG_TYPE_LABEL,
|
||||||
|
logBadge,
|
||||||
|
logsPath,
|
||||||
|
logTitle,
|
||||||
|
objectLogsPath,
|
||||||
|
objectText,
|
||||||
|
TARGET_LABEL,
|
||||||
|
} from "./labels.ts";
|
||||||
|
|
||||||
|
// 日志详情抽屉:任务复用原任务详情(业务摘要、时间线、引擎步骤、取消 / 重跑);定时任务运行、后台调查、审计各自一组字段。
|
||||||
|
// 底部「问智能体」带上这条日志打开智能体抽屉。
|
||||||
|
|
||||||
|
/** 选中的日志 → 智能体上下文(单条时带上能识别的对象 id,服务端据此取数) */
|
||||||
|
export function agentContextFor(entries: LogEntry[]): AgentContext {
|
||||||
|
const ctx: AgentContext = {
|
||||||
|
label: entries.length === 1 ? `日志:${logTitle(entries[0]!)}` : `日志:${entries.length} 条`,
|
||||||
|
goal: agentGoalFor(entries),
|
||||||
|
};
|
||||||
|
if (entries.length === 1) {
|
||||||
|
const e = entries[0]!;
|
||||||
|
if (e.source === "engine_run") ctx.taskId = e.refId;
|
||||||
|
const o = e.object;
|
||||||
|
if (o?.id) {
|
||||||
|
if (o.type === "download_task") ctx.downloadTaskId = o.id;
|
||||||
|
else if (o.type === "organization_record") ctx.recordId = o.id;
|
||||||
|
else if (o.type === "media_entity") ctx.entityId = o.id;
|
||||||
|
else if (o.type === "site") ctx.siteId = o.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AskAgentButton({ entries, size = "sm" }: { entries: LogEntry[]; size?: "sm" | "xs" }) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size={size}
|
||||||
|
disabled={!entries.length}
|
||||||
|
onClick={() => openAgentDrawer(agentContextFor(entries))}
|
||||||
|
>
|
||||||
|
<Bot />
|
||||||
|
问智能体
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkRow({ links }: { links: { to: string; label: string; icon?: ReactNode }[] }) {
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-caption">
|
||||||
|
{links.map((l) => (
|
||||||
|
<li key={l.to}>
|
||||||
|
<Link
|
||||||
|
to={l.to}
|
||||||
|
className="inline-flex items-center gap-1 text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{l.icon ?? <ExternalLink className="h-3.5 w-3.5" />}
|
||||||
|
{l.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScheduleRunBody({ e }: { e: LogEntry }) {
|
||||||
|
const x = e.extra as {
|
||||||
|
jobId?: string;
|
||||||
|
jobKind?: string;
|
||||||
|
trigger?: string;
|
||||||
|
agentRunId?: string | null;
|
||||||
|
summary?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
const jobId = x.jobId ?? e.object?.id ?? "";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DetailSection title="概况">
|
||||||
|
<KvList
|
||||||
|
items={[
|
||||||
|
{ label: "定时任务", value: e.title, wide: true },
|
||||||
|
{ label: "类型", value: x.jobKind === "agent_check" ? "智能体巡检" : "自动任务" },
|
||||||
|
{ label: "触发", value: actorLabel(e) },
|
||||||
|
{ label: "开始", value: formatDateTime(e.at) },
|
||||||
|
{ label: "耗时", value: e.durationMs == null ? "—" : formatDuration(e.durationMs / 1000) },
|
||||||
|
{ label: "摘要", value: x.summary, wide: true },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{x.error ? <p className="mt-3 text-caption break-words text-destructive">{x.error}</p> : null}
|
||||||
|
</DetailSection>
|
||||||
|
<DetailSection title="相关">
|
||||||
|
<LinkRow
|
||||||
|
links={[
|
||||||
|
...(jobId
|
||||||
|
? [
|
||||||
|
{ to: logsPath({ tab: "schedules", job: jobId }), label: "定时任务设置" },
|
||||||
|
{
|
||||||
|
to: objectLogsPath("scheduled_job", jobId),
|
||||||
|
label: "这个定时任务的全部运行",
|
||||||
|
icon: <History className="h-3.5 w-3.5" />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(x.agentRunId ? [{ to: `/console/agent/${encodeURIComponent(x.agentRunId)}`, label: "巡检会话" }] : []),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</DetailSection>
|
||||||
|
<TechInfo>
|
||||||
|
<KvList
|
||||||
|
items={[
|
||||||
|
{ label: "运行记录 ID", value: e.refId, mono: true },
|
||||||
|
{ label: "定时任务 ID", value: jobId, mono: true },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TechInfo>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentRunBody({ e }: { e: LogEntry }) {
|
||||||
|
const x = e.extra as { toolCalls?: number; spawnReason?: string | null; model?: string | null };
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DetailSection title="概况">
|
||||||
|
<KvList
|
||||||
|
items={[
|
||||||
|
{ label: "目标", value: <span className="whitespace-pre-wrap">{e.title}</span>, wide: true },
|
||||||
|
{ label: "开始", value: formatDateTime(e.at) },
|
||||||
|
{ label: "耗时", value: e.durationMs == null ? "—" : formatDuration(e.durationMs / 1000) },
|
||||||
|
{ label: "结果", value: e.message, wide: true },
|
||||||
|
{ label: "工具调用", value: x.toolCalls ?? 0 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</DetailSection>
|
||||||
|
<DetailSection title="相关">
|
||||||
|
<LinkRow links={[{ to: `/console/agent/${encodeURIComponent(e.refId)}`, label: "打开会话" }]} />
|
||||||
|
</DetailSection>
|
||||||
|
<TechInfo>
|
||||||
|
<KvList
|
||||||
|
items={[
|
||||||
|
{ label: "会话 ID", value: e.refId, mono: true },
|
||||||
|
{ label: "模型", value: x.model, mono: true },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TechInfo>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuditBody({ e }: { e: LogEntry }) {
|
||||||
|
const x = e.extra as { action?: string; traceId?: string | null; payload?: unknown };
|
||||||
|
const o = e.object;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DetailSection title="事件">
|
||||||
|
<KvList
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: "操作者",
|
||||||
|
value: e.actor ? `${actorTypeLabel(e.actor.type)}${e.actor.id ? ` · ${e.actor.id}` : ""}` : "—",
|
||||||
|
wide: true,
|
||||||
|
},
|
||||||
|
{ label: "对象", value: o ? (TARGET_LABEL[o.type] ?? o.type) : "—" },
|
||||||
|
{ label: "摘要", value: e.message },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</DetailSection>
|
||||||
|
{o?.id ? (
|
||||||
|
<DetailSection title="相关">
|
||||||
|
<LinkRow
|
||||||
|
links={[
|
||||||
|
{
|
||||||
|
to: objectLogsPath(o.type, o.id),
|
||||||
|
label: "同一对象的全部日志",
|
||||||
|
icon: <History className="h-3.5 w-3.5" />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</DetailSection>
|
||||||
|
) : null}
|
||||||
|
<JsonView label="负载" value={x.payload} />
|
||||||
|
<TechInfo>
|
||||||
|
<KvList
|
||||||
|
items={[
|
||||||
|
{ label: "动作", value: x.action, mono: true },
|
||||||
|
{ label: "对象类型", value: o?.type, mono: true },
|
||||||
|
{ label: "对象 ID", value: o?.id, mono: true },
|
||||||
|
{ label: "Trace", value: x.traceId, mono: true },
|
||||||
|
{ label: "事件 ID", value: e.refId, mono: true },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TechInfo>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务:按引擎运行 id 取详情(列表里没有这条也能打开,旧链接 ?run= 用) */
|
||||||
|
function RunSheet({
|
||||||
|
runId,
|
||||||
|
entry,
|
||||||
|
onOpenRun,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
runId: string;
|
||||||
|
entry: LogEntry | null;
|
||||||
|
onOpenRun: (id: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const q = useEngineRun(runId);
|
||||||
|
const r = q.data;
|
||||||
|
return (
|
||||||
|
<DetailSheet
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
width="lg"
|
||||||
|
title={r ? runTitle(r) : entry ? logTitle(entry) : "任务"}
|
||||||
|
description={r ? <TaskStatusLine r={r} /> : undefined}
|
||||||
|
footer={
|
||||||
|
r ? (
|
||||||
|
<>
|
||||||
|
<Button asChild variant="ghost" size="sm">
|
||||||
|
<Link to={`/console/tasks/${encodeURIComponent(r.id)}`}>
|
||||||
|
<ExternalLink />
|
||||||
|
独立页面
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{entry ? <AskAgentButton entries={[entry]} /> : null}
|
||||||
|
<RunActions r={r} onOpenRun={onOpenRun} />
|
||||||
|
</>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{q.isPending ? (
|
||||||
|
<LoadingState rows={6} />
|
||||||
|
) : q.isError ? (
|
||||||
|
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
|
||||||
|
) : r ? (
|
||||||
|
<TaskDetailBody r={r} onOpenRun={onOpenRun} />
|
||||||
|
) : null}
|
||||||
|
</DetailSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志详情抽屉。open = 日志 id(<source>:<原始 id>);任务类只要 id 就能打开,其余要在已加载的列表里找到这条。
|
||||||
|
*/
|
||||||
|
export function LogDetailSheet({
|
||||||
|
logId,
|
||||||
|
entry,
|
||||||
|
onOpenLog,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
logId: string | null;
|
||||||
|
entry: LogEntry | null;
|
||||||
|
onOpenLog: (id: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
if (!logId) return null;
|
||||||
|
const sep = logId.indexOf(":");
|
||||||
|
const source = logId.slice(0, sep);
|
||||||
|
const refId = logId.slice(sep + 1);
|
||||||
|
if (source === "engine_run")
|
||||||
|
return <RunSheet runId={refId} entry={entry} onOpenRun={(id) => onOpenLog(`engine_run:${id}`)} onClose={onClose} />;
|
||||||
|
const badge = entry ? logBadge(entry) : null;
|
||||||
|
return (
|
||||||
|
<DetailSheet
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
title={entry ? logTitle(entry) : "日志"}
|
||||||
|
description={
|
||||||
|
entry ? (
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
{badge ? <StatusBadge tone={badge.tone}>{badge.label}</StatusBadge> : null}
|
||||||
|
<span>{LOG_TYPE_LABEL[entry.type]}</span>
|
||||||
|
<span>· {formatDateTime(entry.at)}</span>
|
||||||
|
{entry.object ? <span>· {objectText(entry.object)}</span> : null}
|
||||||
|
</span>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
footer={entry ? <AskAgentButton entries={[entry]} /> : null}
|
||||||
|
>
|
||||||
|
{!entry ? (
|
||||||
|
<EmptyState plain title="这条日志不在当前列表里" description="换个筛选或时间范围再找。" className="py-6" />
|
||||||
|
) : entry.source === "schedule_run" ? (
|
||||||
|
<ScheduleRunBody e={entry} />
|
||||||
|
) : entry.source === "agent_run" ? (
|
||||||
|
<AgentRunBody e={entry} />
|
||||||
|
) : (
|
||||||
|
<AuditBody e={entry} />
|
||||||
|
)}
|
||||||
|
</DetailSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { LOG_STREAM_EVENT, type LogEntry } from "@umt/contracts";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { MemoryRouter } from "react-router";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { LogStreamClient, type LogStreamStatus, upsertEntries } from "~/api/logs.ts";
|
||||||
|
import { qk } from "~/lib/query-keys.ts";
|
||||||
|
import LogsPage from "~/routes/console/logs.tsx";
|
||||||
|
import { agentGoalFor, logBadge, logSubtitle, logsPath, logTitle, objectText, sinceToFrom } from "./labels.ts";
|
||||||
|
import { agentContextFor } from "./log-detail.tsx";
|
||||||
|
|
||||||
|
// 日志页:标题 / 徽章 / 摘要、筛选参数、流的暂停与续传、问智能体的上下文、页面渲染。
|
||||||
|
|
||||||
|
const entry = (over: Partial<LogEntry>): LogEntry => ({
|
||||||
|
id: "audit_event:1",
|
||||||
|
type: "audit",
|
||||||
|
source: "audit_event",
|
||||||
|
refId: "1",
|
||||||
|
at: "2026-10-06T08:00:00.000Z",
|
||||||
|
finishedAt: null,
|
||||||
|
durationMs: null,
|
||||||
|
level: "info",
|
||||||
|
status: "settings.update",
|
||||||
|
title: "settings.update",
|
||||||
|
message: null,
|
||||||
|
object: null,
|
||||||
|
actor: { type: "user", id: "admin" },
|
||||||
|
extra: {},
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const task = entry({
|
||||||
|
id: "engine_run:r1",
|
||||||
|
type: "task",
|
||||||
|
source: "engine_run",
|
||||||
|
refId: "r1",
|
||||||
|
level: "error",
|
||||||
|
status: "failed",
|
||||||
|
title: "沙丘2",
|
||||||
|
message: "qB 拒绝了种子",
|
||||||
|
object: { type: "download_task", id: "d1", label: "沙丘2" },
|
||||||
|
actor: null,
|
||||||
|
extra: { workflowName: "submitDownload", businessKind: "download", businessTitle: "沙丘2" },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("标题、摘要、徽章", () => {
|
||||||
|
it("任务:业务标题 + 工作流;没有业务对象时写做什么", () => {
|
||||||
|
expect(logTitle(task)).toBe("沙丘2");
|
||||||
|
expect(logSubtitle(task)).toBe("提交下载 · qB 拒绝了种子");
|
||||||
|
expect(logBadge(task)).toEqual({ label: "失败", tone: "destructive" });
|
||||||
|
const sys = { ...task, extra: { workflowName: "snapshotSite" }, message: null, level: "success" as const };
|
||||||
|
expect(logTitle(sys)).toBe("站点抓取");
|
||||||
|
// 智能体会话超出预算:级别 warn,徽章写具体结果
|
||||||
|
const agent = { ...task, level: "warn" as const, status: "completed", extra: { outcome: "budget_exceeded" } };
|
||||||
|
expect(logBadge(agent).label).toBe("超出预算");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("定时任务:已跳过 / 巡检;审计:动作中文名;对象文字", () => {
|
||||||
|
const skipped = entry({ source: "schedule_run", type: "auto", status: "skipped", title: "RSS 抓取" });
|
||||||
|
expect(logBadge(skipped).label).toBe("已跳过");
|
||||||
|
const check = entry({
|
||||||
|
source: "schedule_run",
|
||||||
|
type: "auto",
|
||||||
|
status: "ok",
|
||||||
|
level: "success",
|
||||||
|
extra: { jobKind: "agent_check" },
|
||||||
|
});
|
||||||
|
expect(logSubtitle(check)).toBe("智能体巡检");
|
||||||
|
expect(logTitle(entry({ status: "admin.login_failed", type: "auth" }))).toBe("登录失败");
|
||||||
|
expect(logTitle(entry({ status: "downloader_instance.create" }))).toBe("新建下载器");
|
||||||
|
expect(objectText({ type: "scheduled_job", id: "x", label: null })).toBe("定时任务");
|
||||||
|
expect(objectText({ type: "download_task", id: "x", label: "沙丘2" })).toBe("沙丘2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("筛选参数", () => {
|
||||||
|
it("时间预设按分钟取整;未知预设为不限", () => {
|
||||||
|
const now = Date.parse("2026-10-06T08:30:45.123Z");
|
||||||
|
expect(sinceToFrom("1h", now)).toBe("2026-10-06T07:30:00.000Z");
|
||||||
|
expect(sinceToFrom("", now)).toBeUndefined();
|
||||||
|
expect(sinceToFrom("bogus", now)).toBeUndefined();
|
||||||
|
});
|
||||||
|
it("logsPath 只带非空参数", () => {
|
||||||
|
expect(logsPath()).toBe("/console/logs");
|
||||||
|
expect(logsPath({ type: "task", level: "", objectId: "a b" })).toBe("/console/logs?type=task&objectId=a+b");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("流", () => {
|
||||||
|
/** 假 EventSource:记下 URL,可手动派发事件 */
|
||||||
|
function fakeFactory() {
|
||||||
|
const made: { url: string; closed: boolean; fire: (type: string, data?: unknown) => void; error: () => void }[] =
|
||||||
|
[];
|
||||||
|
const factory = (url: string) => {
|
||||||
|
const listeners = new Map<string, ((ev: MessageEvent<string>) => void)[]>();
|
||||||
|
const es = {
|
||||||
|
onerror: null as ((ev: Event) => unknown) | null,
|
||||||
|
addEventListener: (t: string, fn: (ev: MessageEvent<string>) => void) =>
|
||||||
|
listeners.set(t, [...(listeners.get(t) ?? []), fn]),
|
||||||
|
close: () => {
|
||||||
|
rec.closed = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const rec = {
|
||||||
|
url,
|
||||||
|
closed: false,
|
||||||
|
fire: (type: string, data: unknown = {}) => {
|
||||||
|
for (const fn of listeners.get(type) ?? []) fn({ data: JSON.stringify(data) } as MessageEvent<string>);
|
||||||
|
},
|
||||||
|
error: () => es.onerror?.(new Event("error")),
|
||||||
|
};
|
||||||
|
made.push(rec);
|
||||||
|
return es as unknown as EventSource;
|
||||||
|
};
|
||||||
|
return { made, factory };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("连接带筛选与 since;暂停断开;继续从收到的最新一条续上", () => {
|
||||||
|
const { made, factory } = fakeFactory();
|
||||||
|
const got: LogEntry[] = [];
|
||||||
|
const statuses: LogStreamStatus[] = [];
|
||||||
|
const c = new LogStreamClient(
|
||||||
|
{ type: "auth", level: "warn" },
|
||||||
|
{ onEntry: (e) => got.push(e), onStatus: (s) => statuses.push(s) },
|
||||||
|
"2026-10-06T08:00:00.000Z",
|
||||||
|
factory,
|
||||||
|
);
|
||||||
|
c.start();
|
||||||
|
expect(made).toHaveLength(1);
|
||||||
|
const u = new URL(made[0]!.url, "http://x");
|
||||||
|
expect(u.pathname).toBe("/api/v1/logs/stream");
|
||||||
|
expect(Object.fromEntries(u.searchParams)).toEqual({
|
||||||
|
type: "auth",
|
||||||
|
level: "warn",
|
||||||
|
since: "2026-10-06T08:00:00.000Z",
|
||||||
|
});
|
||||||
|
made[0]!.fire("ready");
|
||||||
|
made[0]!.fire(LOG_STREAM_EVENT, entry({ id: "audit_event:2", at: "2026-10-06T08:05:00.000Z" }));
|
||||||
|
expect(got.map((e) => e.id)).toEqual(["audit_event:2"]);
|
||||||
|
expect(statuses).toEqual(["connecting", "open"]);
|
||||||
|
|
||||||
|
// 暂停
|
||||||
|
c.stop();
|
||||||
|
expect(made[0]!.closed).toBe(true);
|
||||||
|
expect(statuses.at(-1)).toBe("off");
|
||||||
|
// 暂停期间列表刷新出更旧的条目不会把起点往回拨
|
||||||
|
c.advance("2026-10-06T07:00:00.000Z");
|
||||||
|
c.start();
|
||||||
|
expect(made).toHaveLength(2);
|
||||||
|
expect(new URL(made[1]!.url, "http://x").searchParams.get("since")).toBe("2026-10-06T08:05:00.000Z");
|
||||||
|
c.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("出错后重连;停止后不再重连", () => {
|
||||||
|
const { made, factory } = fakeFactory();
|
||||||
|
const statuses: LogStreamStatus[] = [];
|
||||||
|
const c = new LogStreamClient({}, { onEntry: () => {}, onStatus: (s) => statuses.push(s) }, null, factory);
|
||||||
|
c.start();
|
||||||
|
made[0]!.error();
|
||||||
|
expect(statuses).toContain("reconnecting");
|
||||||
|
c.stop();
|
||||||
|
made[0]!.error();
|
||||||
|
expect(made).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("按 id 覆盖合并,时间倒序", () => {
|
||||||
|
const a = entry({ id: "a", at: "2026-10-06T08:00:00.000Z" });
|
||||||
|
const b = entry({ id: "b", at: "2026-10-06T08:01:00.000Z", level: "running" });
|
||||||
|
const b2 = { ...b, level: "success" as const };
|
||||||
|
const c = entry({ id: "c", at: "2026-10-06T07:59:00.000Z" });
|
||||||
|
expect(upsertEntries([b, a], [c, b2]).map((e) => [e.id, e.level])).toEqual([
|
||||||
|
["b", "success"],
|
||||||
|
["a", "info"],
|
||||||
|
["c", "info"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("问智能体", () => {
|
||||||
|
it("目标里按时间正序列出日志与 id;单条任务带上任务与下载任务上下文", () => {
|
||||||
|
const later = entry({ id: "audit_event:9", at: "2026-10-06T09:00:00.000Z", status: "capability.denied" });
|
||||||
|
const goal = agentGoalFor([later, task]);
|
||||||
|
expect(goal.startsWith("看看这 2 条日志")).toBe(true);
|
||||||
|
expect(goal.indexOf("engine_run:r1")).toBeLessThan(goal.indexOf("audit_event:9"));
|
||||||
|
expect(goal).toContain("qB 拒绝了种子");
|
||||||
|
const ctx = agentContextFor([task]);
|
||||||
|
expect(ctx).toMatchObject({ taskId: "r1", downloadTaskId: "d1", label: "日志:沙丘2" });
|
||||||
|
expect(agentContextFor([task, later]).label).toBe("日志:2 条");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("太长时截断,不超过智能体目标上限", () => {
|
||||||
|
const many = Array.from({ length: 200 }, (_, i) =>
|
||||||
|
entry({
|
||||||
|
id: `audit_event:${i}`,
|
||||||
|
message: "x".repeat(200),
|
||||||
|
at: new Date(Date.UTC(2026, 9, 6, 0, i)).toISOString(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const goal = agentGoalFor(many);
|
||||||
|
expect(goal.length).toBeLessThanOrEqual(6100);
|
||||||
|
expect(goal).toMatch(/另有 \d+ 条未列出/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("日志页渲染", () => {
|
||||||
|
it("筛选写在 URL;列表显示时间、类型、结果、摘要;默认实时", () => {
|
||||||
|
const client = new QueryClient();
|
||||||
|
client.setQueryData(qk.list("logs", { type: "task" }), {
|
||||||
|
pages: [{ items: [task], nextCursor: null }],
|
||||||
|
pageParams: [undefined],
|
||||||
|
});
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
createElement(
|
||||||
|
QueryClientProvider,
|
||||||
|
{ client },
|
||||||
|
createElement(MemoryRouter, { initialEntries: ["/console/logs?type=task"] }, createElement(LogsPage)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(html).toContain("定时任务");
|
||||||
|
expect(html).toContain("沙丘2");
|
||||||
|
expect(html).toContain("qB 拒绝了种子");
|
||||||
|
expect(html).toContain("暂停");
|
||||||
|
expect(html).toContain("显示例行记录");
|
||||||
|
expect(html).toContain("问智能体");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("定时任务页签渲染计划看板", () => {
|
||||||
|
const client = new QueryClient();
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
createElement(
|
||||||
|
QueryClientProvider,
|
||||||
|
{ client },
|
||||||
|
createElement(MemoryRouter, { initialEntries: ["/console/logs?tab=schedules"] }, createElement(LogsPage)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(html).toContain('aria-selected="true"');
|
||||||
|
expect(html).not.toContain("显示例行记录");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,13 +20,15 @@ describe("计划页标签", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("planPath:节锚点与 ?job= 高亮", () => {
|
it("planPath:节锚点与 ?job= 高亮", () => {
|
||||||
expect(planPath()).toBe("/console/schedules");
|
expect(planPath()).toBe("/console/logs?tab=schedules");
|
||||||
expect(planPath({ section: "auto" })).toBe("/console/schedules#auto");
|
expect(planPath({ section: "auto" })).toBe("/console/logs?tab=schedules#auto");
|
||||||
expect(planPath({ section: "checks", job: "check.pending" })).toBe("/console/schedules?job=check.pending#checks");
|
expect(planPath({ section: "checks", job: "check.pending" })).toBe(
|
||||||
|
"/console/logs?tab=schedules&job=check.pending#checks",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("运行记录跳任务页按定时任务筛选", () => {
|
it("运行记录跳日志页按定时任务筛选", () => {
|
||||||
expect(scheduleStreamPath("trash.purge")).toBe("/console/tasks?tab=all&kind=schedule&ref=trash.purge");
|
expect(scheduleStreamPath("trash.purge")).toBe("/console/logs?objectType=scheduled_job&objectId=trash.purge");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formatUsd:两位小数,不足一分写 < $0.01", () => {
|
it("formatUsd:两位小数,不足一分写 < $0.01", () => {
|
||||||
|
|||||||
@@ -13,16 +13,17 @@ export const GROUP_ORDER: ScheduleGroup[] = ["collect", "download", "organize",
|
|||||||
/** 计划页「自动任务」一节的分组(智能体巡检单独一节,放在最上面);维护类合成一组「数据清理」 */
|
/** 计划页「自动任务」一节的分组(智能体巡检单独一节,放在最上面);维护类合成一组「数据清理」 */
|
||||||
export const TASK_GROUPS: ScheduleGroup[] = ["collect", "download", "organize", "maintain"];
|
export const TASK_GROUPS: ScheduleGroup[] = ["collect", "download", "organize", "maintain"];
|
||||||
|
|
||||||
/** 计划页两节的锚点:/console/schedules#auto、#checks */
|
/** 定时任务视图两节的锚点:/console/logs?tab=schedules#auto、#checks(界面第二轮:计划页并入日志页的「定时任务」页签) */
|
||||||
export type PlanSection = "auto" | "checks";
|
export type PlanSection = "auto" | "checks";
|
||||||
export const PLAN_PATH = "/console/schedules";
|
export const PLAN_PATH = "/console/logs?tab=schedules";
|
||||||
export function planPath(opts: { section?: PlanSection; job?: string } = {}): string {
|
export function planPath(opts: { section?: PlanSection; job?: string } = {}): string {
|
||||||
const q = opts.job ? `?job=${encodeURIComponent(opts.job)}` : "";
|
const q = opts.job ? `&job=${encodeURIComponent(opts.job)}` : "";
|
||||||
return `${PLAN_PATH}${q}${opts.section ? `#${opts.section}` : ""}`;
|
return `${PLAN_PATH}${q}${opts.section ? `#${opts.section}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 任务页里某个定时任务触发的任务(businessKind=schedule) */
|
/** 日志页里某个定时任务的全部运行记录(含它触发的任务与修改留痕) */
|
||||||
export const scheduleStreamPath = (id: string) => `/console/tasks?tab=all&kind=schedule&ref=${encodeURIComponent(id)}`;
|
export const scheduleStreamPath = (id: string) =>
|
||||||
|
`/console/logs?objectType=scheduled_job&objectId=${encodeURIComponent(id)}`;
|
||||||
|
|
||||||
export const GROUP_LABEL: Record<ScheduleGroup, string> = {
|
export const GROUP_LABEL: Record<ScheduleGroup, string> = {
|
||||||
collect: "采集",
|
collect: "采集",
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ import { DraftCheckDialog } from "./draft-check-dialog.tsx";
|
|||||||
import { formatUsd } from "./labels.ts";
|
import { formatUsd } from "./labels.ts";
|
||||||
import { RunsSheet } from "./runs-sheet.tsx";
|
import { RunsSheet } from "./runs-sheet.tsx";
|
||||||
|
|
||||||
// 计划页(FE-6,/console/schedules):「智能体巡检」= 到点发起智能体会话的定时任务(卡片,放最上面:
|
// 定时任务(FE-6 的计划页;界面第二轮并入日志页「定时任务」页签,/console/logs?tab=schedules):「智能体巡检」= 到点发起智能体会话的定时任务(卡片,放最上面:
|
||||||
// 数量少、由用户自己建、会花钱,是这页最需要看的);「自动任务」= 程序按固定周期执行的定时任务
|
// 数量少、由用户自己建、会花钱,是这页最需要看的);「自动任务」= 程序按固定周期执行的定时任务
|
||||||
// (按分类折叠的紧凑列表,维护类合成「数据清理」)。自动任务的运行记录跳任务页按该定时任务筛选。
|
// (按分类折叠的紧凑列表,维护类合成「数据清理」)。自动任务的运行记录跳日志页按该定时任务筛选。
|
||||||
|
|
||||||
/** 每秒一跳的当前时间(倒计时用) */
|
/** 每秒一跳的当前时间(倒计时用) */
|
||||||
export function useNow(ms = 1000) {
|
export function useNow(ms = 1000) {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export const BUSINESS_KIND_LABEL: Record<EngineBusinessKind, string> = {
|
|||||||
decision: "待处理事项",
|
decision: "待处理事项",
|
||||||
media: "作品",
|
media: "作品",
|
||||||
};
|
};
|
||||||
export const BUSINESS_KINDS = Object.keys(BUSINESS_KIND_LABEL) as EngineBusinessKind[];
|
|
||||||
|
|
||||||
/** 来源:四类(定时/事件/手动/Agent);API Key 调用算手动 */
|
/** 来源:四类(定时/事件/手动/Agent);API Key 调用算手动 */
|
||||||
export type TaskSource = "schedule" | "event" | "manual" | "agent";
|
export type TaskSource = "schedule" | "event" | "manual" | "agent";
|
||||||
@@ -25,13 +24,6 @@ export const SOURCE_LABEL: Record<TaskSource, string> = {
|
|||||||
manual: "手动",
|
manual: "手动",
|
||||||
agent: "智能体",
|
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 {
|
export function sourceOf(t: EngineTriggeredBy | null | undefined): TaskSource | null {
|
||||||
if (!t) return null;
|
if (!t) return null;
|
||||||
if (t === "user" || t === "api_key") return "manual";
|
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 OUTCOME_STATUS[outcome];
|
||||||
return runStatus(r.status);
|
return runStatus(r.status);
|
||||||
}
|
}
|
||||||
export const RUN_STATUS_OPTIONS = ["pending", "running", "completed", "failed", "canceled"].map((value) => ({
|
|
||||||
value,
|
|
||||||
label: runStatus(value).label,
|
|
||||||
}));
|
|
||||||
|
|
||||||
/** 行标题:业务对象名 > 业务类型 + 短 id > 工作流做什么 */
|
/** 行标题:业务对象名 > 业务类型 + 短 id > 工作流做什么 */
|
||||||
export function runTitle(r: Pick<EngineRun, "business" | "workflowName">): string {
|
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":
|
case "agent":
|
||||||
return { to: `/console/agent/${id}`, label: "智能体会话" };
|
return { to: `/console/agent/${id}`, label: "智能体会话" };
|
||||||
case "schedule":
|
case "schedule":
|
||||||
return { to: `/console/schedules?job=${id}`, label: "定时任务" };
|
return { to: `/console/logs?tab=schedules&job=${id}`, label: "定时任务" };
|
||||||
case "feed":
|
case "feed":
|
||||||
// RSS 属于站点(IA-2):旧 RSS 页按 ?feed= 查所属站点后跳到站点的 RSS Tab
|
// RSS 属于站点(IA-2):旧 RSS 页按 ?feed= 查所属站点后跳到站点的 RSS Tab
|
||||||
return { to: `${consolePath("sources")}?feed=${encodeURIComponent(id)}`, label: "RSS" };
|
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;
|
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 { KvList } from "~/components/console/kv-list.tsx";
|
||||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||||
import { Timeline, type TimelineItem } from "~/components/console/timeline.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 { Button } from "~/components/ui/button.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
import { formatDuration, formatRelative, spanSec } from "~/lib/labels.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 link = businessLink(b, r.input);
|
||||||
const src = sourceOf(b?.triggeredBy);
|
const src = sourceOf(b?.triggeredBy);
|
||||||
const hasError = r.error !== null && r.error !== undefined;
|
const hasError = r.error !== null && r.error !== undefined;
|
||||||
|
// 相关日志(日志页按对象筛选):本任务的操作留痕;关联对象的全部日志(任务、定时任务运行、审计)
|
||||||
const audits = [
|
const audits = [
|
||||||
{ to: `/console/audit?targetType=engine_run&targetId=${encodeURIComponent(r.id)}`, label: "本任务的操作记录" },
|
{ to: objectLogsPath("engine_run", r.id), label: "本任务的操作记录" },
|
||||||
...(b?.kind === "schedule"
|
...(b && BUSINESS_OBJECT_TYPE[b.kind]
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
to: `/console/audit?targetType=scheduled_job&targetId=${encodeURIComponent(b.refId)}`,
|
to: objectLogsPath(BUSINESS_OBJECT_TYPE[b.kind]!, b.refId),
|
||||||
label: "定时任务的修改记录",
|
label: b.kind === "schedule" ? "定时任务的全部日志" : "关联对象的全部日志",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
@@ -287,7 +289,7 @@ export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun
|
|||||||
<Timeline items={businessTimeline(r)} />
|
<Timeline items={businessTimeline(r)} />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection title="相关审计">
|
<DetailSection title="相关日志">
|
||||||
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-caption">
|
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-caption">
|
||||||
{audits.map((a) => (
|
{audits.map((a) => (
|
||||||
<li key={a.to}>
|
<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>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -37,6 +37,7 @@ export default [
|
|||||||
route("engine", "routes/console/engine.tsx"),
|
route("engine", "routes/console/engine.tsx"),
|
||||||
route("engine/:runId", "routes/console/engine.$runId.tsx"),
|
route("engine/:runId", "routes/console/engine.$runId.tsx"),
|
||||||
route("audit", "routes/console/audit.tsx"),
|
route("audit", "routes/console/audit.tsx"),
|
||||||
|
route("logs", "routes/console/logs.tsx"),
|
||||||
route("settings", "routes/console/settings.tsx"),
|
route("settings", "routes/console/settings.tsx"),
|
||||||
route("schedules", "routes/console/schedules.tsx"),
|
route("schedules", "routes/console/schedules.tsx"),
|
||||||
route("search", "routes/console/search.tsx"),
|
route("search", "routes/console/search.tsx"),
|
||||||
|
|||||||
@@ -1,266 +1,8 @@
|
|||||||
import { AuditAction, type AuditEvent, CAPABILITY_CATALOG, type KnownCapabilityId } from "@umt/contracts";
|
import { legacyRedirectLoader } from "./redirects.ts";
|
||||||
import { X } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useSearchParams } from "react-router";
|
|
||||||
import { useAuditEvents } from "~/api/audit.ts";
|
|
||||||
import { DataTable } from "~/components/console/data-table.tsx";
|
|
||||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
|
||||||
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 媒体自动化控制台" }];
|
// 界面第二轮:审计页并入日志页(登录、API Key 在「登录」类,其余在「审计」类);?targetType=&targetId= 换成对象筛选。
|
||||||
|
export const clientLoader = legacyRedirectLoader;
|
||||||
|
|
||||||
const actorLabel = actorTypeLabel;
|
export default function LegacyRedirect() {
|
||||||
|
return null;
|
||||||
const ACTION_LABEL: Record<string, string> = {
|
|
||||||
"admin.setup": "初始化管理员",
|
|
||||||
"admin.login_success": "登录",
|
|
||||||
"admin.logout": "退出登录",
|
|
||||||
"admin.password_change": "修改密码",
|
|
||||||
"admin.totp_enable": "启用二次验证",
|
|
||||||
"admin.totp_disable": "停用二次验证",
|
|
||||||
"api_key.create": "创建 API Key",
|
|
||||||
"api_key.update": "修改 API Key",
|
|
||||||
"api_key.revoke": "撤销 API Key",
|
|
||||||
"settings.update": "修改设置",
|
|
||||||
"secret.create": "新建凭证",
|
|
||||||
"secret.rotate": "轮换凭证",
|
|
||||||
"secret.delete": "删除凭证",
|
|
||||||
"site_account.credentials_set": "录入站点凭证",
|
|
||||||
"engine.run_cancel": "取消任务",
|
|
||||||
"pi_upstream.oauth_start": "开始订阅登录",
|
|
||||||
"pi_upstream.oauth_login": "完成订阅登录",
|
|
||||||
"pi_upstream.oauth_logout": "退出订阅登录",
|
|
||||||
"schedule.run_now": "立即执行定时任务",
|
|
||||||
"schedule.skip": "跳过定时任务",
|
|
||||||
"acquisition_plan.manual_submit": "手动提交下载",
|
|
||||||
"acquisition_plan.cancel": "取消下载",
|
|
||||||
"engine.run_retry": "重跑任务",
|
|
||||||
"capability.invoke": "智能体操作",
|
|
||||||
"capability.denied": "智能体操作被拒绝",
|
|
||||||
"site.created": "登记站点",
|
|
||||||
"site.updated": "修改站点",
|
|
||||||
"feed_source.created": "新建 RSS",
|
|
||||||
"identity.release_assigned": "指定资源身份",
|
|
||||||
"schedule.update": "修改定时任务",
|
|
||||||
"files.mkdir": "新建目录",
|
|
||||||
"files.rename": "重命名",
|
|
||||||
"files.move": "移动文件",
|
|
||||||
"files.copy": "复制文件",
|
|
||||||
"files.organize": "整理文件",
|
|
||||||
"files.delete": "删除文件",
|
|
||||||
"files.trash_restore": "从回收站还原",
|
|
||||||
};
|
|
||||||
const VERB: Record<string, string> = { create: "新建", update: "修改", delete: "删除", test: "测试", verify: "校验" };
|
|
||||||
const NOUN: Record<string, string> = {
|
|
||||||
library_root: "媒体库目录",
|
|
||||||
downloader_instance: "下载器",
|
|
||||||
media_server_instance: "媒体服务器",
|
|
||||||
path_mapping: "路径映射",
|
|
||||||
pi_upstream: "模型服务",
|
|
||||||
schedule: "定时任务",
|
|
||||||
automation_grant: "持续授权",
|
|
||||||
};
|
|
||||||
const TARGET_LABEL: Record<string, string> = {
|
|
||||||
...NOUN,
|
|
||||||
admin_user: "管理员",
|
|
||||||
api_key: "API Key",
|
|
||||||
settings: "设置",
|
|
||||||
secret: "凭证",
|
|
||||||
site: "站点",
|
|
||||||
site_account: "站点账号",
|
|
||||||
feed_source: "RSS",
|
|
||||||
engine_run: "任务",
|
|
||||||
acquisition_plan: "下载",
|
|
||||||
download_task: "下载任务",
|
|
||||||
media_entity: "作品",
|
|
||||||
source_release: "资源",
|
|
||||||
organization_record: "整理记录",
|
|
||||||
file: "文件",
|
|
||||||
capability: "能力",
|
|
||||||
};
|
|
||||||
/** 对象列:能力显示中文名,其余显示对象类型;ID 只在详情里 */
|
|
||||||
function targetText(type: string, id: string | null): string {
|
|
||||||
if (type === "capability" && id) return CAPABILITY_CATALOG[id as KnownCapabilityId]?.title ?? id;
|
|
||||||
return TARGET_LABEL[type] ?? type;
|
|
||||||
}
|
|
||||||
function actionLabel(a: string): string {
|
|
||||||
if (ACTION_LABEL[a]) return ACTION_LABEL[a];
|
|
||||||
const [noun, verb] = a.split(".");
|
|
||||||
if (noun && verb && NOUN[noun] && VERB[verb]) return `${VERB[verb]}${NOUN[noun]}`;
|
|
||||||
return a;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 前缀组 + 具体动作
|
|
||||||
const ACTION_OPTIONS = [
|
|
||||||
...[
|
|
||||||
"admin",
|
|
||||||
"api_key",
|
|
||||||
"settings",
|
|
||||||
"secret",
|
|
||||||
"library_root",
|
|
||||||
"downloader_instance",
|
|
||||||
"media_server_instance",
|
|
||||||
"path_mapping",
|
|
||||||
].map((p) => ({ value: `${p}.*`, label: `${p}.*(全部)` })),
|
|
||||||
...AuditAction.options.map((a) => ({ value: a, label: `${actionLabel(a)} · ${a}` })),
|
|
||||||
];
|
|
||||||
|
|
||||||
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<AuditEvent | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const t = setTimeout(() => {
|
|
||||||
const v = target.trim();
|
|
||||||
setTargetType(v);
|
|
||||||
setParams(
|
|
||||||
(p) => {
|
|
||||||
if (v) p.set("targetType", v);
|
|
||||||
else p.delete("targetType");
|
|
||||||
return p;
|
|
||||||
},
|
|
||||||
{ replace: true },
|
|
||||||
);
|
|
||||||
}, 350);
|
|
||||||
return () => clearTimeout(t);
|
|
||||||
}, [target, setParams]);
|
|
||||||
|
|
||||||
const setParam = (k: string, v: string) =>
|
|
||||||
setParams(
|
|
||||||
(p) => {
|
|
||||||
if (v) p.set(k, v);
|
|
||||||
else p.delete(k);
|
|
||||||
return p;
|
|
||||||
},
|
|
||||||
{ replace: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const q = useAuditEvents({
|
|
||||||
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 || targetId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader title="审计" />
|
|
||||||
<FilterBar
|
|
||||||
search={{ value: target, onChange: setTarget, placeholder: "对象类型,如 downloader_instance" }}
|
|
||||||
onReset={
|
|
||||||
filtered
|
|
||||||
? () => {
|
|
||||||
setTarget("");
|
|
||||||
setParams({}, { replace: true });
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FilterSelect
|
|
||||||
label="操作者"
|
|
||||||
value={actorType}
|
|
||||||
onChange={(v) => setParam("actorType", v)}
|
|
||||||
options={ACTOR_TYPES}
|
|
||||||
/>
|
|
||||||
<FilterSelect label="动作" value={action} onChange={(v) => setParam("action", v)} options={ACTION_OPTIONS} />
|
|
||||||
{targetId ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setParam("targetId", "")} title={targetId}>
|
|
||||||
对象 <span className="max-w-40 truncate font-mono">{targetId}</span>
|
|
||||||
<X />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</FilterBar>
|
|
||||||
<DataTable<AuditEvent>
|
|
||||||
paginationKey={JSON.stringify({ actorType, action, targetType, targetId })}
|
|
||||||
rows={rows}
|
|
||||||
rowKey={(r) => r.id}
|
|
||||||
loading={q.isPending}
|
|
||||||
error={q.error}
|
|
||||||
onRetry={() => q.refetch()}
|
|
||||||
empty={{
|
|
||||||
title: filtered ? "没有符合条件的审计事件" : "还没有审计事件",
|
|
||||||
}}
|
|
||||||
onRowClick={setSelected}
|
|
||||||
selectedKey={selected?.id ?? null}
|
|
||||||
hasMore={!!q.hasNextPage}
|
|
||||||
onLoadMore={() => q.fetchNextPage()}
|
|
||||||
loadingMore={q.isFetchingNextPage}
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
key: "at",
|
|
||||||
header: "时间",
|
|
||||||
cell: (r) => (
|
|
||||||
<span className="whitespace-nowrap tabular-nums" title={formatDateTime(r.at)}>
|
|
||||||
{formatRelative(r.at)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "action",
|
|
||||||
header: "动作",
|
|
||||||
cell: (r) => (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="font-medium">{actionLabel(r.action)}</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ key: "actor", header: "操作者", hideBelow: "sm", cell: (r) => actorLabel(r.actorType) },
|
|
||||||
{
|
|
||||||
key: "target",
|
|
||||||
header: "对象",
|
|
||||||
hideBelow: "md",
|
|
||||||
cell: (r) =>
|
|
||||||
r.targetType ? (
|
|
||||||
<p className="min-w-0 max-w-[18rem] truncate" title={`${r.targetType} · ${r.targetId ?? ""}`}>
|
|
||||||
{targetText(r.targetType, r.targetId)}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<DetailSheet
|
|
||||||
open={!!selected}
|
|
||||||
onOpenChange={(o) => !o && setSelected(null)}
|
|
||||||
title={selected ? actionLabel(selected.action) : ""}
|
|
||||||
description={selected ? formatDateTime(selected.at) : undefined}
|
|
||||||
>
|
|
||||||
{selected ? (
|
|
||||||
<>
|
|
||||||
<DetailSection title="事件">
|
|
||||||
<KvList
|
|
||||||
items={[
|
|
||||||
{ label: "动作", value: selected.action, mono: true },
|
|
||||||
{
|
|
||||||
label: "操作者",
|
|
||||||
value: `${actorLabel(selected.actorType)}${selected.actorId ? ` · ${selected.actorId}` : ""}`,
|
|
||||||
wide: true,
|
|
||||||
},
|
|
||||||
{ label: "对象类型", value: selected.targetType },
|
|
||||||
{ label: "对象 ID", value: selected.targetId, mono: true },
|
|
||||||
{ label: "Trace", value: selected.traceId, mono: true },
|
|
||||||
{ label: "事件 ID", value: selected.id, mono: true },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</DetailSection>
|
|
||||||
<JsonView label="负载" value={selected.payload} />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</DetailSheet>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { LogEntry } from "@umt/contracts";
|
||||||
|
import { Pause, Play, RotateCw, ScrollText, X } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router";
|
||||||
|
import { agentKeys } from "~/api/agent.ts";
|
||||||
|
import { type LogParams, type LogStreamStatus, upsertEntries, useLogStream, useLogs } from "~/api/logs.ts";
|
||||||
|
import { DataTable } from "~/components/console/data-table.tsx";
|
||||||
|
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||||
|
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||||
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||||
|
import {
|
||||||
|
LOG_FILTER_KEYS,
|
||||||
|
LOG_LEVEL,
|
||||||
|
LOG_LEVELS,
|
||||||
|
LOG_SINCE,
|
||||||
|
LOG_TYPE_LABEL,
|
||||||
|
LOG_TYPES,
|
||||||
|
type LogFilterKey,
|
||||||
|
type LogFilters,
|
||||||
|
logBadge,
|
||||||
|
logSubtitle,
|
||||||
|
logTitle,
|
||||||
|
objectText,
|
||||||
|
sinceToFrom,
|
||||||
|
TARGET_LABEL,
|
||||||
|
} from "~/components/logs/labels.ts";
|
||||||
|
import { AskAgentButton, LogDetailSheet } from "~/components/logs/log-detail.tsx";
|
||||||
|
import { PlanBoard } from "~/components/schedules/schedule-board.tsx";
|
||||||
|
import { useLiveTasks } from "~/components/tasks/live.ts";
|
||||||
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||||
|
import { formatRelative } from "~/lib/labels.ts";
|
||||||
|
import { qk } from "~/lib/query-keys.ts";
|
||||||
|
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||||
|
|
||||||
|
export const meta = () => [{ title: "日志 · UHD 媒体自动化控制台" }];
|
||||||
|
|
||||||
|
// 日志(界面第二轮 V 线):任务、定时与自动任务、登录、审计合成一份列表(GET /logs),实时追加(GET /logs/stream);
|
||||||
|
// 「定时任务」页签是原计划页(定时任务的开关、间隔、立即执行;运行记录回到日志按定时任务筛选)。
|
||||||
|
// URL:?type= &level= &since= &q= &objectType= &objectId= &system=1 筛选;?log=<id> / ?run=<引擎运行 id> 打开详情;
|
||||||
|
// ?tab=schedules[&job=] 定时任务页签。
|
||||||
|
|
||||||
|
type Tab = "logs" | "schedules";
|
||||||
|
|
||||||
|
/** 列表里的时间:今天只写时分秒,其余带月日 */
|
||||||
|
function shortTime(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
const hms = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
const today = new Date();
|
||||||
|
return d.toDateString() === today.toDateString() ? hms : `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hms}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STREAM_LABEL: Record<LogStreamStatus, string> = {
|
||||||
|
off: "已暂停",
|
||||||
|
connecting: "连接中…",
|
||||||
|
open: "实时",
|
||||||
|
reconnecting: "重连中…",
|
||||||
|
};
|
||||||
|
|
||||||
|
function StreamState({ status }: { status: LogStreamStatus }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-meta text-muted-foreground" role="status">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"inline-block size-2 rounded-full",
|
||||||
|
status === "open" ? "animate-pulse bg-success" : status === "off" ? "bg-foreground/30" : "bg-warning",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{STREAM_LABEL[status]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogsView({
|
||||||
|
params,
|
||||||
|
setParams,
|
||||||
|
}: {
|
||||||
|
params: URLSearchParams;
|
||||||
|
setParams: ReturnType<typeof useSearchParams>[1];
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
useLiveTasks();
|
||||||
|
const filters = Object.fromEntries(LOG_FILTER_KEYS.map((k) => [k, params.get(k) ?? ""])) as LogFilters;
|
||||||
|
const [keyword, setKeyword] = useState(filters.q);
|
||||||
|
const [live, setLive] = useState(true);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
||||||
|
const anchor = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const update = (fn: (p: URLSearchParams) => void) =>
|
||||||
|
setParams(
|
||||||
|
(prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
fn(next);
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
const setFilter = (k: LogFilterKey, v: string) =>
|
||||||
|
update((p) => {
|
||||||
|
if (v) p.set(k, v);
|
||||||
|
else p.delete(k);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 关键词输入防抖写进 URL
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: 只跟输入走
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
if (keyword.trim() !== filters.q) setFilter("q", keyword.trim());
|
||||||
|
}, 350);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [keyword]);
|
||||||
|
|
||||||
|
const from = useMemo(() => sinceToFrom(filters.since), [filters.since]);
|
||||||
|
const query: LogParams = {
|
||||||
|
type: filters.type || undefined,
|
||||||
|
level: filters.level || undefined,
|
||||||
|
from,
|
||||||
|
q: filters.q || undefined,
|
||||||
|
objectType: filters.objectType || undefined,
|
||||||
|
objectId: filters.objectId || undefined,
|
||||||
|
system: filters.system || undefined,
|
||||||
|
};
|
||||||
|
const list = useLogs(query);
|
||||||
|
const listRows = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]);
|
||||||
|
// 流:从列表里最新一条之后接着推;时间范围只决定下界,新日志总在范围内
|
||||||
|
const stream = useLogStream(query, { enabled: live && !list.isPending, since: listRows[0]?.at ?? null });
|
||||||
|
const rows = useMemo(
|
||||||
|
() => (stream.entries.length ? upsertEntries(listRows, stream.entries) : listRows),
|
||||||
|
[listRows, stream.entries],
|
||||||
|
);
|
||||||
|
const fresh = useMemo(() => new Set(stream.entries.map((e) => e.id)), [stream.entries]);
|
||||||
|
|
||||||
|
// 筛选变了:清掉选择
|
||||||
|
const filterKey = JSON.stringify(query);
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在筛选变化时清空
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(new Set());
|
||||||
|
anchor.current = null;
|
||||||
|
}, [filterKey]);
|
||||||
|
|
||||||
|
const openId = params.get("log") ?? (params.get("run") ? `engine_run:${params.get("run")}` : null);
|
||||||
|
const openEntry = openId ? (rows.find((e) => e.id === openId) ?? null) : null;
|
||||||
|
const selectedRows = rows.filter((e) => selected.has(e.id));
|
||||||
|
|
||||||
|
/** 勾选:按住 Shift 选中与上一次勾选之间的一段 */
|
||||||
|
const toggle = (e: LogEntry, shift: boolean) => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
const on = !prev.has(e.id);
|
||||||
|
if (shift && anchor.current) {
|
||||||
|
const a = rows.findIndex((r) => r.id === anchor.current);
|
||||||
|
const b = rows.findIndex((r) => r.id === e.id);
|
||||||
|
if (a >= 0 && b >= 0) {
|
||||||
|
for (const r of rows.slice(Math.min(a, b), Math.max(a, b) + 1)) {
|
||||||
|
if (on) next.add(r.id);
|
||||||
|
else next.delete(r.id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (on) next.add(e.id);
|
||||||
|
else next.delete(e.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
anchor.current = e.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = LOG_FILTER_KEYS.some((k) => !!filters[k]);
|
||||||
|
const objectChip = filters.objectType || filters.objectId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||||
|
<StreamState status={live ? stream.status : "off"} />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setLive((v) => !v)}
|
||||||
|
aria-pressed={live}
|
||||||
|
aria-label={live ? "暂停实时" : "继续实时"}
|
||||||
|
>
|
||||||
|
{live ? <Pause /> : <Play />}
|
||||||
|
{live ? "暂停" : "继续"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
stream.clear();
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.all("logs") });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCw />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
{selected.size ? (
|
||||||
|
<span className="ml-auto flex flex-wrap items-center gap-2" data-floating-bar>
|
||||||
|
<span className="text-caption text-muted-foreground">已选 {selected.size} 条</span>
|
||||||
|
<AskAgentButton entries={selectedRows} />
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setSelected(new Set())}>
|
||||||
|
<X />
|
||||||
|
清除选择
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="ml-auto text-meta text-muted-foreground">勾选日志(按住 Shift 选一段)后可以问智能体</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FilterBar
|
||||||
|
search={{ value: keyword, onChange: setKeyword, placeholder: "搜索标题、错误、对象 ID" }}
|
||||||
|
onReset={
|
||||||
|
filtered
|
||||||
|
? () => {
|
||||||
|
setKeyword("");
|
||||||
|
update((p) => {
|
||||||
|
for (const k of LOG_FILTER_KEYS) p.delete(k);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FilterSelect
|
||||||
|
label="类型"
|
||||||
|
value={filters.type}
|
||||||
|
onChange={(v) => setFilter("type", v)}
|
||||||
|
options={LOG_TYPES.map((value) => ({ value, label: LOG_TYPE_LABEL[value] }))}
|
||||||
|
/>
|
||||||
|
<FilterSelect
|
||||||
|
label="结果"
|
||||||
|
value={filters.level}
|
||||||
|
onChange={(v) => setFilter("level", v)}
|
||||||
|
options={LOG_LEVELS.map((value) => ({ value, label: LOG_LEVEL[value].label }))}
|
||||||
|
/>
|
||||||
|
<FilterSelect
|
||||||
|
label="时间"
|
||||||
|
value={filters.since}
|
||||||
|
onChange={(v) => setFilter("since", v)}
|
||||||
|
options={LOG_SINCE}
|
||||||
|
allLabel="不限"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant={filters.system === "1" ? "secondary" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
aria-pressed={filters.system === "1"}
|
||||||
|
title="没有业务对象的任务、例行定时任务的成功运行"
|
||||||
|
onClick={() => setFilter("system", filters.system === "1" ? "" : "1")}
|
||||||
|
>
|
||||||
|
{filters.system === "1" ? "隐藏例行记录" : "显示例行记录"}
|
||||||
|
</Button>
|
||||||
|
{objectChip ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
title={`${filters.objectType} ${filters.objectId}`}
|
||||||
|
onClick={() =>
|
||||||
|
update((p) => {
|
||||||
|
p.delete("objectType");
|
||||||
|
p.delete("objectId");
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{filters.objectType ? (TARGET_LABEL[filters.objectType] ?? filters.objectType) : "对象"}
|
||||||
|
{filters.objectId ? <span className="max-w-40 truncate font-mono">{filters.objectId}</span> : null}
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</FilterBar>
|
||||||
|
|
||||||
|
<DataTable<LogEntry>
|
||||||
|
paginationKey={filterKey}
|
||||||
|
rows={list.isPending ? undefined : rows}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
loading={list.isPending}
|
||||||
|
error={list.error}
|
||||||
|
onRetry={() => void list.refetch()}
|
||||||
|
onRowClick={(r) =>
|
||||||
|
update((p) => {
|
||||||
|
p.delete("run");
|
||||||
|
p.set("log", r.id);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
selectedKey={openId}
|
||||||
|
hasMore={!!list.hasNextPage}
|
||||||
|
onLoadMore={() => list.fetchNextPage()}
|
||||||
|
loadingMore={list.isFetchingNextPage}
|
||||||
|
empty={{
|
||||||
|
icon: ScrollText,
|
||||||
|
title: filtered ? "没有符合条件的日志" : "还没有日志",
|
||||||
|
description: filtered ? "换个筛选或放宽时间范围。" : "任务运行、定时任务执行、登录和设置修改都会记在这里。",
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: "pick",
|
||||||
|
header: <span className="sr-only">选择</span>,
|
||||||
|
className: "w-8",
|
||||||
|
cell: (r) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`选择 ${logTitle(r)}`}
|
||||||
|
checked={selected.has(r.id)}
|
||||||
|
onClick={(ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
toggle(r, ev.shiftKey);
|
||||||
|
}}
|
||||||
|
onChange={() => {}}
|
||||||
|
className="h-4 w-4 cursor-pointer accent-foreground"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "at",
|
||||||
|
header: "时间",
|
||||||
|
cell: (r) => (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"whitespace-nowrap font-mono text-caption tabular-nums",
|
||||||
|
fresh.has(r.id) ? "text-foreground" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
title={`${formatDateTime(r.at)}(${formatRelative(r.at)})`}
|
||||||
|
>
|
||||||
|
{shortTime(r.at)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "type",
|
||||||
|
header: "类型",
|
||||||
|
hideBelow: "sm",
|
||||||
|
cell: (r) => <span className="whitespace-nowrap text-caption">{LOG_TYPE_LABEL[r.type]}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "level",
|
||||||
|
header: "结果",
|
||||||
|
cell: (r) => {
|
||||||
|
const b = logBadge(r);
|
||||||
|
return <StatusBadge tone={b.tone}>{b.label}</StatusBadge>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "title",
|
||||||
|
header: "摘要",
|
||||||
|
cell: (r) => {
|
||||||
|
const sub = logSubtitle(r);
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 max-w-[16rem] sm:max-w-md lg:max-w-xl">
|
||||||
|
<p className="truncate font-medium text-foreground" title={logTitle(r)}>
|
||||||
|
{logTitle(r)}
|
||||||
|
</p>
|
||||||
|
{sub ? (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"truncate text-meta",
|
||||||
|
r.level === "error" ? "text-destructive" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
title={sub}
|
||||||
|
>
|
||||||
|
{sub}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "object",
|
||||||
|
header: "对象",
|
||||||
|
hideBelow: "lg",
|
||||||
|
cell: (r) => {
|
||||||
|
const t = objectText(r.object);
|
||||||
|
return t ? (
|
||||||
|
<span className="block max-w-[14rem] truncate text-caption text-muted-foreground" title={t}>
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<LogDetailSheet
|
||||||
|
logId={openId}
|
||||||
|
entry={openEntry}
|
||||||
|
onOpenLog={(id) =>
|
||||||
|
update((p) => {
|
||||||
|
p.delete("run");
|
||||||
|
p.set("log", id);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onClose={() =>
|
||||||
|
update((p) => {
|
||||||
|
p.delete("log");
|
||||||
|
p.delete("run");
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LogsPage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const tab: Tab = params.get("tab") === "schedules" ? "schedules" : "logs";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="日志"
|
||||||
|
actions={
|
||||||
|
tab === "schedules" ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
qc.invalidateQueries({ queryKey: qk.all("schedules") });
|
||||||
|
qc.invalidateQueries({ queryKey: [...agentKeys.all, "list"] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCw />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setParams(
|
||||||
|
(prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
if (v === "schedules") next.set("tab", "schedules");
|
||||||
|
else {
|
||||||
|
next.delete("tab");
|
||||||
|
next.delete("job");
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="logs">日志</TabsTrigger>
|
||||||
|
<TabsTrigger value="schedules">定时任务</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
<div className="mt-4">
|
||||||
|
{tab === "schedules" ? (
|
||||||
|
<PlanBoard highlightJob={params.get("job") ?? undefined} />
|
||||||
|
) : (
|
||||||
|
<LogsView params={params} setParams={setParams} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { redirect } from "react-router";
|
import { redirect } from "react-router";
|
||||||
|
import { BUSINESS_OBJECT_TYPE, RUN_STATUS_LEVEL } from "~/components/logs/labels.ts";
|
||||||
|
|
||||||
// 控制台 IA 重构后的旧链接 → 新路由(IA-1:docs/design/console-ia.md §3.3;IA-3:§7;IA-2 站点类型:RSS 并入站点)。
|
// 控制台 IA 重构后的旧链接 → 新路由(IA-1:docs/design/console-ia.md §3.3;IA-3:§7;IA-2 站点类型:RSS 并入站点)。
|
||||||
// 纯函数,各路由模块的 clientLoader 调用;nav.test 与冒烟都读 LEGACY_REDIRECTS。
|
// 纯函数,各路由模块的 clientLoader 调用;nav.test 与冒烟都读 LEGACY_REDIRECTS。
|
||||||
// 设置页的 ?tab= 旧链接由设置页自己处理(IA-2)。
|
// 设置页的 ?tab= 旧链接由设置页自己处理(IA-2)。
|
||||||
|
// 界面第二轮(V 线):任务、审计、引擎面板、计划合进日志页 /console/logs;任务详情 /console/tasks/:id 保留为独立页。
|
||||||
|
|
||||||
const IDENTITY_TAB: Record<string, string> = {
|
const IDENTITY_TAB: Record<string, string> = {
|
||||||
pool: "filter=unidentified",
|
pool: "filter=unidentified",
|
||||||
@@ -19,10 +21,14 @@ export function legacyRedirect(pathname: string, search: string): string | null
|
|||||||
const rest = q.toString();
|
const rest = q.toString();
|
||||||
const withRest = (to: string) => (rest ? `${to}${to.includes("?") ? "&" : "?"}${rest}` : to);
|
const withRest = (to: string) => (rest ? `${to}${to.includes("?") ? "&" : "?"}${rest}` : to);
|
||||||
|
|
||||||
// IA-3:引擎面板并入任务页;运行详情用任务页抽屉(?run=)
|
// 引擎面板 → 日志页「任务」;运行详情用日志页抽屉(?run=)
|
||||||
if (path === "/console/engine") return withRest(tab ? `/console/tasks?tab=${tab}` : "/console/tasks");
|
if (path === "/console/engine") return tasksToLogs(new URLSearchParams(search));
|
||||||
const engineRun = /^\/console\/engine\/([^/]+)$/.exec(path);
|
const engineRun = /^\/console\/engine\/([^/]+)$/.exec(path);
|
||||||
if (engineRun) return withRest(`/console/tasks?run=${engineRun[1]}`);
|
if (engineRun)
|
||||||
|
return logsUrl([
|
||||||
|
["type", "task"],
|
||||||
|
["run", decodeURIComponent(engineRun[1]!)],
|
||||||
|
]);
|
||||||
// IA-3:身份与元数据取消,待识别 / 外部身份 / 别名并入媒体概览
|
// IA-3:身份与元数据取消,待识别 / 外部身份 / 别名并入媒体概览
|
||||||
if (path === "/console/identity") return withRest(`/console/library?${IDENTITY_TAB[tab ?? ""] ?? IDENTITY_TAB.pool}`);
|
if (path === "/console/identity") return withRest(`/console/library?${IDENTITY_TAB[tab ?? ""] ?? IDENTITY_TAB.pool}`);
|
||||||
// DL-3:下载计划页签取消,计划并入下载面板;带计划 id 的深链(?plan= 或 /plans/:id)打开对应行
|
// DL-3:下载计划页签取消,计划并入下载面板;带计划 id 的深链(?plan= 或 /plans/:id)打开对应行
|
||||||
@@ -42,20 +48,70 @@ export function legacyRedirect(pathname: string, search: string): string | null
|
|||||||
// 2026-10-06:待处理中心取消,问题回到发生处;旧链接按问题类型落到对应页面
|
// 2026-10-06:待处理中心取消,问题回到发生处;旧链接按问题类型落到对应页面
|
||||||
if (path === "/console/inbox") return inboxRedirect(q.get("kind"));
|
if (path === "/console/inbox") return inboxRedirect(q.get("kind"));
|
||||||
|
|
||||||
if (!tab) return null;
|
|
||||||
if (path === "/console/tasks") {
|
if (path === "/console/tasks") {
|
||||||
// FE-6:计划从任务页拆出,回到独立的计划页(?job= 透传)
|
// FE-6 / DL-3 的旧页签
|
||||||
if (tab === "plan") return withRest("/console/schedules");
|
if (tab === "plan") return withRest("/console/logs?tab=schedules");
|
||||||
if (tab === "downloads") return withRest("/console/downloads");
|
if (tab === "downloads") return withRest("/console/downloads");
|
||||||
if (tab === "plans") return withRest("/console/downloads");
|
if (tab === "plans") return withRest("/console/downloads");
|
||||||
// 旧"业务任务"页签:统一任务流
|
return tasksToLogs(new URLSearchParams(search));
|
||||||
if (tab === "tasks" || tab === "business") return withRest("/console/tasks");
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
// 审计 → 日志页(审计里的登录、API Key 事件在「登录」类)
|
||||||
|
if (path === "/console/audit") return auditToLogs(new URLSearchParams(search));
|
||||||
|
// 计划 → 日志页「定时任务」页签(?job= 透传)
|
||||||
|
if (path === "/console/schedules") return withRest("/console/logs?tab=schedules");
|
||||||
|
|
||||||
|
if (!tab) return null;
|
||||||
if (path === "/console/subscriptions" && tab === "policies") return withRest("/console/rules");
|
if (path === "/console/subscriptions" && tab === "policies") return withRest("/console/rules");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LOGS_PATH = "/console/logs";
|
||||||
|
|
||||||
|
function logsUrl(pairs: [string, string | null | undefined][]): string {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
for (const [k, v] of pairs) if (v) q.set(k, v);
|
||||||
|
const s = q.toString();
|
||||||
|
return s ? `${LOGS_PATH}?${s}` : LOGS_PATH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 旧任务页 / 引擎面板的筛选 → 日志页:页签与状态换成级别,业务对象换成对象筛选(按对象时不限类型) */
|
||||||
|
function tasksToLogs(q: URLSearchParams): string {
|
||||||
|
const tab = q.get("tab");
|
||||||
|
const levels = new Set<string>();
|
||||||
|
if (tab === "active") levels.add("running");
|
||||||
|
else if (tab === "failed") levels.add("error");
|
||||||
|
else
|
||||||
|
for (const st of (q.get("status") ?? "").split(",")) {
|
||||||
|
const l = RUN_STATUS_LEVEL[st.trim()];
|
||||||
|
if (l) levels.add(l);
|
||||||
|
}
|
||||||
|
const kind = q.get("kind");
|
||||||
|
const ref = q.get("ref");
|
||||||
|
return logsUrl([
|
||||||
|
["type", ref ? null : "task"],
|
||||||
|
["level", [...levels].join(",")],
|
||||||
|
["since", q.get("since")],
|
||||||
|
["q", q.get("workflow") ?? q.get("workflowName")],
|
||||||
|
["objectType", ref && kind ? (BUSINESS_OBJECT_TYPE[kind] ?? null) : null],
|
||||||
|
["objectId", ref],
|
||||||
|
["system", q.get("system")],
|
||||||
|
["run", q.get("run")],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 旧审计页:对象筛选照搬;动作当关键词(去掉 .* 前缀组的通配) */
|
||||||
|
function auditToLogs(q: URLSearchParams): string {
|
||||||
|
const targetType = q.get("targetType");
|
||||||
|
const targetId = q.get("targetId");
|
||||||
|
const action = q.get("action")?.replace(/\.\*$/, "") ?? null;
|
||||||
|
return logsUrl([
|
||||||
|
["type", targetType || targetId || action ? null : "audit"],
|
||||||
|
["q", action],
|
||||||
|
["objectType", targetType],
|
||||||
|
["objectId", targetId],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/** 整理侧的问题(下载完识别不到、路径冲突、文件被排除、整理卡住 / 失败)→ 整理记录「需要处理」 */
|
/** 整理侧的问题(下载完识别不到、路径冲突、文件被排除、整理卡住 / 失败)→ 整理记录「需要处理」 */
|
||||||
export const ORGANIZE_ATTENTION_PATH = "/console/organize?tab=records&attention=1";
|
export const ORGANIZE_ATTENTION_PATH = "/console/organize?tab=records&attention=1";
|
||||||
|
|
||||||
@@ -88,8 +144,27 @@ export const LEGACY_REDIRECTS: { from: string; to: string }[] = [
|
|||||||
{ from: "/console/downloads/plans", to: "/console/downloads" },
|
{ from: "/console/downloads/plans", to: "/console/downloads" },
|
||||||
{ from: "/console/downloads/plans?plan=p-1", to: "/console/downloads?plan=p-1" },
|
{ from: "/console/downloads/plans?plan=p-1", to: "/console/downloads?plan=p-1" },
|
||||||
{ from: "/console/downloads/plans/p-1", to: "/console/downloads?plan=p-1" },
|
{ from: "/console/downloads/plans/p-1", to: "/console/downloads?plan=p-1" },
|
||||||
{ from: "/console/tasks?tab=tasks", to: "/console/tasks" },
|
{ from: "/console/tasks?tab=tasks", to: "/console/logs?type=task" },
|
||||||
{ from: "/console/tasks?tab=business", to: "/console/tasks" },
|
{ from: "/console/tasks?tab=business", to: "/console/logs?type=task" },
|
||||||
|
{ from: "/console/tasks", to: "/console/logs?type=task" },
|
||||||
|
{ from: "/console/tasks?tab=active", to: "/console/logs?type=task&level=running" },
|
||||||
|
{ from: "/console/tasks?tab=failed&since=24h", to: "/console/logs?type=task&level=error&since=24h" },
|
||||||
|
{ from: "/console/tasks?tab=all&status=failed", to: "/console/logs?type=task&level=error" },
|
||||||
|
{ from: "/console/tasks?run=run-1", to: "/console/logs?type=task&run=run-1" },
|
||||||
|
{
|
||||||
|
from: "/console/tasks?tab=all&kind=schedule&ref=trash.purge",
|
||||||
|
to: "/console/logs?objectType=scheduled_job&objectId=trash.purge",
|
||||||
|
},
|
||||||
|
{ from: "/console/tasks?kind=download&ref=d-1", to: "/console/logs?objectType=download_task&objectId=d-1" },
|
||||||
|
{ from: "/console/audit", to: "/console/logs?type=audit" },
|
||||||
|
{
|
||||||
|
from: "/console/audit?targetType=engine_run&targetId=r1",
|
||||||
|
to: "/console/logs?objectType=engine_run&objectId=r1",
|
||||||
|
},
|
||||||
|
{ from: "/console/audit?action=settings.update", to: "/console/logs?q=settings.update" },
|
||||||
|
{ from: "/console/audit?action=api_key.*", to: "/console/logs?q=api_key" },
|
||||||
|
{ from: "/console/schedules", to: "/console/logs?tab=schedules" },
|
||||||
|
{ from: "/console/schedules?job=trash.purge", to: "/console/logs?tab=schedules&job=trash.purge" },
|
||||||
{ from: "/console/subscriptions?tab=policies", to: "/console/rules" },
|
{ from: "/console/subscriptions?tab=policies", to: "/console/rules" },
|
||||||
{ from: "/console/sources?tab=snapshots", to: "/console/sites" },
|
{ from: "/console/sources?tab=snapshots", to: "/console/sites" },
|
||||||
{ from: "/console/sources?tab=snapshots&siteId=abc", to: "/console/sites/abc?tab=snapshots" },
|
{ from: "/console/sources?tab=snapshots&siteId=abc", to: "/console/sites/abc?tab=snapshots" },
|
||||||
@@ -98,11 +173,11 @@ export const LEGACY_REDIRECTS: { from: string; to: string }[] = [
|
|||||||
{ from: "/console/sources?siteId=abc", to: "/console/sites/abc?tab=rss" },
|
{ from: "/console/sources?siteId=abc", to: "/console/sites/abc?tab=rss" },
|
||||||
{ from: "/console/sources?tab=feeds&siteId=abc", to: "/console/sites/abc?tab=rss" },
|
{ from: "/console/sources?tab=feeds&siteId=abc", to: "/console/sites/abc?tab=rss" },
|
||||||
{ from: "/console/sources?new=1", to: "/console/sites?new=rss_feed" },
|
{ from: "/console/sources?new=1", to: "/console/sites?new=rss_feed" },
|
||||||
{ from: "/console/engine", to: "/console/tasks" },
|
{ from: "/console/engine", to: "/console/logs?type=task" },
|
||||||
{ from: "/console/engine?status=failed", to: "/console/tasks?status=failed" },
|
{ from: "/console/engine?status=failed", to: "/console/logs?type=task&level=error" },
|
||||||
{ from: "/console/engine/run-1", to: "/console/tasks?run=run-1" },
|
{ from: "/console/engine/run-1", to: "/console/logs?type=task&run=run-1" },
|
||||||
{ from: "/console/tasks?tab=plan", to: "/console/schedules" },
|
{ from: "/console/tasks?tab=plan", to: "/console/logs?tab=schedules" },
|
||||||
{ from: "/console/tasks?tab=plan&job=trash.purge", to: "/console/schedules?job=trash.purge" },
|
{ from: "/console/tasks?tab=plan&job=trash.purge", to: "/console/logs?tab=schedules&job=trash.purge" },
|
||||||
{ from: "/console/identity", to: "/console/library?filter=unidentified" },
|
{ from: "/console/identity", to: "/console/library?filter=unidentified" },
|
||||||
{ from: "/console/identity?tab=external", to: "/console/library?view=identities" },
|
{ from: "/console/identity?tab=external", to: "/console/library?view=identities" },
|
||||||
{ from: "/console/identity?tab=aliases", to: "/console/library?view=aliases" },
|
{ from: "/console/identity?tab=aliases", to: "/console/library?view=aliases" },
|
||||||
|
|||||||
@@ -1,38 +1,8 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { legacyRedirectLoader } from "./redirects.ts";
|
||||||
import { RotateCw } from "lucide-react";
|
|
||||||
import { useSearchParams } from "react-router";
|
|
||||||
import { agentKeys } from "~/api/agent.ts";
|
|
||||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
|
||||||
import { PlanBoard } from "~/components/schedules/schedule-board.tsx";
|
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
|
||||||
import { qk } from "~/lib/query-keys.ts";
|
|
||||||
|
|
||||||
// FE-6:计划页(定时任务配置)与任务页(运行记录)分开。?job= 高亮并滚到该定时任务;#auto / #checks 定位到节。
|
// 界面第二轮:计划页并入日志页的「定时任务」页签(/console/logs?tab=schedules,?job= 透传)。
|
||||||
|
export const clientLoader = legacyRedirectLoader;
|
||||||
|
|
||||||
export const meta = () => [{ title: "计划 · UHD 媒体自动化控制台" }];
|
export default function LegacyRedirect() {
|
||||||
|
return null;
|
||||||
export default function SchedulesPage() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const [params] = useSearchParams();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title="计划"
|
|
||||||
actions={
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.all("schedules") });
|
|
||||||
qc.invalidateQueries({ queryKey: [...agentKeys.all, "list"] });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RotateCw />
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PlanBoard highlightJob={params.get("job") ?? undefined} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -124,9 +124,9 @@ function Operations({ taskId }: { taskId: string }) {
|
|||||||
|
|
||||||
const back = (
|
const back = (
|
||||||
<Button asChild variant="ghost" size="sm">
|
<Button asChild variant="ghost" size="sm">
|
||||||
<Link to={consolePath("tasks")}>
|
<Link to={`${consolePath("logs")}?type=task`}>
|
||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
任务
|
日志
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,151 +1,8 @@
|
|||||||
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 { 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";
|
import { legacyRedirectLoader } from "./redirects.ts";
|
||||||
|
|
||||||
export const meta = () => [{ title: "任务 · UHD 媒体自动化控制台" }];
|
// 界面第二轮:任务页并入日志页(/console/logs?type=task);旧链接与筛选在 redirects.ts 里换算。任务详情 /console/tasks/:id 保留。
|
||||||
|
|
||||||
// 旧的 ?tab=downloads / plans / business / plan 跳到下载面板 / 下载面板 / 任务流 / 计划页
|
|
||||||
export const clientLoader = legacyRedirectLoader;
|
export const clientLoader = legacyRedirectLoader;
|
||||||
|
|
||||||
type Tab = StreamTab;
|
export default function LegacyRedirect() {
|
||||||
const TABS: Tab[] = ["all", "active", "failed"];
|
return null;
|
||||||
const FILTER_KEYS: (keyof StreamFilters)[] = ["status", "kind", "ref", "source", "since", "workflow", "system"];
|
|
||||||
|
|
||||||
function Count({ n }: { n: number | undefined }) {
|
|
||||||
if (!n) return null;
|
|
||||||
return <span className="text-meta text-muted-foreground tabular-nums">{n > 999 ? "999+" : n}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RunSheet({
|
|
||||||
runId,
|
|
||||||
onOpen,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
runId: string | null;
|
|
||||||
onOpen: (id: string) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const q = useEngineRun(runId ?? undefined);
|
|
||||||
const r = q.data;
|
|
||||||
return (
|
|
||||||
<DetailSheet
|
|
||||||
open={!!runId}
|
|
||||||
onOpenChange={(o) => !o && onClose()}
|
|
||||||
width="lg"
|
|
||||||
title={r ? runTitle(r) : "任务"}
|
|
||||||
description={r ? <TaskStatusLine r={r} /> : undefined}
|
|
||||||
footer={
|
|
||||||
r ? (
|
|
||||||
<>
|
|
||||||
<Button asChild variant="ghost" size="sm">
|
|
||||||
<Link to={`/console/tasks/${encodeURIComponent(r.id)}`}>
|
|
||||||
<ExternalLink />
|
|
||||||
独立页面
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<RunActions r={r} onOpenRun={onOpen} />
|
|
||||||
</>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{q.isPending ? (
|
|
||||||
<LoadingState rows={6} />
|
|
||||||
) : q.isError ? (
|
|
||||||
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
|
|
||||||
) : r ? (
|
|
||||||
<TaskDetailBody r={r} onOpenRun={onOpen} />
|
|
||||||
) : null}
|
|
||||||
</DetailSheet>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title="任务"
|
|
||||||
actions={
|
|
||||||
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: qk.all("engine") })}>
|
|
||||||
<RotateCw />
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Tabs
|
|
||||||
value={tab}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
update((p) => {
|
|
||||||
p.set("tab", v);
|
|
||||||
if (v !== "all") p.delete("status");
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="all">全部</TabsTrigger>
|
|
||||||
<TabsTrigger value="active">
|
|
||||||
进行中
|
|
||||||
<Count n={by ? (by.running ?? 0) + (by.pending ?? 0) : undefined} />
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="failed">
|
|
||||||
失败
|
|
||||||
<Count n={by?.failed} />
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
<div className="mt-4">
|
|
||||||
<TaskStream
|
|
||||||
tab={tab}
|
|
||||||
filters={filters}
|
|
||||||
setFilter={setFilter}
|
|
||||||
resetFilters={() =>
|
|
||||||
update((p) => {
|
|
||||||
for (const k of FILTER_KEYS) p.delete(k);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onOpen={(id) => update((p) => p.set("run", id))}
|
|
||||||
selectedId={runId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<RunSheet
|
|
||||||
runId={runId}
|
|
||||||
onOpen={(id) => update((p) => p.set("run", id))}
|
|
||||||
onClose={() => update((p) => p.delete("run"))}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -57,6 +57,13 @@ const STATIC_ROUTES = [
|
|||||||
"/console/agent",
|
"/console/agent",
|
||||||
"/console/agent?mock=1",
|
"/console/agent?mock=1",
|
||||||
"/console/audit",
|
"/console/audit",
|
||||||
|
// 日志(界面第二轮):任务 / 审计 / 计划的旧地址都跳到这里
|
||||||
|
"/console/logs",
|
||||||
|
"/console/logs?type=auth",
|
||||||
|
"/console/logs?level=error&since=24h",
|
||||||
|
"/console/logs?system=1&q=feeds",
|
||||||
|
"/console/logs?tab=schedules",
|
||||||
|
"/console/logs?tab=schedules&job=trash.purge",
|
||||||
"/console/status",
|
"/console/status",
|
||||||
"/console/search?q=沙丘",
|
"/console/search?q=沙丘",
|
||||||
"/console/settings",
|
"/console/settings",
|
||||||
@@ -87,6 +94,7 @@ const DETAIL_ROUTES = [
|
|||||||
{ route: "/console/downloads/:id", list: "/api/v1/download-tasks" },
|
{ route: "/console/downloads/:id", list: "/api/v1/download-tasks" },
|
||||||
{ route: "/console/tasks/:id", list: "/api/v1/engine/runs" },
|
{ route: "/console/tasks/:id", list: "/api/v1/engine/runs" },
|
||||||
{ route: "/console/tasks?run=:id", list: "/api/v1/engine/runs" },
|
{ route: "/console/tasks?run=:id", list: "/api/v1/engine/runs" },
|
||||||
|
{ route: "/console/logs?run=:id", list: "/api/v1/engine/runs" },
|
||||||
{ route: "/console/agent/:id", list: "/api/v1/agent/runs", after: "?mock=0" },
|
{ route: "/console/agent/:id", list: "/api/v1/agent/runs", after: "?mock=0" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -98,7 +106,7 @@ const REDIRECT_ROUTES = [
|
|||||||
{ from: "/console/tasks?tab=downloads", to: "/console/downloads" },
|
{ from: "/console/tasks?tab=downloads", to: "/console/downloads" },
|
||||||
{ from: "/console/tasks?tab=downloads&status=paused", to: "/console/downloads?status=paused" },
|
{ from: "/console/tasks?tab=downloads&status=paused", to: "/console/downloads?status=paused" },
|
||||||
{ from: "/console/tasks?tab=plans", to: "/console/downloads" },
|
{ from: "/console/tasks?tab=plans", to: "/console/downloads" },
|
||||||
{ from: "/console/tasks?tab=tasks", to: "/console/tasks" },
|
{ from: "/console/tasks?tab=tasks", to: "/console/logs?type=task" },
|
||||||
{ from: "/console/subscriptions?tab=policies", to: "/console/rules" },
|
{ from: "/console/subscriptions?tab=policies", to: "/console/rules" },
|
||||||
// 偏好标签并入规则页(origin 筛选透传)
|
// 偏好标签并入规则页(origin 筛选透传)
|
||||||
{ from: "/console/preferences", to: "/console/rules" },
|
{ from: "/console/preferences", to: "/console/rules" },
|
||||||
@@ -107,13 +115,15 @@ const REDIRECT_ROUTES = [
|
|||||||
{ from: "/console/sources?tab=snapshots&siteId=:id", to: "/console/sites/:id?tab=snapshots", list: "/api/v1/sites" },
|
{ from: "/console/sources?tab=snapshots&siteId=:id", to: "/console/sites/:id?tab=snapshots", list: "/api/v1/sites" },
|
||||||
// 旧的下载详情地址:先查业务任务(404 属预期),再跳到下载详情
|
// 旧的下载详情地址:先查业务任务(404 属预期),再跳到下载详情
|
||||||
{ from: "/console/tasks/:id", to: "/console/downloads/:id", list: "/api/v1/download-tasks", expect404: true },
|
{ from: "/console/tasks/:id", to: "/console/downloads/:id", list: "/api/v1/download-tasks", expect404: true },
|
||||||
// IA-3:引擎面板 / 定时任务 / 身份与元数据并入任务页与媒体概览
|
// 界面第二轮:任务 / 引擎面板 / 审计 / 计划并入日志页;身份与元数据并入媒体概览(IA-3)
|
||||||
{ from: "/console/tasks?tab=business", to: "/console/tasks" },
|
{ from: "/console/tasks?tab=business", to: "/console/logs?type=task" },
|
||||||
{ from: "/console/engine", to: "/console/tasks" },
|
{ from: "/console/tasks?tab=failed", to: "/console/logs?type=task&level=error" },
|
||||||
{ from: "/console/engine/:id", to: "/console/tasks?run=:id", list: "/api/v1/engine/runs" },
|
{ from: "/console/engine", to: "/console/logs?type=task" },
|
||||||
// FE-6:计划从任务页拆回独立的计划页
|
{ from: "/console/engine/:id", to: "/console/logs?type=task&run=:id", list: "/api/v1/engine/runs" },
|
||||||
{ from: "/console/tasks?tab=plan", to: "/console/schedules" },
|
{ from: "/console/audit", to: "/console/logs?type=audit" },
|
||||||
{ from: "/console/tasks?tab=plan&job=trash.purge", to: "/console/schedules?job=trash.purge" },
|
{ from: "/console/schedules", to: "/console/logs?tab=schedules" },
|
||||||
|
{ from: "/console/tasks?tab=plan", to: "/console/logs?tab=schedules" },
|
||||||
|
{ from: "/console/tasks?tab=plan&job=trash.purge", to: "/console/logs?tab=schedules&job=trash.purge" },
|
||||||
{ from: "/console/identity", to: "/console/library?filter=unidentified" },
|
{ from: "/console/identity", to: "/console/library?filter=unidentified" },
|
||||||
{ from: "/console/identity?tab=external", to: "/console/library?view=identities" },
|
{ from: "/console/identity?tab=external", to: "/console/library?view=identities" },
|
||||||
// 设置旧页签 ?tab= → 分区 hash(IA-2)
|
// 设置旧页签 ?tab= → 分区 hash(IA-2)
|
||||||
|
|||||||
Reference in new issue
Block a user