feat: add gateway logs tab
This commit is contained in:
@@ -50,6 +50,10 @@ import {
|
||||
GatewayFrameSchema,
|
||||
type HelloOk,
|
||||
HelloOkSchema,
|
||||
type LogsTailParams,
|
||||
LogsTailParamsSchema,
|
||||
type LogsTailResult,
|
||||
LogsTailResultSchema,
|
||||
type ModelsListParams,
|
||||
ModelsListParamsSchema,
|
||||
type NodeDescribeParams,
|
||||
@@ -258,6 +262,8 @@ export const validateCronRunParams =
|
||||
ajv.compile<CronRunParams>(CronRunParamsSchema);
|
||||
export const validateCronRunsParams =
|
||||
ajv.compile<CronRunsParams>(CronRunsParamsSchema);
|
||||
export const validateLogsTailParams =
|
||||
ajv.compile<LogsTailParams>(LogsTailParamsSchema);
|
||||
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
|
||||
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
|
||||
export const validateChatAbortParams = ajv.compile<ChatAbortParams>(
|
||||
@@ -338,6 +344,8 @@ export {
|
||||
CronRemoveParamsSchema,
|
||||
CronRunParamsSchema,
|
||||
CronRunsParamsSchema,
|
||||
LogsTailParamsSchema,
|
||||
LogsTailResultSchema,
|
||||
ChatHistoryParamsSchema,
|
||||
ChatSendParamsSchema,
|
||||
UpdateRunParamsSchema,
|
||||
@@ -407,6 +415,8 @@ export type {
|
||||
CronRunParams,
|
||||
CronRunsParams,
|
||||
CronRunLogEntry,
|
||||
LogsTailParams,
|
||||
LogsTailResult,
|
||||
PollParams,
|
||||
UpdateRunParams,
|
||||
};
|
||||
|
||||
@@ -808,6 +808,27 @@ export const CronRunLogEntrySchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const LogsTailParamsSchema = Type.Object(
|
||||
{
|
||||
cursor: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 5000 })),
|
||||
maxBytes: Type.Optional(Type.Integer({ minimum: 1, maximum: 1_000_000 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const LogsTailResultSchema = Type.Object(
|
||||
{
|
||||
file: NonEmptyString,
|
||||
cursor: Type.Integer({ minimum: 0 }),
|
||||
size: Type.Integer({ minimum: 0 }),
|
||||
lines: Type.Array(Type.String()),
|
||||
truncated: Type.Optional(Type.Boolean()),
|
||||
reset: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
// WebChat/WebSocket-native chat methods
|
||||
export const ChatHistoryParamsSchema = Type.Object(
|
||||
{
|
||||
@@ -920,6 +941,8 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
CronRunParams: CronRunParamsSchema,
|
||||
CronRunsParams: CronRunsParamsSchema,
|
||||
CronRunLogEntry: CronRunLogEntrySchema,
|
||||
LogsTailParams: LogsTailParamsSchema,
|
||||
LogsTailResult: LogsTailResultSchema,
|
||||
ChatHistoryParams: ChatHistoryParamsSchema,
|
||||
ChatSendParams: ChatSendParamsSchema,
|
||||
ChatAbortParams: ChatAbortParamsSchema,
|
||||
@@ -991,6 +1014,8 @@ export type CronRemoveParams = Static<typeof CronRemoveParamsSchema>;
|
||||
export type CronRunParams = Static<typeof CronRunParamsSchema>;
|
||||
export type CronRunsParams = Static<typeof CronRunsParamsSchema>;
|
||||
export type CronRunLogEntry = Static<typeof CronRunLogEntrySchema>;
|
||||
export type LogsTailParams = Static<typeof LogsTailParamsSchema>;
|
||||
export type LogsTailResult = Static<typeof LogsTailResultSchema>;
|
||||
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
|
||||
export type ChatEvent = Static<typeof ChatEventSchema>;
|
||||
export type UpdateRunParams = Static<typeof UpdateRunParamsSchema>;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { configHandlers } from "./server-methods/config.js";
|
||||
import { connectHandlers } from "./server-methods/connect.js";
|
||||
import { cronHandlers } from "./server-methods/cron.js";
|
||||
import { healthHandlers } from "./server-methods/health.js";
|
||||
import { logsHandlers } from "./server-methods/logs.js";
|
||||
import { modelsHandlers } from "./server-methods/models.js";
|
||||
import { nodeHandlers } from "./server-methods/nodes.js";
|
||||
import { providersHandlers } from "./server-methods/providers.js";
|
||||
@@ -25,6 +26,7 @@ import { wizardHandlers } from "./server-methods/wizard.js";
|
||||
|
||||
const handlers: GatewayRequestHandlers = {
|
||||
...connectHandlers,
|
||||
...logsHandlers,
|
||||
...voicewakeHandlers,
|
||||
...healthHandlers,
|
||||
...providersHandlers,
|
||||
|
||||
150
src/gateway/server-methods/logs.ts
Normal file
150
src/gateway/server-methods/logs.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateLogsTailParams,
|
||||
} from "../protocol/index.js";
|
||||
import { getResolvedLoggerSettings } from "../../logging.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
const DEFAULT_LIMIT = 500;
|
||||
const DEFAULT_MAX_BYTES = 250_000;
|
||||
const MAX_LIMIT = 5000;
|
||||
const MAX_BYTES = 1_000_000;
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
async function readLogSlice(params: {
|
||||
file: string;
|
||||
cursor?: number;
|
||||
limit: number;
|
||||
maxBytes: number;
|
||||
}) {
|
||||
const stat = await fs.stat(params.file).catch(() => null);
|
||||
if (!stat) {
|
||||
return {
|
||||
cursor: 0,
|
||||
size: 0,
|
||||
lines: [] as string[],
|
||||
truncated: false,
|
||||
reset: false,
|
||||
};
|
||||
}
|
||||
|
||||
const size = stat.size;
|
||||
const maxBytes = clamp(params.maxBytes, 1, MAX_BYTES);
|
||||
const limit = clamp(params.limit, 1, MAX_LIMIT);
|
||||
let cursor =
|
||||
typeof params.cursor === "number" && Number.isFinite(params.cursor)
|
||||
? Math.max(0, Math.floor(params.cursor))
|
||||
: undefined;
|
||||
let reset = false;
|
||||
let truncated = false;
|
||||
let start = 0;
|
||||
|
||||
if (cursor != null) {
|
||||
if (cursor > size) {
|
||||
reset = true;
|
||||
start = Math.max(0, size - maxBytes);
|
||||
truncated = start > 0;
|
||||
} else {
|
||||
start = cursor;
|
||||
if (size - start > maxBytes) {
|
||||
reset = true;
|
||||
truncated = true;
|
||||
start = Math.max(0, size - maxBytes);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
start = Math.max(0, size - maxBytes);
|
||||
truncated = start > 0;
|
||||
}
|
||||
|
||||
if (size === 0 || size <= start) {
|
||||
return {
|
||||
cursor: size,
|
||||
size,
|
||||
lines: [] as string[],
|
||||
truncated,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
const handle = await fs.open(params.file, "r");
|
||||
try {
|
||||
let prefix = "";
|
||||
if (start > 0) {
|
||||
const prefixBuf = Buffer.alloc(1);
|
||||
const prefixRead = await handle.read(prefixBuf, 0, 1, start - 1);
|
||||
prefix = prefixBuf.toString("utf8", 0, prefixRead.bytesRead);
|
||||
}
|
||||
|
||||
const length = Math.max(0, size - start);
|
||||
const buffer = Buffer.alloc(length);
|
||||
const readResult = await handle.read(buffer, 0, length, start);
|
||||
let text = buffer.toString("utf8", 0, readResult.bytesRead);
|
||||
let lines = text.split("\n");
|
||||
if (start > 0 && prefix !== "\n") {
|
||||
lines = lines.slice(1);
|
||||
}
|
||||
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
||||
lines = lines.slice(0, -1);
|
||||
}
|
||||
if (lines.length > limit) {
|
||||
lines = lines.slice(lines.length - limit);
|
||||
}
|
||||
|
||||
cursor = size;
|
||||
|
||||
return {
|
||||
cursor,
|
||||
size,
|
||||
lines,
|
||||
truncated,
|
||||
reset,
|
||||
};
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
export const logsHandlers: GatewayRequestHandlers = {
|
||||
"logs.tail": async ({ params, respond }) => {
|
||||
if (!validateLogsTailParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid logs.tail params: ${formatValidationErrors(validateLogsTailParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const p = params as { cursor?: number; limit?: number; maxBytes?: number };
|
||||
const file = getResolvedLoggerSettings().file;
|
||||
try {
|
||||
const result = await readLogSlice({
|
||||
file,
|
||||
cursor: p.cursor,
|
||||
limit: p.limit ?? DEFAULT_LIMIT,
|
||||
maxBytes: p.maxBytes ?? DEFAULT_MAX_BYTES,
|
||||
});
|
||||
respond(true, { file, ...result }, undefined);
|
||||
} catch (err) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
`log read failed: ${String(err)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -212,6 +212,7 @@ type Client = {
|
||||
|
||||
const METHODS = [
|
||||
"health",
|
||||
"logs.tail",
|
||||
"providers.status",
|
||||
"status",
|
||||
"usage.status",
|
||||
|
||||
Reference in New Issue
Block a user