feat(contracts): 日志列表 / 日志流与资源监控契约,审计新增登录失败

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 17:58:08 +08:00
1 parent ad3bf8926b
commit 01c68f9771
9 files changed
+245

No files matched your search

+2
View File
@@ -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,
+24
View File
@@ -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;
+9
View File
@@ -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",
+1
View File
@@ -194,6 +194,7 @@ export type PasskeyRenameRequest = z.infer<typeof PasskeyRenameRequest>;
export const AuditAction = z.enum([
"admin.setup",
"admin.login_success",
"admin.login_failed",
"admin.logout",
"admin.password_change",
"admin.totp_enable",
+2
View File
@@ -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";
+117
View File
@@ -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<typeof LogType>;
/** 级别 / 结果:进行中、成功、信息(普通操作)、警告(取消、跳过以外的非成功,如登录失败、被拒绝、超出预算)、失败 */
export const LogLevel = z.enum(["running", "success", "info", "warn", "error"]);
export type LogLevel = z.infer<typeof LogLevel>;
/** 来源表 */
export const LogSource = z.enum(["engine_run", "schedule_run", "agent_run", "audit_event"]);
export type LogSource = z.infer<typeof LogSource>;
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<typeof LogObject>;
export const LogEntry = z.object({
/** <source>:<原始 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<typeof LogEntry>;
/** 单值或逗号分隔多值(type=task,auto) */
function csv<T extends string>(e: z.ZodType<T>, 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<typeof LogFilter>;
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<typeof LogQuery>;
export const LogPage = cursorPage(LogEntry);
export type LogPage = z.infer<typeof LogPage>;
export const LogStreamQuery = LogFilter.extend({
/** 只推这个时间之后的新日志(客户端列表里最新一条的时间);缺省为连接时刻 */
since: Timestamp.optional(),
});
export type LogStreamQuery = z.infer<typeof LogStreamQuery>;
/** SSE 事件名:entry = 新日志或已推过的日志状态有变化(按 id 覆盖) */
export const LOG_STREAM_EVENT = "log.entry";
@@ -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<typeof ResourceScope>;
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<typeof ResourceSample>;
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<typeof SystemResources>;
export const SystemResourcesQuery = z.object({
/** 趋势窗口(分钟),5–360,缺省 60 */
minutes: z.coerce.number().int().min(5).max(360).default(60),
});
export type SystemResourcesQuery = z.infer<typeof SystemResourcesQuery>;
+3
View File
@@ -101,6 +101,9 @@ const OVERRIDES: Record<string, (v: Record<string, unknown>) => Record<string, u
OrganizeRequest: (v) => ({ ...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,
@@ -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<any>): Promise<void> {
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<any>): Promise<void> {
await execDdl(db, `drop index if exists scheduled_job_run_started_idx;`);
}