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,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("显示例行记录");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user