merge: ui2/v-logs-status

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 18:23:24 +08:00
commit ee3ebcb25c
53 files changed
+4100 -934

No files matched your search

+25 -11
View File
@@ -24,24 +24,32 @@ const matchesRoute = (pathname: string) => {
};
describe("控制台导航", () => {
it("IA-2:4 组,任务归系统,站点 / 订阅 / 规则合为「获取」,没有独立 RSS 项", () => {
it("IA-2:4 组,站点 / 订阅 / 规则合为「获取」,没有独立 RSS 项;系统组只有日志、运行状态、设置", () => {
expect(NAV_GROUPS.map((g) => g.title)).toEqual(["概览", ACQUIRE_GROUP_TITLE, "整理", "系统"]);
expect(ACQUIRE_GROUP_TITLE).toBe("获取");
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(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(NAV_ITEMS.length);
expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true);
});
it("取消的入口不在侧栏:没有待处理(问题回到发生处),引擎面板 / 身份并入别处", () => {
it("取消的入口不在侧栏:没有待处理(问题回到发生处),引擎面板 / 身份 / 任务 / 审计 / 计划并入别处", () => {
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(pageTitle("/console/inbox")).toBeUndefined();
});
@@ -61,8 +69,10 @@ describe("控制台导航", () => {
expect(findNavItem("/console")?.label).toBe("首页");
expect(findNavItem("/console/")?.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/plans")?.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 不跳转", () => {
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/subscriptions", "")).toBeNull();
// 旧 RSS 页的 ?feed= 由 sources.tsx 查所属站点后跳转
+20 -26
View File
@@ -1,7 +1,6 @@
import {
Bot,
CalendarClock,
CalendarCog,
Compass,
Download,
FileStack,
@@ -11,7 +10,6 @@ import {
House,
Library,
ListChecks,
ListTodo,
type LucideIcon,
ScrollText,
Settings,
@@ -40,9 +38,10 @@ export function consolePath(...segments: (string | number)[]): string {
export const ACQUIRE_GROUP_TITLE = "获取";
/**
* 侧栏 4 组 14 项(IA-2,docs/design/console-ia.md):概览 → 获取 → 整理 → 系统。
* 任务归系统;RSS 属于站点(站点详情的 RSS Tab),不再单独占一项。
* 侧栏 4 组 13 项(IA-2,docs/design/console-ia.md):概览 → 获取 → 整理 → 系统。
* RSS 属于站点(站点详情的 RSS Tab),不再单独占一项。
* 没有「待处理」(2026-10-06 取消):问题在发生处处理,铃铛只放等你批准的智能体操作与系统健康;移动端抽屉用同一份表。
* 系统组只有三项(界面第二轮):日志(任务、定时与自动、登录、审计合一,定时任务设置是其中一个页签)、运行状态、设置。
*/
export const NAV_GROUPS: NavGroup[] = [
{
@@ -54,12 +53,6 @@ export const NAV_GROUPS: NavGroup[] = [
icon: House,
description: "在忙什么、要我处理什么、最近入库了什么",
},
{
to: "/console/schedules",
label: "计划",
icon: CalendarCog,
description: "智能体巡检与自动任务的开关和间隔",
},
{
to: "/console/agent",
label: "智能体会话",
@@ -110,10 +103,16 @@ export const NAV_GROUPS: NavGroup[] = [
title: "系统",
items: [
{
to: "/console/tasks",
label: "任务",
icon: ListTodo,
description: "最近处理了什么、有没有失败",
to: "/console/logs",
label: "日志",
icon: ScrollText,
description: "任务、定时与自动任务、登录与操作留痕,可实时查看",
},
{
to: "/console/status",
label: "运行状态",
icon: Gauge,
description: "服务是否正常、CPU 与内存、磁盘与数据库",
},
{
to: "/console/settings",
@@ -121,22 +120,15 @@ export const NAV_GROUPS: NavGroup[] = [
icon: Settings,
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> = {
"/console/preferences": "偏好标签",
@@ -162,6 +154,8 @@ export function settingsPath(section: SettingsSection, anchor?: string): string
/** 按路径找当前导航项(最长前缀匹配) */
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 (
NAV_ITEMS.find((i) => i.to === pathname.replace(/\/+$/, "")) ??
NAV_ITEMS.filter((i) => i.to !== CONSOLE_BASE && pathname.startsWith(`${i.to}/`)).sort(
@@ -0,0 +1,137 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Dashboard, SystemHealth, SystemResources } 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 { qk } from "~/lib/query-keys.ts";
import { HomeRuntime } from "./home-runtime.tsx";
// 首页运行状态简版卡:整体健康、CPU、内存、磁盘,以及下载器 / 站点 / 媒体库目录摘要,点进去是运行状态页。
const health = (over: Partial<SystemHealth> = {}): SystemHealth =>
({
level: "warn",
checkedAt: new Date().toISOString(),
api: { version: "t", startedAt: new Date().toISOString(), uptimeSec: 10 },
processes: [],
retiredProcesses: [],
sse: { connections: 0 },
db: { level: "ok", latencyMs: 1, sizeBytes: 1, connections: null, error: null },
disks: [
{
scope: "library_root",
id: "r1",
name: "电影",
path: "/media/movies",
level: "warn",
totalBytes: 1000,
usedBytes: 870,
availableBytes: 130,
usedRatio: 0.87,
error: null,
},
],
diskThresholds: { warnPercent: 85, criticalPercent: 90 },
mounts: [
{
kind: "library_root",
id: "r1",
name: "电影",
path: "/m",
status: "online",
reason: null,
sentinelChecked: true,
},
{
kind: "library_root",
id: "r2",
name: "剧集",
path: "/t",
status: "offline",
reason: "root_missing",
sentinelChecked: true,
},
],
engine: {
level: "ok",
pending: 0,
running: 0,
oldestPendingAgeSec: null,
finished24h: 0,
failed24h: 0,
failureRate24h: null,
},
schedules: { level: "ok", total: 0, enabled: 0, running: 0, failing: [], failedRuns24h: 0 },
storage: { level: "ok", alerts: [] },
...over,
}) as SystemHealth;
const resources: SystemResources = {
at: new Date().toISOString(),
intervalSec: 5,
bucketSec: 5,
cpu: { scope: "cgroup", percent: 42.4, cores: 4, hostPercent: 30, load: [1, 1, 1] },
memory: {
scope: "cgroup",
usedBytes: 900,
limitBytes: 1000,
percent: 92,
hostTotalBytes: 2000,
hostAvailableBytes: 800,
swapUsedBytes: 0,
},
network: { scope: "cgroup", rxBps: 1, txBps: 1, rxTotalBytes: 1, txTotalBytes: 1 },
diskIo: { scope: "cgroup", readBps: 1, writeBps: 1 },
hostUptimeSec: 100,
samples: [],
};
function render(h: SystemHealth, downloaders: Dashboard["sources"]["downloaders"]) {
const client = new QueryClient();
client.setQueryData(qk.sub("system", "health"), h);
client.setQueryData(qk.sub("system", "resources", 30), resources);
client.setQueryData([...qk.dashboard, "sites-accounts"], {
items: [
{ id: "s1", enabled: true, accountStatus: "ok" },
{ id: "s2", enabled: true, accountStatus: "expired" },
{ id: "s3", enabled: false, accountStatus: "expired" },
],
nextCursor: null,
});
return renderToStaticMarkup(
createElement(
QueryClientProvider,
{ client },
createElement(MemoryRouter, null, createElement(HomeRuntime, { downloaders })),
),
);
}
describe("首页运行状态卡", () => {
it("整体健康、CPU / 内存、磁盘、下载器 / 站点 / 媒体库目录摘要,链到运行状态页", () => {
const html = render(health(), [
{ id: "d1", name: "qb", enabled: true, health: "healthy", lastSeenAt: null },
{ id: "d2", name: "qb2", enabled: true, health: "offline", lastSeenAt: null },
] as Dashboard["sources"]["downloaders"]);
expect(html).toContain("运行状态");
expect(html).toContain('href="/console/status"');
expect(html).toContain("需要关注");
expect(html).toContain("42%");
// 内存 ≥ 90% 标红
expect(html).toMatch(/text-destructive[^"]*">92%/);
expect(html).toContain("电影");
expect(html).toContain("87%");
// 摘要:正常数 / 总数(停用的不算)
expect(html).toMatch(/下载器<span[^>]*>1\/2/);
expect(html).toMatch(/站点<span[^>]*>1\/2/);
expect(html).toMatch(/媒体库目录<span[^>]*>1\/2/);
});
it("全部正常时写「全部正常」,没有配置的项不列", () => {
const html = render(health({ level: "ok", mounts: [] }), []);
expect(html).toContain("全部正常");
expect(html).not.toContain("媒体库目录<");
expect(html).not.toContain("下载器<span");
});
});
@@ -1,13 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { type Dashboard, DownloadBoard, type HealthLevel } from "@umt/contracts";
import { ArrowRight, Download, HardDrive, type LucideIcon } from "lucide-react";
import { ArrowRight, Download, Gauge, type LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Link } from "react-router";
import { useSitesWithAccounts } from "~/api/dashboard.ts";
import { apiParsed } from "~/api/parse.ts";
import { useSystemHealth } from "~/api/system.ts";
import { useSystemHealth, useSystemResources } from "~/api/system.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { Sparkline, usageTone } from "~/components/status/resource-section.tsx";
import { formatRelative } from "~/lib/labels.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
@@ -48,74 +50,129 @@ function OverviewCard({
);
}
function StorageOverview() {
const q = useSystemHealth(60_000);
const mediaDisks = q.data?.disks.filter((d) => d.scope !== "data_dir") ?? [];
const disks = mediaDisks.length ? mediaDisks : (q.data?.disks ?? []);
const BAD_ACCOUNT = ["invalid", "expired", "challenge"];
/** 一行「名称 正常数 / 总数」;有异常时数值标黄 */
function SummaryItem({ label, ok, total, to }: { label: string; ok: number; total: number; to: string }) {
if (!total) return null;
return (
<OverviewCard title="存储情况" icon={HardDrive} to={consolePath("status")}>
<Link to={to} className="inline-flex items-center gap-1 hover:text-foreground">
<span
aria-hidden
className={cn("inline-block size-1.5 rounded-full", ok < total ? "bg-warning" : "bg-success")}
/>
{label}
<span className={cn("tabular-nums", ok < total ? "text-warning" : "text-foreground")}>
{ok}/{total}
</span>
</Link>
);
}
/**
* 运行状态简版(界面第二轮,参考 MP 仪表盘):整体健康、CPU、内存、磁盘,以及下载器 / 站点 / 媒体库目录在线摘要。
* 详细版在系统 → 运行状态。
*/
function RuntimeOverview({ downloaders }: { downloaders?: Dashboard["sources"]["downloaders"] }) {
const q = useSystemHealth(60_000);
const r = useSystemResources(30, 10_000);
const sites = useSitesWithAccounts();
const h = q.data;
const res = r.data;
const mediaDisks = h?.disks.filter((d) => d.scope !== "data_dir") ?? [];
const disks = (mediaDisks.length ? mediaDisks : (h?.disks ?? []))
.filter((d) => d.usedRatio !== null)
.sort((a, b) => (b.usedRatio ?? 0) - (a.usedRatio ?? 0))
.slice(0, 3);
const enabledDl = (downloaders ?? []).filter((d) => d.enabled);
const okDl = enabledDl.filter((d) => !["offline", "failing"].includes(d.health)).length;
const siteRows = (sites.data?.items ?? []).filter((s) => s.enabled);
const okSites = siteRows.filter((s) => !(s.accountStatus && BAD_ACCOUNT.includes(s.accountStatus))).length;
const roots = h?.mounts.filter((m) => m.kind === "library_root") ?? [];
const okRoots = roots.filter((m) => m.status === "online").length;
const level = h ? LEVEL[h.level === "unknown" ? "unknown" : h.level] : null;
return (
<OverviewCard title="运行状态" icon={Gauge} to={consolePath("status")}>
{q.isPending ? (
<LoadingState rows={2} />
<LoadingState rows={3} />
) : q.isError ? (
<ErrorState plain title="存储状态加载失败" onRetry={() => q.refetch()} className="py-6" />
) : !disks.length ? (
<EmptyState plain icon={HardDrive} title="暂无存储状态" className="py-6" />
) : (
<ul className="space-y-4">
{disks.map((d) => {
const pct = d.usedRatio === null ? null : d.usedRatio * 100;
const state = LEVEL[d.level];
return (
<li key={`${d.scope}-${d.id ?? d.path}`} className="min-w-0">
<div className="flex items-center justify-between gap-2">
<p className="min-w-0 truncate text-sm font-medium" title={d.name}>
{d.name !== SCOPE[d.scope] ? (
<span className="mr-1.5 text-meta font-normal text-muted-foreground">{SCOPE[d.scope]}</span>
) : null}
{d.name}
</p>
<StatusBadge tone={state.tone}>
{state.label}
{pct === null ? "" : ` · ${Math.round(pct)}%`}
</StatusBadge>
</div>
{pct !== null ? (
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
<div
className={cn(
"h-full rounded-full",
d.level === "critical"
? "bg-destructive"
: d.level === "warn"
? "bg-warning"
: "bg-foreground/60",
)}
style={{ width: `${pct}%` }}
/>
</div>
) : null}
<p className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums">
<span>
剩余{" "}
<span className="font-medium text-foreground">
{d.availableBytes === null ? "未知" : formatBytes(d.availableBytes, 1)}
</span>
<ErrorState plain title="运行状态加载失败" onRetry={() => q.refetch()} className="py-6" />
) : h ? (
<>
<div className="mb-3 flex items-center gap-2">
{level ? <StatusBadge tone={level.tone}>{h.level === "ok" ? "全部正常" : level.label}</StatusBadge> : null}
<span className="text-meta text-muted-foreground">检查于 {formatRelative(h.checkedAt)}</span>
</div>
<div className="grid grid-cols-2 gap-3">
{[
{ label: "CPU", v: res?.cpu.percent, key: "cpuPercent" as const },
{ label: "内存", v: res?.memory.percent, key: "memoryPercent" as const },
].map((x) => (
<div key={x.label} className="min-w-0 rounded-lg border border-border/70 px-3 py-2">
<p className="flex items-center justify-between text-meta text-muted-foreground">
{x.label}
<span className={cn("text-body font-semibold tabular-nums", usageTone(x.v))}>
{x.v == null ? "—" : `${Math.round(x.v)}%`}
</span>
<span>已用 {d.usedBytes === null ? "未知" : formatBytes(d.usedBytes, 1)}</span>
<span>总量 {d.totalBytes === null ? "未知" : formatBytes(d.totalBytes, 1)}</span>
</p>
<p className="mt-1 truncate text-meta text-muted-foreground" title={d.path}>
{d.path}
</p>
{d.error ? <p className="mt-1 text-meta text-warning">无法读取磁盘用量</p> : null}
</li>
);
})}
</ul>
{res ? (
<Sparkline
samples={res.samples}
keys={[{ key: x.key, name: x.label }]}
format={(v) => `${v.toFixed(1)}%`}
max={100}
height={32}
label={`${x.label}占用趋势(最近 30 分钟)`}
/>
) : (
<div className="h-8" />
)}
</div>
))}
</div>
{disks.length ? (
<ul className="mt-3 space-y-2">
{disks.map((d) => {
const pct = (d.usedRatio ?? 0) * 100;
return (
<li key={`${d.scope}-${d.id ?? d.path}`} className="min-w-0">
<div className="flex items-center justify-between gap-2 text-meta">
<span className="min-w-0 truncate" title={d.path}>
<span className="mr-1 text-muted-foreground">{SCOPE[d.scope]}</span>
{d.name}
</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{Math.round(pct)}% · 剩余{" "}
{d.availableBytes === null ? "未知" : formatBytes(d.availableBytes, 1)}
</span>
</div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
<div
className={cn(
"h-full rounded-full",
d.level === "critical"
? "bg-destructive"
: d.level === "warn"
? "bg-warning"
: "bg-foreground/60",
)}
style={{ width: `${pct}%` }}
/>
</div>
</li>
);
})}
</ul>
) : null}
<p className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-meta text-muted-foreground">
<SummaryItem label="下载器" ok={okDl} total={enabledDl.length} to={consolePath("downloads")} />
<SummaryItem label="站点" ok={okSites} total={siteRows.length} to={consolePath("sites")} />
<SummaryItem label="媒体库目录" ok={okRoots} total={roots.length} to={consolePath("status")} />
</p>
</>
) : (
<EmptyState plain icon={Gauge} title="暂无运行状态" className="py-6" />
)}
{q.data && !q.isError ? (
<p className="mt-3 text-meta text-muted-foreground">所在磁盘用量 · 检查于 {formatRelative(q.data.checkedAt)}</p>
) : null}
</OverviewCard>
);
}
@@ -202,7 +259,7 @@ function DownloaderOverview({ downloaders }: { downloaders?: Dashboard["sources"
export function HomeRuntime({ downloaders }: { downloaders?: Dashboard["sources"]["downloaders"] }) {
return (
<div className="mb-4 grid gap-4 lg:grid-cols-2">
<StorageOverview />
<RuntimeOverview downloaders={downloaders} />
<DownloaderOverview downloaders={downloaders} />
</div>
);
+298
View File
@@ -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",
};
+312
View File
@@ -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>
);
}
+240
View File
@@ -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= 高亮", () => {
expect(planPath()).toBe("/console/schedules");
expect(planPath({ section: "auto" })).toBe("/console/schedules#auto");
expect(planPath({ section: "checks", job: "check.pending" })).toBe("/console/schedules?job=check.pending#checks");
expect(planPath()).toBe("/console/logs?tab=schedules");
expect(planPath({ section: "auto" })).toBe("/console/logs?tab=schedules#auto");
expect(planPath({ section: "checks", job: "check.pending" })).toBe(
"/console/logs?tab=schedules&job=check.pending#checks",
);
});
it("运行记录跳任务页按定时任务筛选", () => {
expect(scheduleStreamPath("trash.purge")).toBe("/console/tasks?tab=all&kind=schedule&ref=trash.purge");
it("运行记录跳日志页按定时任务筛选", () => {
expect(scheduleStreamPath("trash.purge")).toBe("/console/logs?objectType=scheduled_job&objectId=trash.purge");
});
it("formatUsd:两位小数,不足一分写 < $0.01", () => {
+6 -5
View File
@@ -13,16 +13,17 @@ export const GROUP_ORDER: ScheduleGroup[] = ["collect", "download", "organize",
/** 计划页「自动任务」一节的分组(智能体巡检单独一节,放在最上面);维护类合成一组「数据清理」 */
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 const PLAN_PATH = "/console/schedules";
export const PLAN_PATH = "/console/logs?tab=schedules";
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}` : ""}`;
}
/** 任务页里某个定时任务触发的任务(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> = {
collect: "采集",
@@ -18,9 +18,9 @@ import { DraftCheckDialog } from "./draft-check-dialog.tsx";
import { formatUsd } from "./labels.ts";
import { RunsSheet } from "./runs-sheet.tsx";
// 计划页(FE-6,/console/schedules):「智能体巡检」= 到点发起智能体会话的定时任务(卡片,放最上面:
// 定时任务(FE-6 的计划页;界面第二轮并入日志页「定时任务」页签,/console/logs?tab=schedules):「智能体巡检」= 到点发起智能体会话的定时任务(卡片,放最上面:
// 数量少、由用户自己建、会花钱,是这页最需要看的);「自动任务」= 程序按固定周期执行的定时任务
// (按分类折叠的紧凑列表,维护类合成「数据清理」)。自动任务的运行记录跳任务页按该定时任务筛选。
// (按分类折叠的紧凑列表,维护类合成「数据清理」)。自动任务的运行记录跳日志页按该定时任务筛选。
/** 每秒一跳的当前时间(倒计时用) */
export function useNow(ms = 1000) {
@@ -0,0 +1,271 @@
import type { ResourceSample, ResourceScope, SystemResources } from "@umt/contracts";
import { Activity, Cpu, HardDrive, MemoryStick, Network } from "lucide-react";
import { type ReactNode, useState } from "react";
import { Line, LineChart, ResponsiveContainer, Tooltip, YAxis } from "recharts";
import { useSystemResources } from "~/api/system.ts";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { formatDuration } from "~/lib/labels.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
// 资源监控(界面第二轮,参考 MP 仪表盘的 CPU / 内存 / 网络组件):四块磁贴,每块一个当前值 + 小趋势线。
// 图表沿用运行状态页的无彩口径:前景色细线,一张图一个量;网络收 / 发两条线靠线型与图例区分。
const SCOPE_LABEL: Record<ResourceScope, string> = { cgroup: "本容器", host: "整机", none: "取不到" };
const pct = (v: number | null | undefined) => (v == null ? "—" : `${Math.round(v)}%`);
const rate = (v: number | null | undefined) => (v == null ? "—" : `${formatBytes(v, 1)}/s`);
/** 用量分档:≥ 90% 红、≥ 75% 黄(只给数值上色,文字仍写清) */
export function usageTone(v: number | null | undefined): string {
if (v == null) return "text-foreground";
return v >= 90 ? "text-destructive" : v >= 75 ? "text-warning" : "text-foreground";
}
type Key = Exclude<keyof ResourceSample, "at">;
function clock(iso: string) {
return new Date(iso).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
}
/** 小趋势线:无坐标轴,悬停看时间与数值 */
export function Sparkline({
samples,
keys,
format,
max,
height = 48,
label,
}: {
samples: ResourceSample[];
keys: { key: Key; name: string; dash?: string }[];
format: (v: number) => string;
/** 纵轴上限(百分比类给 100,保持刻度稳定) */
max?: number;
height?: number;
label: string;
}) {
const has = samples.some((s) => keys.some((k) => s[k.key] != null));
if (!has)
return (
<div className="flex items-center text-meta text-muted-foreground" style={{ height }}>
正在采样…
</div>
);
return (
<div role="img" aria-label={label} className="text-muted-foreground" style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={samples} margin={{ top: 4, right: 2, bottom: 2, left: 2 }}>
<YAxis hide domain={[0, max ?? "auto"]} />
<Tooltip
labelFormatter={(_, p) => (p?.[0]?.payload?.at ? clock(String(p[0].payload.at)) : "")}
formatter={(v, name) => [format(Number(v)), String(name)]}
contentStyle={{
background: "var(--color-popover)",
border: "1px solid var(--color-border)",
borderRadius: 12,
fontSize: 12,
color: "var(--color-foreground)",
}}
cursor={{ stroke: "var(--color-muted-foreground)", strokeDasharray: "3 3" }}
/>
{keys.map((k, i) => (
<Line
key={k.key}
type="monotone"
dataKey={k.key}
name={k.name}
stroke="var(--color-foreground)"
strokeOpacity={i ? 0.5 : 0.85}
strokeDasharray={k.dash}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, strokeWidth: 2, stroke: "var(--color-background)" }}
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
);
}
function Tile({
icon: Icon,
title,
scope,
value,
valueClass,
hint,
children,
}: {
icon: typeof Cpu;
title: string;
scope: ResourceScope;
value: ReactNode;
valueClass?: string;
hint: ReactNode;
children: ReactNode;
}) {
return (
<section className="pg-slab min-w-0 rounded-xl p-4">
<div className="flex items-center gap-1.5 text-meta text-muted-foreground">
<Icon className="h-3.5 w-3.5" aria-hidden />
<span>{title}</span>
<span className="ml-auto">{SCOPE_LABEL[scope]}</span>
</div>
<p className={cn("mt-1 text-title-sm font-semibold tabular-nums", valueClass)}>{value}</p>
<p className="mt-0.5 truncate text-meta text-muted-foreground tabular-nums">{hint}</p>
<div className="mt-2">{children}</div>
</section>
);
}
/** 最近 n 分钟平均(MP 的 1m / 5m / 15m 平均) */
function avg(samples: ResourceSample[], key: Key, minutes: number): number | null {
const from = Date.now() - minutes * 60_000;
const v = samples
.filter((s) => Date.parse(s.at) >= from)
.map((s) => s[key])
.filter((x): x is number => x != null);
return v.length ? v.reduce((a, b) => a + b, 0) / v.length : null;
}
export function ResourceTiles({ d }: { d: SystemResources }) {
const m = d.memory;
return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<Tile
icon={Cpu}
title="CPU"
scope={d.cpu.scope}
value={pct(d.cpu.percent)}
valueClass={usageTone(d.cpu.percent)}
hint={
<>
{d.cpu.cores ? `${d.cpu.cores} 核` : ""}
{d.cpu.load ? ` · 负载 ${d.cpu.load.map((x) => x.toFixed(2)).join(" / ")}` : ""}
{` · 5 分钟平均 ${pct(avg(d.samples, "cpuPercent", 5))}`}
</>
}
>
<Sparkline
samples={d.samples}
keys={[{ key: "cpuPercent", name: "CPU" }]}
format={(v) => `${v.toFixed(1)}%`}
max={100}
label="CPU 占用趋势"
/>
</Tile>
<Tile
icon={MemoryStick}
title="内存"
scope={m.scope}
value={pct(m.percent)}
valueClass={usageTone(m.percent)}
hint={
<>
{m.usedBytes != null ? formatBytes(m.usedBytes, 1) : "—"}
{m.limitBytes != null ? ` / ${formatBytes(m.limitBytes, 1)}` : ""}
{m.scope === "cgroup" && m.hostTotalBytes != null && m.hostAvailableBytes != null
? ` · 整机可用 ${formatBytes(m.hostAvailableBytes, 1)}`
: ""}
</>
}
>
<Sparkline
samples={d.samples}
keys={[{ key: "memoryPercent", name: "内存" }]}
format={(v) => `${v.toFixed(1)}%`}
max={100}
label="内存占用趋势"
/>
</Tile>
<Tile
icon={Network}
title="网络"
scope={d.network.scope}
value={
<>
<span className="text-caption font-normal text-muted-foreground">↓ </span>
{rate(d.network.rxBps)}
</>
}
hint={`↑ ${rate(d.network.txBps)}${d.network.rxTotalBytes != null ? ` · 累计收 ${formatBytes(d.network.rxTotalBytes, 1)}` : ""}`}
>
<Sparkline
samples={d.samples}
keys={[
{ key: "netRxBps", name: "接收" },
{ key: "netTxBps", name: "发送", dash: "4 3" },
]}
format={(v) => `${formatBytes(v, 1)}/s`}
label="网络吞吐趋势(实线接收,虚线发送)"
/>
</Tile>
<Tile
icon={HardDrive}
title="磁盘读写"
scope={d.diskIo.scope}
value={
<>
<span className="text-caption font-normal text-muted-foreground">读 </span>
{rate(d.diskIo.readBps)}
</>
}
hint={`写 ${rate(d.diskIo.writeBps)}${d.hostUptimeSec != null ? ` · 主机已运行 ${formatDuration(d.hostUptimeSec)}` : ""}`}
>
<Sparkline
samples={d.samples}
keys={[
{ key: "diskReadBps", name: "读" },
{ key: "diskWriteBps", name: "写", dash: "4 3" },
]}
format={(v) => `${formatBytes(v, 1)}/s`}
label="磁盘读写趋势(实线读,虚线写)"
/>
</Tile>
</div>
);
}
const WINDOWS = [15, 60, 360];
/** 运行状态页「资源」节:窗口切换 + 四块磁贴 */
export function ResourceSection() {
const [minutes, setMinutes] = useState(60);
const q = useSystemResources(minutes);
return (
<section className="min-w-0">
<div className="mb-3 flex flex-wrap items-center gap-2">
<Activity className="h-4 w-4 text-muted-foreground" aria-hidden />
<h2 className="text-body font-semibold text-foreground">资源</h2>
<span className="text-meta text-muted-foreground">
每 {q.data?.intervalSec ?? 5} 秒采样一次,只保留最近 6 小时(重启后重新累计)
</span>
<Tabs value={String(minutes)} onValueChange={(v) => setMinutes(Number(v))} className="ml-auto">
<TabsList className="pb-0">
{WINDOWS.map((n) => (
<TabsTrigger key={n} value={String(n)} className="h-7 px-2.5 text-caption">
{n < 60 ? `${n} 分钟` : `${n / 60} 小时`}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
{q.isPending ? (
<LoadingState rows={3} />
) : q.isError ? (
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
) : (
<ResourceTiles d={q.data} />
)}
</section>
);
}
+1 -15
View File
@@ -15,7 +15,6 @@ export const BUSINESS_KIND_LABEL: Record<EngineBusinessKind, string> = {
decision: "待处理事项",
media: "作品",
};
export const BUSINESS_KINDS = Object.keys(BUSINESS_KIND_LABEL) as EngineBusinessKind[];
/** 来源:四类(定时/事件/手动/Agent);API Key 调用算手动 */
export type TaskSource = "schedule" | "event" | "manual" | "agent";
@@ -25,13 +24,6 @@ export const SOURCE_LABEL: Record<TaskSource, string> = {
manual: "手动",
agent: "智能体",
};
/** 来源 → 服务端 triggeredBy 筛选值(逗号分隔多值) */
export const SOURCE_TRIGGERED_BY: Record<TaskSource, string> = {
schedule: "schedule",
event: "event",
manual: "user,api_key",
agent: "agent",
};
export function sourceOf(t: EngineTriggeredBy | null | undefined): TaskSource | null {
if (!t) return null;
if (t === "user" || t === "api_key") return "manual";
@@ -86,10 +78,6 @@ export function engineRunStatus(r: Pick<EngineRun, "status" | "business">) {
return OUTCOME_STATUS[outcome];
return runStatus(r.status);
}
export const RUN_STATUS_OPTIONS = ["pending", "running", "completed", "failed", "canceled"].map((value) => ({
value,
label: runStatus(value).label,
}));
/** 行标题:业务对象名 > 业务类型 + 短 id > 工作流做什么 */
export function runTitle(r: Pick<EngineRun, "business" | "workflowName">): string {
@@ -118,7 +106,7 @@ export function businessLink(b: EngineRun["business"], input?: unknown): { to: s
case "agent":
return { to: `/console/agent/${id}`, label: "智能体会话" };
case "schedule":
return { to: `/console/schedules?job=${id}`, label: "定时任务" };
return { to: `/console/logs?tab=schedules&job=${id}`, label: "定时任务" };
case "feed":
// RSS 属于站点(IA-2):旧 RSS 页按 ?feed= 查所属站点后跳到站点的 RSS Tab
return { to: `${consolePath("sources")}?feed=${encodeURIComponent(id)}`, label: "RSS" };
@@ -133,5 +121,3 @@ export function businessLink(b: EngineRun["business"], input?: unknown): { to: s
return null;
}
}
export const ENGINE_ACTIVE = new Set(["pending", "running", "sleeping"]);
+30
View File
@@ -0,0 +1,30 @@
import { useQueryClient } from "@tanstack/react-query";
import { useRef } from "react";
import { qk } from "~/lib/query-keys.ts";
import { useSse } from "~/lib/sse.ts";
/** 引擎本身不发事件;业务事件(outbox → SSE 桥)足以代表任务在动,2 秒节流刷新任务相关查询 */
const TASK_EVENTS = [
"task.created",
"task.status_changed",
"download.status_changed",
"download.threshold",
"organization.changed",
"feed.fetched",
"decision.created",
"decision.updated",
"agent.run_status",
"schedule.changed",
];
export function useLiveTasks() {
const qc = useQueryClient();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useSse(TASK_EVENTS, () => {
if (timer.current) return;
timer.current = setTimeout(() => {
timer.current = null;
qc.invalidateQueries({ queryKey: qk.all("engine") });
}, 2000);
});
}
@@ -11,6 +11,7 @@ import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Timeline, type TimelineItem } from "~/components/console/timeline.tsx";
import { BUSINESS_OBJECT_TYPE, objectLogsPath } from "~/components/logs/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatDuration, formatRelative, spanSec } from "~/lib/labels.ts";
@@ -240,13 +241,14 @@ export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun
const link = businessLink(b, r.input);
const src = sourceOf(b?.triggeredBy);
const hasError = r.error !== null && r.error !== undefined;
// 相关日志(日志页按对象筛选):本任务的操作留痕;关联对象的全部日志(任务、定时任务运行、审计)
const audits = [
{ to: `/console/audit?targetType=engine_run&targetId=${encodeURIComponent(r.id)}`, label: "本任务的操作记录" },
...(b?.kind === "schedule"
{ to: objectLogsPath("engine_run", r.id), label: "本任务的操作记录" },
...(b && BUSINESS_OBJECT_TYPE[b.kind]
? [
{
to: `/console/audit?targetType=scheduled_job&targetId=${encodeURIComponent(b.refId)}`,
label: "定时任务的修改记录",
to: objectLogsPath(BUSINESS_OBJECT_TYPE[b.kind]!, b.refId),
label: b.kind === "schedule" ? "定时任务的全部日志" : "关联对象的全部日志",
},
]
: []),
@@ -287,7 +289,7 @@ export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun
<Timeline items={businessTimeline(r)} />
</DetailSection>
<DetailSection title="相关审计">
<DetailSection title="相关日志">
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-caption">
{audits.map((a) => (
<li key={a.to}>
@@ -1,307 +0,0 @@
import { useQueryClient } from "@tanstack/react-query";
import type { EngineBusinessKind, EngineRun, ScheduledJob } from "@umt/contracts";
import { CalendarClock, ExternalLink, History, ListTodo, X } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { Link } from "react-router";
import { useEngineRuns } from "~/api/engine.ts";
import { useScheduleBoard } from "~/api/schedules.ts";
import { DataTable } from "~/components/console/data-table.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { RunsSheet } from "~/components/schedules/runs-sheet.tsx";
import { Button } from "~/components/ui/button.tsx";
import { formatRelative } from "~/lib/labels.ts";
import { qk } from "~/lib/query-keys.ts";
import { useSse } from "~/lib/sse.ts";
import {
BUSINESS_KIND_LABEL,
BUSINESS_KINDS,
businessLink,
ENGINE_ACTIVE,
engineRunStatus,
RUN_STATUS_OPTIONS,
runTitle,
SOURCE_LABEL,
SOURCE_TRIGGERED_BY,
sourceOf,
type TaskSource,
workflowLabel,
} from "./labels.ts";
export type StreamTab = "all" | "active" | "failed";
/** 引擎本身不发事件;业务事件(outbox → SSE 桥)足以代表任务在动,2 秒节流刷新 */
const TASK_EVENTS = [
"task.created",
"task.status_changed",
"download.status_changed",
"download.threshold",
"organization.changed",
"feed.fetched",
"decision.created",
"decision.updated",
"agent.run_status",
"schedule.changed",
];
export function useLiveTasks() {
const qc = useQueryClient();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useSse(TASK_EVENTS, () => {
if (timer.current) return;
timer.current = setTimeout(() => {
timer.current = null;
qc.invalidateQueries({ queryKey: qk.all("engine") });
}, 2000);
});
}
const SINCE: { value: string; label: string; ms: number }[] = [
{ value: "1h", label: "1 小时内", ms: 3600_000 },
{ value: "24h", label: "24 小时内", ms: 86400_000 },
{ value: "7d", label: "7 天内", ms: 7 * 86400_000 },
{ value: "30d", label: "30 天内", ms: 30 * 86400_000 },
];
export type StreamFilters = {
status: string;
kind: string;
ref: string;
source: string;
since: string;
workflow: string;
/** "1" = 显示系统任务 */
system: string;
};
function SourceBadge({ r }: { r: EngineRun }) {
const s = sourceOf(r.business?.triggeredBy);
return s ? <span className="text-caption">{SOURCE_LABEL[s]}</span> : <span className="text-muted-foreground">—</span>;
}
/** 定时任务筛选时,页头给出定时任务自己的运行记录(含跳过的,引擎里没有) */
function ScheduleBanner({ jobId, onClear }: { jobId: string; onClear: () => void }) {
const board = useScheduleBoard();
const job = board.data?.items.find((j) => j.id === jobId) ?? null;
const [open, setOpen] = useState<ScheduledJob | null>(null);
return (
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-xl border border-border/70 px-3 py-2 text-caption">
<CalendarClock className="h-4 w-4 text-muted-foreground" />
<span className="min-w-0 truncate">
定时任务 <span className="font-medium text-foreground">{job?.name ?? jobId}</span> 触发的任务
</span>
<span className="ml-auto flex items-center gap-1">
<Button variant="ghost" size="xs" disabled={!job} onClick={() => setOpen(job)}>
<History />
运行记录
</Button>
<Button asChild variant="ghost" size="xs">
<Link to={`/console/schedules?job=${encodeURIComponent(jobId)}`}>
<ExternalLink />
计划
</Link>
</Button>
<Button variant="ghost" size="icon-xs" aria-label="清除定时任务筛选" onClick={onClear}>
<X />
</Button>
</span>
<RunsSheet job={open} onClose={() => setOpen(null)} />
</div>
);
}
export function TaskStream({
tab,
filters,
setFilter,
resetFilters,
onOpen,
selectedId,
}: {
tab: StreamTab;
filters: StreamFilters;
setFilter: (k: keyof StreamFilters, v: string) => void;
resetFilters: () => void;
onOpen: (id: string) => void;
selectedId: string | null;
}) {
useLiveTasks();
// 时间下界按分钟取整,查询 key 稳定
const from = useMemo(() => {
const ms = SINCE.find((s) => s.value === filters.since)?.ms;
return ms ? new Date(Math.floor((Date.now() - ms) / 60_000) * 60_000).toISOString() : undefined;
}, [filters.since]);
const base = {
businessKind: (filters.kind || undefined) as EngineBusinessKind | undefined,
businessRefId: filters.ref || undefined,
workflowName: filters.workflow || undefined,
triggeredBy: SOURCE_TRIGGERED_BY[filters.source as TaskSource] ?? undefined,
from,
};
const active = tab === "active";
// 进行中 = 进行中 + 排队(status 多值,服务端分页);来源筛选同样在服务端做
const list = useEngineRuns(
{
...base,
status: active ? "running,pending" : tab === "failed" ? "failed" : filters.status || undefined,
},
true,
active ? 10_000 : false,
);
const all = list.data?.pages.flatMap((p) => p.items);
// 系统任务(没有业务对象的引擎步骤、定时任务的成功运行)默认不列;按对象 / 定时任务筛选或勾选时显示
const showSystem = filters.system === "1" || !!filters.ref || filters.kind === "schedule" || !!filters.workflow;
const isSystem = (r: EngineRun) => !r.business || (r.business.kind === "schedule" && r.status === "completed");
const rows = showSystem ? all : all?.filter((r) => !isSystem(r));
const hidden = (all?.length ?? 0) - (rows?.length ?? 0);
const filtered = Object.entries(filters).some(([k, v]) => k !== "system" && !!v);
return (
<>
{filters.kind === "schedule" && filters.ref ? (
<ScheduleBanner
jobId={filters.ref}
onClear={() => {
setFilter("ref", "");
setFilter("kind", "");
}}
/>
) : filters.ref ? (
<div className="mb-3 flex items-center gap-2 text-caption text-muted-foreground">
只看对象 <code className="font-mono text-foreground">{filters.ref}</code>
<Button variant="ghost" size="icon-xs" aria-label="清除对象筛选" onClick={() => setFilter("ref", "")}>
<X />
</Button>
</div>
) : null}
<FilterBar onReset={filtered ? resetFilters : undefined}>
<FilterSelect
label="来源"
value={filters.source}
onChange={(v) => setFilter("source", v)}
options={(Object.keys(SOURCE_LABEL) as TaskSource[]).map((value) => ({ value, label: SOURCE_LABEL[value] }))}
/>
<FilterSelect
label="业务"
value={filters.kind}
onChange={(v) => setFilter("kind", v)}
options={BUSINESS_KINDS.map((value) => ({ value, label: BUSINESS_KIND_LABEL[value] }))}
/>
{tab === "all" ? (
<FilterSelect
label="状态"
value={filters.status}
onChange={(v) => setFilter("status", v)}
options={RUN_STATUS_OPTIONS}
/>
) : null}
<FilterSelect
label="时间"
value={filters.since}
onChange={(v) => setFilter("since", v)}
options={SINCE}
allLabel="不限"
/>
<Button
variant={filters.system === "1" ? "secondary" : "ghost"}
size="sm"
aria-pressed={filters.system === "1"}
onClick={() => setFilter("system", filters.system === "1" ? "" : "1")}
>
{filters.system === "1" ? "隐藏系统任务" : hidden ? `显示系统任务(${hidden})` : "显示系统任务"}
</Button>
{filters.workflow ? (
<Button variant="outline" size="sm" onClick={() => setFilter("workflow", "")}>
{workflowLabel(filters.workflow)}
<X />
</Button>
) : null}
</FilterBar>
<DataTable<EngineRun>
paginationKey={JSON.stringify({ tab, filters })}
rows={rows}
rowKey={(r) => r.id}
loading={list.isPending}
error={list.error}
onRetry={() => void list.refetch()}
onRowClick={(r) => onOpen(r.id)}
selectedKey={selectedId}
hasMore={!!list.hasNextPage}
onLoadMore={() => list.fetchNextPage()}
loadingMore={list.isFetchingNextPage}
empty={{
icon: ListTodo,
title: filtered
? "没有符合条件的任务"
: tab === "active"
? "没有进行中的任务"
: tab === "failed"
? "没有失败的任务"
: "还没有任务",
description: filters.source && list.hasNextPage ? "按来源筛选时一页可能较少,可翻页继续查找。" : undefined,
}}
columns={[
{
key: "title",
header: "任务",
cell: (r) => (
<div className="min-w-0 max-w-[15rem] sm:max-w-sm">
<p className="truncate font-medium text-foreground">{runTitle(r)}</p>
{r.business?.title ? (
<p className="truncate text-meta text-muted-foreground">{workflowLabel(r.workflowName)}</p>
) : null}
</div>
),
},
{ key: "source", header: "来源", hideBelow: "md", cell: (r) => <SourceBadge r={r} /> },
{
key: "status",
header: "状态",
cell: (r) => {
const s = engineRunStatus(r);
return <StatusBadge tone={s.tone}>{s.label}</StatusBadge>;
},
},
{
key: "time",
header: "开始",
hideBelow: "sm",
cell: (r) => (
<div className="whitespace-nowrap">
<p className="text-muted-foreground" title={r.startedAt ?? r.createdAt}>
{r.startedAt
? formatRelative(r.startedAt)
: ENGINE_ACTIVE.has(r.status)
? `排队 ${formatRelative(r.createdAt)}`
: `未开始 · ${formatRelative(r.createdAt)}`}
</p>
</div>
),
},
{
key: "link",
header: "关联",
hideBelow: "lg",
cell: (r) => {
const l = businessLink(r.business);
return l ? (
<Link
to={l.to}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 text-caption text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
{l.label}
<ExternalLink className="h-3 w-3" />
</Link>
) : (
<span className="text-muted-foreground">—</span>
);
},
},
]}
/>
</>
);
}