Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
131 lines
4.9 KiB
TypeScript
131 lines
4.9 KiB
TypeScript
import { createRoute } from "@hono/zod-openapi";
|
|
import { Api, apiResponse, LOG_STREAM_EVENT, type LogFilter, LogStreamQuery } from "@umt/contracts";
|
|
import type { Context } from "hono";
|
|
import { streamSSE } from "hono/streaming";
|
|
import { requireScope } from "../../http/auth.ts";
|
|
import { ApiException } from "../../http/errors.ts";
|
|
import type { ServerModule } from "../types.ts";
|
|
import { type LogQueries, logQueries } from "./query.ts";
|
|
import { createLogStreamPoller, STREAM_POLL_MS } from "./stream.ts";
|
|
|
|
export { decodeCursor, encodeCursor, logQueries } from "./query.ts";
|
|
export { createLogStreamPoller } from "./stream.ts";
|
|
|
|
/**
|
|
* 日志模块(界面第二轮 V 线,docs/runbook/logs.md):
|
|
* - GET /logs:任务 / 定时与自动 / 登录 / 审计合并成一份列表(查询时 union,不新建表),仅 admin;
|
|
* - GET /logs/stream:同一组筛选的 SSE 流,事件名 log.entry(新日志或状态变化,客户端按 id 覆盖)。
|
|
*/
|
|
export interface LogsModuleOptions {
|
|
/** 引擎 schema(测试覆盖);缺省 openworkflow */
|
|
engineSchema?: string;
|
|
/** 流的轮询间隔,缺省 2 秒 */
|
|
pollMs?: number;
|
|
keepaliveMs?: number;
|
|
}
|
|
|
|
export function createLogsModule(opts: LogsModuleOptions = {}): ServerModule {
|
|
return {
|
|
name: "logs",
|
|
registerRoutes(app, ctx) {
|
|
let q: LogQueries | null = null;
|
|
const queries = () => {
|
|
q ??= logQueries(ctx.db, { engineSchema: opts.engineSchema });
|
|
return q;
|
|
};
|
|
|
|
const list = Api.logs.list;
|
|
app.openapi(
|
|
createRoute({
|
|
method: list.method,
|
|
path: list.path,
|
|
summary: list.summary,
|
|
tags: ["logs"],
|
|
middleware: [requireScope(list.scope)] as never,
|
|
request: { query: list.query as never },
|
|
responses: {
|
|
200: { description: list.summary, content: { "application/json": { schema: apiResponse(list.response) } } },
|
|
},
|
|
}),
|
|
// biome-ignore lint/suspicious/noExplicitAny: 入参已由 zod-openapi 校验
|
|
(async (c: any) => {
|
|
const query = c.req.valid("query");
|
|
const data = list.response.parse(await queries().list(query));
|
|
return c.json({ ok: true, data }, 200);
|
|
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
|
}) as any,
|
|
);
|
|
|
|
const stream = Api.logs.stream;
|
|
app.openapi(
|
|
createRoute({
|
|
method: stream.method,
|
|
path: stream.path,
|
|
summary: stream.summary,
|
|
tags: ["logs"],
|
|
middleware: [requireScope(stream.scope)] as never,
|
|
responses: {
|
|
200: { description: stream.summary, content: { "text/event-stream": { schema: { type: "string" } } } },
|
|
},
|
|
}),
|
|
// SSE 响应不走 JSON 信封
|
|
(async (c: Context) => {
|
|
const parsed = LogStreamQuery.safeParse(c.req.query());
|
|
if (!parsed.success) throw new ApiException("bad_request", parsed.error.issues[0]?.message ?? "筛选参数无效");
|
|
const { since, ...filter } = parsed.data;
|
|
const poller = createLogStreamPoller(queries(), filter as LogFilter, {
|
|
...(since ? { since: new Date(since) } : {}),
|
|
});
|
|
const log = ctx.logger.child({ component: "logs-stream" });
|
|
return streamSSE(c, async (s) => {
|
|
let closed = false;
|
|
let wake: (() => void) | null = null;
|
|
s.onAbort(() => {
|
|
closed = true;
|
|
wake?.();
|
|
});
|
|
const keepalive = setInterval(() => {
|
|
void s.write(": keepalive\n\n").catch(() => {
|
|
closed = true;
|
|
});
|
|
}, opts.keepaliveMs ?? 15_000);
|
|
await s.writeSSE({ event: "ready", data: JSON.stringify({ at: new Date().toISOString() }) });
|
|
try {
|
|
while (!closed) {
|
|
let items: Awaited<ReturnType<typeof poller.poll>> = [];
|
|
try {
|
|
items = await poller.poll();
|
|
} catch (err) {
|
|
// 库暂时不可达:这一轮跳过,下一轮再查
|
|
log.debug({ err }, "日志流查询失败");
|
|
}
|
|
for (const e of items) {
|
|
if (closed) break;
|
|
await s.writeSSE({ id: e.id, event: LOG_STREAM_EVENT, data: JSON.stringify(e) }).catch(() => {
|
|
closed = true;
|
|
});
|
|
}
|
|
if (closed) break;
|
|
await new Promise<void>((r) => {
|
|
const t = setTimeout(r, opts.pollMs ?? STREAM_POLL_MS);
|
|
wake = () => {
|
|
clearTimeout(t);
|
|
r();
|
|
};
|
|
});
|
|
wake = null;
|
|
}
|
|
} finally {
|
|
clearInterval(keepalive);
|
|
}
|
|
});
|
|
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
|
}) as any,
|
|
);
|
|
},
|
|
registerTasks() {},
|
|
};
|
|
}
|
|
|
|
export const logsModule = createLogsModule();
|