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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user