From 01c68f9771a306b7fcd63ed06383c1c0a2bd2fa1 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 17:58:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(contracts):=20=E6=97=A5=E5=BF=97=E5=88=97?= =?UTF-8?q?=E8=A1=A8=20/=20=E6=97=A5=E5=BF=97=E6=B5=81=E4=B8=8E=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E7=9B=91=E6=8E=A7=E5=A5=91=E7=BA=A6=EF=BC=8C=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E6=96=B0=E5=A2=9E=E7=99=BB=E5=BD=95=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- packages/contracts/src/api.ts | 2 + packages/contracts/src/api/logs.ts | 24 +++++ packages/contracts/src/api/system.ts | 9 ++ packages/contracts/src/auth.ts | 1 + packages/contracts/src/index.ts | 2 + packages/contracts/src/logs.ts | 117 +++++++++++++++++++++ packages/contracts/src/system-resources.ts | 72 +++++++++++++ packages/contracts/test/schemas.test.ts | 3 + packages/db/migrations/0083_log_indexes.ts | 15 +++ 9 files changed, 245 insertions(+) create mode 100644 packages/contracts/src/api/logs.ts create mode 100644 packages/contracts/src/logs.ts create mode 100644 packages/contracts/src/system-resources.ts create mode 100644 packages/db/migrations/0083_log_indexes.ts diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index 47e996e5..eea71add 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -5,6 +5,7 @@ import { episodeMappingApi } from "./api/episode-mapping.ts"; import { executionApi } from "./api/execution.ts"; import { instancesApi } from "./api/instances.ts"; import { libraryApi } from "./api/library.ts"; +import { logsApi } from "./api/logs.ts"; import { opsApi } from "./api/ops.ts"; import { organizeApi } from "./api/organize.ts"; import { platformApi } from "./api/platform.ts"; @@ -41,6 +42,7 @@ export const Api = { ...organizeApi, ...platformApi, ...systemApi, + ...logsApi, ...libraryApi, ...browserApi, ...schedulesApi, diff --git a/packages/contracts/src/api/logs.ts b/packages/contracts/src/api/logs.ts new file mode 100644 index 00000000..3a446c48 --- /dev/null +++ b/packages/contracts/src/api/logs.ts @@ -0,0 +1,24 @@ +import * as logs from "../logs.ts"; +import { ep } from "./shared.ts"; + +// 日志模块(界面第二轮 V 线,docs/runbook/logs.md):业务任务、定时 / 自动任务、登录、审计合成一份列表。仅 admin。 +export const logsApi = { + logs: { + list: ep({ + method: "get", + path: "/logs", + scope: "admin", + summary: "日志列表:任务 / 定时与自动 / 登录 / 审计合并,按时间倒序,游标分页", + query: logs.LogQuery, + response: logs.LogPage, + }), + stream: ep({ + method: "get", + path: "/logs/stream", + scope: "admin", + summary: "日志流(text/event-stream):按同一组筛选推新日志与状态变化,事件名 log.entry", + query: logs.LogStreamQuery, + response: logs.LogEntry, + }), + }, +} as const; diff --git a/packages/contracts/src/api/system.ts b/packages/contracts/src/api/system.ts index 47d77277..b9fadae0 100644 --- a/packages/contracts/src/api/system.ts +++ b/packages/contracts/src/api/system.ts @@ -3,6 +3,7 @@ import * as auth from "../auth.ts"; import { cursorPage, HealthStatus, IdParam } from "../common.ts"; import * as events from "../events.ts"; import * as ops from "../ops.ts"; +import * as resources from "../system-resources.ts"; import { EngineRunIdParam, ep, OkResult } from "./shared.ts"; export const systemApi = { @@ -170,6 +171,14 @@ export const systemApi = { query: ops.SystemHealthHistoryQuery, response: ops.SystemHealthHistory, }), + resources: ep({ + method: "get", + path: "/system/resources", + scope: "admin", + summary: "资源监控:CPU / 内存 / 网络 / 磁盘读写的当前值与最近一段时间的趋势(api 进程内存采样)", + query: resources.SystemResourcesQuery, + response: resources.SystemResources, + }), storage: ep({ method: "get", path: "/system/storage", diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 8a77820b..4466c91e 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -194,6 +194,7 @@ export type PasskeyRenameRequest = z.infer; export const AuditAction = z.enum([ "admin.setup", "admin.login_success", + "admin.login_failed", "admin.logout", "admin.password_change", "admin.totp_enable", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index cf64f28f..23409032 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -18,6 +18,7 @@ export * from "./identity.ts"; export * from "./instances.ts"; export * from "./library-versions.ts"; export * from "./local-metadata.ts"; +export * from "./logs.ts"; export * from "./ops.ts"; export * from "./organization.ts"; export * from "./organize-sources.ts"; @@ -29,3 +30,4 @@ export * from "./scrape.ts"; export * from "./settings.ts"; export * from "./sources.ts"; export * from "./subtitles.ts"; +export * from "./system-resources.ts"; diff --git a/packages/contracts/src/logs.ts b/packages/contracts/src/logs.ts new file mode 100644 index 00000000..6f6c7eaa --- /dev/null +++ b/packages/contracts/src/logs.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; +import { cursorPage, Timestamp } from "./common.ts"; + +// 日志模块(界面第二轮 V 线):业务任务、定时 / 自动任务运行、登录、审计合成一份按时间倒序的列表。 +// 不新建表:GET /logs 在查询时把各来源表 union 起来(engine workflow_runs / scheduled_job_run / +// agent_run(trigger=event)/ audit_event),游标按 (时间, id) 分页;GET /logs/stream 用 SSE 追加新日志。 + +/** + * 类型: + * - task 业务任务(工作流引擎的一次运行) + * - auto 定时与自动:定时任务运行记录(含智能体巡检)、智能体后台调查 + * - auth 登录:管理员登录 / 退出 / 登录失败 / 二次验证 / Passkey / 改密、API Key 的增改撤 + * - audit 其余审计:设置、凭证、实例、文件操作、智能体操作等 + */ +export const LogType = z.enum(["task", "auto", "auth", "audit"]); +export type LogType = z.infer; + +/** 级别 / 结果:进行中、成功、信息(普通操作)、警告(取消、跳过以外的非成功,如登录失败、被拒绝、超出预算)、失败 */ +export const LogLevel = z.enum(["running", "success", "info", "warn", "error"]); +export type LogLevel = z.infer; + +/** 来源表 */ +export const LogSource = z.enum(["engine_run", "schedule_run", "agent_run", "audit_event"]); +export type LogSource = z.infer; + +export const LogObject = z.object({ + /** 对象类型:审计的 target_type;任务按业务对象换算(download_task / organization_record / agent_run / scheduled_job …) */ + type: z.string(), + id: z.string().nullable(), + /** 展示名(作品、下载任务、定时任务名等);取不到为 null */ + label: z.string().nullable(), +}); +export type LogObject = z.infer; + +export const LogEntry = z.object({ + /** :<原始 id>,全局唯一,也是游标的一部分 */ + id: z.string(), + type: LogType, + source: LogSource, + /** 原始记录 id(引擎 run id、scheduled_job_run id、agent_run id、audit_event id) */ + refId: z.string(), + /** 发生时间:任务为创建时间、定时任务为开始时间、审计为事件时间 */ + at: Timestamp, + finishedAt: Timestamp.nullable(), + durationMs: z.number().nonnegative().nullable(), + level: LogLevel, + /** 来源表的原始状态(completed / failed / ok / skipped / admin.login_success …) */ + status: z.string().nullable(), + /** 一行摘要:任务业务标题或工作流名、定时任务名、智能体目标、审计动作 */ + title: z.string(), + /** 第二行:错误、运行摘要、登录 IP 等 */ + message: z.string().nullable(), + object: LogObject.nullable(), + /** 操作者(审计)或触发来源(任务 / 定时任务) */ + actor: z.object({ type: z.string(), id: z.string().nullable() }).nullable(), + /** 各来源特有字段(workflowName、business、trigger、action、payload …),详情页用 */ + extra: z.record(z.string(), z.unknown()), +}); +export type LogEntry = z.infer; + +/** 单值或逗号分隔多值(type=task,auto) */ +function csv(e: z.ZodType, name: string) { + return z + .string() + .max(200) + .transform((s, ctx) => { + const out: T[] = []; + for (const p of s + .split(",") + .map((x) => x.trim()) + .filter(Boolean)) { + const r = e.safeParse(p); + if (!r.success) { + ctx.addIssue({ code: "custom", message: `未知 ${name}: ${p}` }); + return z.NEVER; + } + out.push(r.data); + } + return out; + }) + .optional(); +} + +/** 列表与流共用的筛选 */ +export const LogFilter = z.object({ + /** 逗号分隔多值;缺省全部 */ + type: csv(LogType, "type"), + level: csv(LogLevel, "level"), + from: Timestamp.optional(), + to: Timestamp.optional(), + /** 关键词:标题、摘要、错误、动作、对象 id(不区分大小写) */ + q: z.string().trim().max(200).optional(), + /** 对象:与 LogEntry.object 的 type / id 匹配;只给 objectId 时不限类型 */ + objectType: z.string().max(100).optional(), + objectId: z.string().max(200).optional(), + /** "1" = 也列系统任务(没有业务对象的引擎运行、定时任务触发的成功运行);缺省隐藏,按对象筛选时自动包含 */ + system: z.enum(["0", "1"]).optional(), +}); +export type LogFilter = z.infer; + +export const LogQuery = LogFilter.extend({ + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), +}); +export type LogQuery = z.infer; + +export const LogPage = cursorPage(LogEntry); +export type LogPage = z.infer; + +export const LogStreamQuery = LogFilter.extend({ + /** 只推这个时间之后的新日志(客户端列表里最新一条的时间);缺省为连接时刻 */ + since: Timestamp.optional(), +}); +export type LogStreamQuery = z.infer; + +/** SSE 事件名:entry = 新日志或已推过的日志状态有变化(按 id 覆盖) */ +export const LOG_STREAM_EVENT = "log.entry"; diff --git a/packages/contracts/src/system-resources.ts b/packages/contracts/src/system-resources.ts new file mode 100644 index 00000000..c0997f75 --- /dev/null +++ b/packages/contracts/src/system-resources.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; +import { Timestamp } from "./common.ts"; + +// 资源监控(界面第二轮 V 线,参考 MoviePilot 仪表盘的 CPU / 内存 / 网络组件): +// api 进程每 5 秒读一次 /proc 与 cgroup v2,采样只放内存(保留 6 小时,进程重启后从头攒)。 +// 容器里优先用 cgroup(本容器的 CPU 占用、内存、磁盘读写),取不到时退回 /proc 整机口径;都取不到的字段为 null。 + +/** 采集口径:cgroup = 本容器(cgroup v2);host = /proc 整机;none = 取不到 */ +export const ResourceScope = z.enum(["cgroup", "host", "none"]); +export type ResourceScope = z.infer; + +const Num = z.number().nonnegative().nullable(); + +export const ResourceSample = z.object({ + at: Timestamp, + /** CPU 占用 %(相对可用核数,0–100) */ + cpuPercent: Num, + memoryUsedBytes: Num, + memoryPercent: Num, + netRxBps: Num, + netTxBps: Num, + diskReadBps: Num, + diskWriteBps: Num, +}); +export type ResourceSample = z.infer; + +export const SystemResources = z.object({ + at: Timestamp, + /** 采样间隔(秒) */ + intervalSec: z.number().positive(), + /** 返回的趋势每点代表的秒数(长时间窗口按桶取平均) */ + bucketSec: z.number().positive(), + cpu: z.object({ + scope: ResourceScope, + percent: Num, + /** 可用核数:cgroup cpu.max 的限额,否则为在线核数 */ + cores: Num, + /** 整机 CPU 占用 %(/proc/stat),与 scope 无关 */ + hostPercent: Num, + /** 整机 1 / 5 / 15 分钟平均负载(3 个数) */ + load: z.array(z.number()).nullable(), + }), + memory: z.object({ + scope: ResourceScope, + /** 已用:cgroup 为 memory.current − inactive_file(同 docker stats);整机为 MemTotal − MemAvailable */ + usedBytes: Num, + /** 上限:cgroup memory.max(不限时退回整机总量) */ + limitBytes: Num, + percent: Num, + hostTotalBytes: Num, + hostAvailableBytes: Num, + swapUsedBytes: Num, + }), + network: z.object({ + scope: ResourceScope, + rxBps: Num, + txBps: Num, + rxTotalBytes: Num, + txTotalBytes: Num, + }), + diskIo: z.object({ scope: ResourceScope, readBps: Num, writeBps: Num }), + /** 整机已运行秒数(/proc/uptime) */ + hostUptimeSec: Num, + samples: z.array(ResourceSample), +}); +export type SystemResources = z.infer; + +export const SystemResourcesQuery = z.object({ + /** 趋势窗口(分钟),5–360,缺省 60 */ + minutes: z.coerce.number().int().min(5).max(360).default(60), +}); +export type SystemResourcesQuery = z.infer; diff --git a/packages/contracts/test/schemas.test.ts b/packages/contracts/test/schemas.test.ts index 97ac2109..6a7d39ea 100644 --- a/packages/contracts/test/schemas.test.ts +++ b/packages/contracts/test/schemas.test.ts @@ -101,6 +101,9 @@ const OVERRIDES: Record) => Record ({ ...v, sourcePaths: undefined }), FilesListQuery: (v) => ({ ...v, filter: "video,unidentified" }), EngineRunQuery: (v) => ({ ...v, status: "running,pending", triggeredBy: "user,api_key" }), + LogFilter: (v) => ({ ...v, type: "task,auth", level: "error,warn" }), + LogQuery: (v) => ({ ...v, type: "task,auth", level: "error,warn" }), + LogStreamQuery: (v) => ({ ...v, type: "auto", level: "running" }), CatalogLookupInput: (v) => v, IdentityResolveInput: (v) => v, AssetLocationCreate: (v) => v, diff --git a/packages/db/migrations/0083_log_indexes.ts b/packages/db/migrations/0083_log_indexes.ts new file mode 100644 index 00000000..2499a736 --- /dev/null +++ b/packages/db/migrations/0083_log_indexes.ts @@ -0,0 +1,15 @@ +import type { Kysely } from "kysely"; +import { execDdl } from "../src/migration-helpers.ts"; + +// 日志模块(界面第二轮 V 线):GET /logs 把定时任务运行记录按开始时间倒序与其它来源合并, +// 原有索引只有 (job_id, started_at),全局倒序要整表排序;补一个按时间的索引。 + +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function up(db: Kysely): Promise { + await execDdl(db, `create index if not exists scheduled_job_run_started_idx on scheduled_job_run (started_at desc);`); +} + +// biome-ignore lint/suspicious/noExplicitAny: 同上 +export async function down(db: Kysely): Promise { + await execDdl(db, `drop index if exists scheduled_job_run_started_idx;`); +}