feat: add gateway logs tab
This commit is contained in:
@@ -51,6 +51,7 @@
|
|||||||
- Web UI: allow reconnect + password URL auth for the control UI and always scrub auth params from the URL. Thanks @oswalpalash for PR #414.
|
- Web UI: allow reconnect + password URL auth for the control UI and always scrub auth params from the URL. Thanks @oswalpalash for PR #414.
|
||||||
- Web UI: add Connect button on Overview to apply connection changes. Thanks @wizaj for PR #385.
|
- Web UI: add Connect button on Overview to apply connection changes. Thanks @wizaj for PR #385.
|
||||||
- Web UI: keep Focus toggle on the top bar (swap with theme toggle) so it stays visible. Thanks @RobOK2050 for reporting. (#440)
|
- Web UI: keep Focus toggle on the top bar (swap with theme toggle) so it stays visible. Thanks @RobOK2050 for reporting. (#440)
|
||||||
|
- Web UI: add Logs tab for gateway file logs with filtering, auto-follow, and export.
|
||||||
- ClawdbotKit: fix SwiftPM resource bundling path for `tool-display.json`. Thanks @fcatuhe for PR #398.
|
- ClawdbotKit: fix SwiftPM resource bundling path for `tool-display.json`. Thanks @fcatuhe for PR #398.
|
||||||
- Tools: add Telegram/WhatsApp reaction tools (with per-provider gating). Thanks @zats for PR #353.
|
- Tools: add Telegram/WhatsApp reaction tools (with per-provider gating). Thanks @zats for PR #353.
|
||||||
- Tools: flatten literal-union schemas for Claude on Vertex AI. Thanks @carlulsoe for PR #409.
|
- Tools: flatten literal-union schemas for Claude on Vertex AI. Thanks @carlulsoe for PR #409.
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ Clawdbot uses a file logger backed by `tslog` ([`src/logging.ts`](https://github
|
|||||||
|
|
||||||
The file format is one JSON object per line.
|
The file format is one JSON object per line.
|
||||||
|
|
||||||
|
The Control UI Logs tab tails this file via the gateway (`logs.tail`).
|
||||||
|
|
||||||
**Verbose vs. log levels**
|
**Verbose vs. log levels**
|
||||||
|
|
||||||
- **File logs** are controlled exclusively by `logging.level`.
|
- **File logs** are controlled exclusively by `logging.level`.
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ The dashboard settings panel lets you store a token; passwords are not persisted
|
|||||||
- Config: apply + restart with validation (`config.apply`) and wake the last active session
|
- Config: apply + restart with validation (`config.apply`) and wake the last active session
|
||||||
- Config schema + form rendering (`config.schema`); Raw JSON editor remains available
|
- Config schema + form rendering (`config.schema`); Raw JSON editor remains available
|
||||||
- Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`)
|
- Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`)
|
||||||
|
- Logs: live tail of gateway file logs with filter/export (`logs.tail`)
|
||||||
- Update: run a package/git update + restart (`update.run`) with a restart report
|
- Update: run a package/git update + restart (`update.run`) with a restart report
|
||||||
|
|
||||||
## Tailnet access (recommended)
|
## Tailnet access (recommended)
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ import {
|
|||||||
GatewayFrameSchema,
|
GatewayFrameSchema,
|
||||||
type HelloOk,
|
type HelloOk,
|
||||||
HelloOkSchema,
|
HelloOkSchema,
|
||||||
|
type LogsTailParams,
|
||||||
|
LogsTailParamsSchema,
|
||||||
|
type LogsTailResult,
|
||||||
|
LogsTailResultSchema,
|
||||||
type ModelsListParams,
|
type ModelsListParams,
|
||||||
ModelsListParamsSchema,
|
ModelsListParamsSchema,
|
||||||
type NodeDescribeParams,
|
type NodeDescribeParams,
|
||||||
@@ -258,6 +262,8 @@ export const validateCronRunParams =
|
|||||||
ajv.compile<CronRunParams>(CronRunParamsSchema);
|
ajv.compile<CronRunParams>(CronRunParamsSchema);
|
||||||
export const validateCronRunsParams =
|
export const validateCronRunsParams =
|
||||||
ajv.compile<CronRunsParams>(CronRunsParamsSchema);
|
ajv.compile<CronRunsParams>(CronRunsParamsSchema);
|
||||||
|
export const validateLogsTailParams =
|
||||||
|
ajv.compile<LogsTailParams>(LogsTailParamsSchema);
|
||||||
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
|
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
|
||||||
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
|
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
|
||||||
export const validateChatAbortParams = ajv.compile<ChatAbortParams>(
|
export const validateChatAbortParams = ajv.compile<ChatAbortParams>(
|
||||||
@@ -338,6 +344,8 @@ export {
|
|||||||
CronRemoveParamsSchema,
|
CronRemoveParamsSchema,
|
||||||
CronRunParamsSchema,
|
CronRunParamsSchema,
|
||||||
CronRunsParamsSchema,
|
CronRunsParamsSchema,
|
||||||
|
LogsTailParamsSchema,
|
||||||
|
LogsTailResultSchema,
|
||||||
ChatHistoryParamsSchema,
|
ChatHistoryParamsSchema,
|
||||||
ChatSendParamsSchema,
|
ChatSendParamsSchema,
|
||||||
UpdateRunParamsSchema,
|
UpdateRunParamsSchema,
|
||||||
@@ -407,6 +415,8 @@ export type {
|
|||||||
CronRunParams,
|
CronRunParams,
|
||||||
CronRunsParams,
|
CronRunsParams,
|
||||||
CronRunLogEntry,
|
CronRunLogEntry,
|
||||||
|
LogsTailParams,
|
||||||
|
LogsTailResult,
|
||||||
PollParams,
|
PollParams,
|
||||||
UpdateRunParams,
|
UpdateRunParams,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -808,6 +808,27 @@ export const CronRunLogEntrySchema = Type.Object(
|
|||||||
{ additionalProperties: false },
|
{ 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
|
// WebChat/WebSocket-native chat methods
|
||||||
export const ChatHistoryParamsSchema = Type.Object(
|
export const ChatHistoryParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
@@ -920,6 +941,8 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||||||
CronRunParams: CronRunParamsSchema,
|
CronRunParams: CronRunParamsSchema,
|
||||||
CronRunsParams: CronRunsParamsSchema,
|
CronRunsParams: CronRunsParamsSchema,
|
||||||
CronRunLogEntry: CronRunLogEntrySchema,
|
CronRunLogEntry: CronRunLogEntrySchema,
|
||||||
|
LogsTailParams: LogsTailParamsSchema,
|
||||||
|
LogsTailResult: LogsTailResultSchema,
|
||||||
ChatHistoryParams: ChatHistoryParamsSchema,
|
ChatHistoryParams: ChatHistoryParamsSchema,
|
||||||
ChatSendParams: ChatSendParamsSchema,
|
ChatSendParams: ChatSendParamsSchema,
|
||||||
ChatAbortParams: ChatAbortParamsSchema,
|
ChatAbortParams: ChatAbortParamsSchema,
|
||||||
@@ -991,6 +1014,8 @@ export type CronRemoveParams = Static<typeof CronRemoveParamsSchema>;
|
|||||||
export type CronRunParams = Static<typeof CronRunParamsSchema>;
|
export type CronRunParams = Static<typeof CronRunParamsSchema>;
|
||||||
export type CronRunsParams = Static<typeof CronRunsParamsSchema>;
|
export type CronRunsParams = Static<typeof CronRunsParamsSchema>;
|
||||||
export type CronRunLogEntry = Static<typeof CronRunLogEntrySchema>;
|
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 ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
|
||||||
export type ChatEvent = Static<typeof ChatEventSchema>;
|
export type ChatEvent = Static<typeof ChatEventSchema>;
|
||||||
export type UpdateRunParams = Static<typeof UpdateRunParamsSchema>;
|
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 { connectHandlers } from "./server-methods/connect.js";
|
||||||
import { cronHandlers } from "./server-methods/cron.js";
|
import { cronHandlers } from "./server-methods/cron.js";
|
||||||
import { healthHandlers } from "./server-methods/health.js";
|
import { healthHandlers } from "./server-methods/health.js";
|
||||||
|
import { logsHandlers } from "./server-methods/logs.js";
|
||||||
import { modelsHandlers } from "./server-methods/models.js";
|
import { modelsHandlers } from "./server-methods/models.js";
|
||||||
import { nodeHandlers } from "./server-methods/nodes.js";
|
import { nodeHandlers } from "./server-methods/nodes.js";
|
||||||
import { providersHandlers } from "./server-methods/providers.js";
|
import { providersHandlers } from "./server-methods/providers.js";
|
||||||
@@ -25,6 +26,7 @@ import { wizardHandlers } from "./server-methods/wizard.js";
|
|||||||
|
|
||||||
const handlers: GatewayRequestHandlers = {
|
const handlers: GatewayRequestHandlers = {
|
||||||
...connectHandlers,
|
...connectHandlers,
|
||||||
|
...logsHandlers,
|
||||||
...voicewakeHandlers,
|
...voicewakeHandlers,
|
||||||
...healthHandlers,
|
...healthHandlers,
|
||||||
...providersHandlers,
|
...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 = [
|
const METHODS = [
|
||||||
"health",
|
"health",
|
||||||
|
"logs.tail",
|
||||||
"providers.status",
|
"providers.status",
|
||||||
"status",
|
"status",
|
||||||
"usage.status",
|
"usage.status",
|
||||||
|
|||||||
@@ -413,6 +413,10 @@
|
|||||||
background: rgba(0, 0, 0, 0.2);
|
background: rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chip input {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.chip-ok {
|
.chip-ok {
|
||||||
color: var(--ok);
|
color: var(--ok);
|
||||||
border-color: rgba(27, 217, 138, 0.4);
|
border-color: rgba(27, 217, 138, 0.4);
|
||||||
@@ -450,6 +454,104 @@
|
|||||||
background: rgba(0, 0, 0, 0.2);
|
background: rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.log-stream {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
max-height: 520px;
|
||||||
|
overflow: auto;
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 90px 70px minmax(140px, 200px) minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-time {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-level {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-level.trace,
|
||||||
|
.log-level.debug {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-level.info {
|
||||||
|
color: var(--info);
|
||||||
|
border-color: rgba(76, 150, 242, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-level.warn {
|
||||||
|
color: var(--warn);
|
||||||
|
border-color: rgba(242, 201, 76, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-level.error,
|
||||||
|
.log-level.fatal {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: rgba(255, 92, 92, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-chip.trace,
|
||||||
|
.log-chip.debug {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-chip.info {
|
||||||
|
color: var(--info);
|
||||||
|
border-color: rgba(76, 150, 242, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-chip.warn {
|
||||||
|
color: var(--warn);
|
||||||
|
border-color: rgba(242, 201, 76, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-chip.error,
|
||||||
|
.log-chip.fatal {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: rgba(255, 92, 92, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-subsystem {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-message {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 620px) {
|
||||||
|
.log-row {
|
||||||
|
grid-template-columns: 70px 60px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-subsystem {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.chat {
|
.chat {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import type {
|
|||||||
CronRunLogEntry,
|
CronRunLogEntry,
|
||||||
CronStatus,
|
CronStatus,
|
||||||
HealthSnapshot,
|
HealthSnapshot,
|
||||||
|
LogEntry,
|
||||||
|
LogLevel,
|
||||||
PresenceEntry,
|
PresenceEntry,
|
||||||
ProvidersStatusSnapshot,
|
ProvidersStatusSnapshot,
|
||||||
SessionsListResult,
|
SessionsListResult,
|
||||||
@@ -37,6 +39,7 @@ import { renderConnections } from "./views/connections";
|
|||||||
import { renderCron } from "./views/cron";
|
import { renderCron } from "./views/cron";
|
||||||
import { renderDebug } from "./views/debug";
|
import { renderDebug } from "./views/debug";
|
||||||
import { renderInstances } from "./views/instances";
|
import { renderInstances } from "./views/instances";
|
||||||
|
import { renderLogs } from "./views/logs";
|
||||||
import { renderNodes } from "./views/nodes";
|
import { renderNodes } from "./views/nodes";
|
||||||
import { renderOverview } from "./views/overview";
|
import { renderOverview } from "./views/overview";
|
||||||
import { renderSessions } from "./views/sessions";
|
import { renderSessions } from "./views/sessions";
|
||||||
@@ -69,6 +72,7 @@ import {
|
|||||||
} from "./controllers/config";
|
} from "./controllers/config";
|
||||||
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
|
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
|
||||||
import { loadDebug, callDebugMethod } from "./controllers/debug";
|
import { loadDebug, callDebugMethod } from "./controllers/debug";
|
||||||
|
import { loadLogs } from "./controllers/logs";
|
||||||
|
|
||||||
export type EventLogEntry = {
|
export type EventLogEntry = {
|
||||||
ts: number;
|
ts: number;
|
||||||
@@ -172,6 +176,14 @@ export type AppViewState = {
|
|||||||
debugCallParams: string;
|
debugCallParams: string;
|
||||||
debugCallResult: string | null;
|
debugCallResult: string | null;
|
||||||
debugCallError: string | null;
|
debugCallError: string | null;
|
||||||
|
logsLoading: boolean;
|
||||||
|
logsError: string | null;
|
||||||
|
logsFile: string | null;
|
||||||
|
logsEntries: LogEntry[];
|
||||||
|
logsFilterText: string;
|
||||||
|
logsLevelFilters: Record<LogLevel, boolean>;
|
||||||
|
logsAutoFollow: boolean;
|
||||||
|
logsTruncated: boolean;
|
||||||
client: GatewayBrowserClient | null;
|
client: GatewayBrowserClient | null;
|
||||||
connect: () => void;
|
connect: () => void;
|
||||||
setTab: (tab: Tab) => void;
|
setTab: (tab: Tab) => void;
|
||||||
@@ -185,6 +197,8 @@ export type AppViewState = {
|
|||||||
handleTelegramSave: () => Promise<void>;
|
handleTelegramSave: () => Promise<void>;
|
||||||
handleSendChat: () => Promise<void>;
|
handleSendChat: () => Promise<void>;
|
||||||
resetToolStream: () => void;
|
resetToolStream: () => void;
|
||||||
|
handleLogsScroll: (event: Event) => void;
|
||||||
|
exportLogs: (lines: string[], label: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function renderApp(state: AppViewState) {
|
export function renderApp(state: AppViewState) {
|
||||||
@@ -478,6 +492,27 @@ export function renderApp(state: AppViewState) {
|
|||||||
onCall: () => callDebugMethod(state),
|
onCall: () => callDebugMethod(state),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
|
${state.tab === "logs"
|
||||||
|
? renderLogs({
|
||||||
|
loading: state.logsLoading,
|
||||||
|
error: state.logsError,
|
||||||
|
file: state.logsFile,
|
||||||
|
entries: state.logsEntries,
|
||||||
|
filterText: state.logsFilterText,
|
||||||
|
levelFilters: state.logsLevelFilters,
|
||||||
|
autoFollow: state.logsAutoFollow,
|
||||||
|
truncated: state.logsTruncated,
|
||||||
|
onFilterTextChange: (next) => (state.logsFilterText = next),
|
||||||
|
onLevelToggle: (level, enabled) => {
|
||||||
|
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
|
||||||
|
},
|
||||||
|
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
|
||||||
|
onRefresh: () => loadLogs(state, { reset: true }),
|
||||||
|
onExport: (lines, label) => state.exportLogs(lines, label),
|
||||||
|
onScroll: (event) => state.handleLogsScroll(event),
|
||||||
|
})
|
||||||
|
: nothing}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import type {
|
|||||||
CronRunLogEntry,
|
CronRunLogEntry,
|
||||||
CronStatus,
|
CronStatus,
|
||||||
HealthSnapshot,
|
HealthSnapshot,
|
||||||
|
LogEntry,
|
||||||
|
LogLevel,
|
||||||
PresenceEntry,
|
PresenceEntry,
|
||||||
ProvidersStatusSnapshot,
|
ProvidersStatusSnapshot,
|
||||||
SessionsListResult,
|
SessionsListResult,
|
||||||
@@ -77,6 +79,7 @@ import {
|
|||||||
loadSkills,
|
loadSkills,
|
||||||
} from "./controllers/skills";
|
} from "./controllers/skills";
|
||||||
import { loadDebug } from "./controllers/debug";
|
import { loadDebug } from "./controllers/debug";
|
||||||
|
import { loadLogs } from "./controllers/logs";
|
||||||
|
|
||||||
type EventLogEntry = {
|
type EventLogEntry = {
|
||||||
ts: number;
|
ts: number;
|
||||||
@@ -85,6 +88,14 @@ type EventLogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const TOOL_STREAM_LIMIT = 50;
|
const TOOL_STREAM_LIMIT = 50;
|
||||||
|
const DEFAULT_LOG_LEVEL_FILTERS: Record<LogLevel, boolean> = {
|
||||||
|
trace: true,
|
||||||
|
debug: true,
|
||||||
|
info: true,
|
||||||
|
warn: true,
|
||||||
|
error: true,
|
||||||
|
fatal: true,
|
||||||
|
};
|
||||||
|
|
||||||
type AgentEventPayload = {
|
type AgentEventPayload = {
|
||||||
runId: string;
|
runId: string;
|
||||||
@@ -343,11 +354,29 @@ export class ClawdbotApp extends LitElement {
|
|||||||
@state() debugCallResult: string | null = null;
|
@state() debugCallResult: string | null = null;
|
||||||
@state() debugCallError: string | null = null;
|
@state() debugCallError: string | null = null;
|
||||||
|
|
||||||
|
@state() logsLoading = false;
|
||||||
|
@state() logsError: string | null = null;
|
||||||
|
@state() logsFile: string | null = null;
|
||||||
|
@state() logsEntries: LogEntry[] = [];
|
||||||
|
@state() logsFilterText = "";
|
||||||
|
@state() logsLevelFilters: Record<LogLevel, boolean> = {
|
||||||
|
...DEFAULT_LOG_LEVEL_FILTERS,
|
||||||
|
};
|
||||||
|
@state() logsAutoFollow = true;
|
||||||
|
@state() logsTruncated = false;
|
||||||
|
@state() logsCursor: number | null = null;
|
||||||
|
@state() logsLastFetchAt: number | null = null;
|
||||||
|
@state() logsLimit = 500;
|
||||||
|
@state() logsMaxBytes = 250_000;
|
||||||
|
@state() logsAtBottom = true;
|
||||||
|
|
||||||
client: GatewayBrowserClient | null = null;
|
client: GatewayBrowserClient | null = null;
|
||||||
private chatScrollFrame: number | null = null;
|
private chatScrollFrame: number | null = null;
|
||||||
private chatScrollTimeout: number | null = null;
|
private chatScrollTimeout: number | null = null;
|
||||||
private chatHasAutoScrolled = false;
|
private chatHasAutoScrolled = false;
|
||||||
private nodesPollInterval: number | null = null;
|
private nodesPollInterval: number | null = null;
|
||||||
|
private logsPollInterval: number | null = null;
|
||||||
|
private logsScrollFrame: number | null = null;
|
||||||
private toolStreamById = new Map<string, ToolStreamEntry>();
|
private toolStreamById = new Map<string, ToolStreamEntry>();
|
||||||
private toolStreamOrder: string[] = [];
|
private toolStreamOrder: string[] = [];
|
||||||
basePath = "";
|
basePath = "";
|
||||||
@@ -370,6 +399,7 @@ export class ClawdbotApp extends LitElement {
|
|||||||
this.applySettingsFromUrl();
|
this.applySettingsFromUrl();
|
||||||
this.connect();
|
this.connect();
|
||||||
this.startNodesPolling();
|
this.startNodesPolling();
|
||||||
|
if (this.tab === "logs") this.startLogsPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected firstUpdated() {
|
protected firstUpdated() {
|
||||||
@@ -379,6 +409,7 @@ export class ClawdbotApp extends LitElement {
|
|||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
window.removeEventListener("popstate", this.popStateHandler);
|
window.removeEventListener("popstate", this.popStateHandler);
|
||||||
this.stopNodesPolling();
|
this.stopNodesPolling();
|
||||||
|
this.stopLogsPolling();
|
||||||
this.detachThemeListener();
|
this.detachThemeListener();
|
||||||
this.topbarObserver?.disconnect();
|
this.topbarObserver?.disconnect();
|
||||||
this.topbarObserver = null;
|
this.topbarObserver = null;
|
||||||
@@ -401,6 +432,14 @@ export class ClawdbotApp extends LitElement {
|
|||||||
this.chatLoading === false;
|
this.chatLoading === false;
|
||||||
this.scheduleChatScroll(forcedByTab || forcedByLoad || !this.chatHasAutoScrolled);
|
this.scheduleChatScroll(forcedByTab || forcedByLoad || !this.chatHasAutoScrolled);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
this.tab === "logs" &&
|
||||||
|
(changed.has("logsEntries") || changed.has("logsAutoFollow") || changed.has("tab"))
|
||||||
|
) {
|
||||||
|
if (this.logsAutoFollow && this.logsAtBottom) {
|
||||||
|
this.scheduleLogsScroll(changed.has("tab") || changed.has("logsAutoFollow"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
@@ -505,6 +544,56 @@ export class ClawdbotApp extends LitElement {
|
|||||||
this.nodesPollInterval = null;
|
this.nodesPollInterval = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private startLogsPolling() {
|
||||||
|
if (this.logsPollInterval != null) return;
|
||||||
|
this.logsPollInterval = window.setInterval(() => {
|
||||||
|
if (this.tab !== "logs") return;
|
||||||
|
void loadLogs(this, { quiet: true });
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopLogsPolling() {
|
||||||
|
if (this.logsPollInterval == null) return;
|
||||||
|
clearInterval(this.logsPollInterval);
|
||||||
|
this.logsPollInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleLogsScroll(force = false) {
|
||||||
|
if (this.logsScrollFrame) cancelAnimationFrame(this.logsScrollFrame);
|
||||||
|
void this.updateComplete.then(() => {
|
||||||
|
this.logsScrollFrame = requestAnimationFrame(() => {
|
||||||
|
this.logsScrollFrame = null;
|
||||||
|
const container = this.querySelector(".log-stream") as HTMLElement | null;
|
||||||
|
if (!container) return;
|
||||||
|
const distanceFromBottom =
|
||||||
|
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
|
const shouldStick = force || distanceFromBottom < 80;
|
||||||
|
if (!shouldStick) return;
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLogsScroll(event: Event) {
|
||||||
|
const container = event.currentTarget as HTMLElement | null;
|
||||||
|
if (!container) return;
|
||||||
|
const distanceFromBottom =
|
||||||
|
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
|
this.logsAtBottom = distanceFromBottom < 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
exportLogs(lines: string[], label: string) {
|
||||||
|
if (lines.length === 0) return;
|
||||||
|
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `clawdbot-logs-${label}-${stamp}.log`;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
resetToolStream() {
|
resetToolStream() {
|
||||||
this.toolStreamById.clear();
|
this.toolStreamById.clear();
|
||||||
this.toolStreamOrder = [];
|
this.toolStreamOrder = [];
|
||||||
@@ -713,6 +802,8 @@ export class ClawdbotApp extends LitElement {
|
|||||||
setTab(next: Tab) {
|
setTab(next: Tab) {
|
||||||
if (this.tab !== next) this.tab = next;
|
if (this.tab !== next) this.tab = next;
|
||||||
if (next === "chat") this.chatHasAutoScrolled = false;
|
if (next === "chat") this.chatHasAutoScrolled = false;
|
||||||
|
if (next === "logs") this.startLogsPolling();
|
||||||
|
else this.stopLogsPolling();
|
||||||
void this.refreshActiveTab();
|
void this.refreshActiveTab();
|
||||||
this.syncUrlWithTab(next, false);
|
this.syncUrlWithTab(next, false);
|
||||||
}
|
}
|
||||||
@@ -751,6 +842,9 @@ export class ClawdbotApp extends LitElement {
|
|||||||
await loadDebug(this);
|
await loadDebug(this);
|
||||||
this.eventLog = this.eventLogBuffer;
|
this.eventLog = this.eventLogBuffer;
|
||||||
}
|
}
|
||||||
|
if (this.tab === "logs") {
|
||||||
|
await loadLogs(this, { reset: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private inferBasePath() {
|
private inferBasePath() {
|
||||||
@@ -824,6 +918,8 @@ export class ClawdbotApp extends LitElement {
|
|||||||
private setTabFromRoute(next: Tab) {
|
private setTabFromRoute(next: Tab) {
|
||||||
if (this.tab !== next) this.tab = next;
|
if (this.tab !== next) this.tab = next;
|
||||||
if (next === "chat") this.chatHasAutoScrolled = false;
|
if (next === "chat") this.chatHasAutoScrolled = false;
|
||||||
|
if (next === "logs") this.startLogsPolling();
|
||||||
|
else this.stopLogsPolling();
|
||||||
if (this.connected) void this.refreshActiveTab();
|
if (this.connected) void this.refreshActiveTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
136
ui/src/ui/controllers/logs.ts
Normal file
136
ui/src/ui/controllers/logs.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import type { GatewayBrowserClient } from "../gateway";
|
||||||
|
import type { LogEntry, LogLevel } from "../types";
|
||||||
|
|
||||||
|
export type LogsState = {
|
||||||
|
client: GatewayBrowserClient | null;
|
||||||
|
connected: boolean;
|
||||||
|
logsLoading: boolean;
|
||||||
|
logsError: string | null;
|
||||||
|
logsCursor: number | null;
|
||||||
|
logsFile: string | null;
|
||||||
|
logsEntries: LogEntry[];
|
||||||
|
logsTruncated: boolean;
|
||||||
|
logsLastFetchAt: number | null;
|
||||||
|
logsLimit: number;
|
||||||
|
logsMaxBytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOG_BUFFER_LIMIT = 2000;
|
||||||
|
const LEVELS = new Set<LogLevel>([
|
||||||
|
"trace",
|
||||||
|
"debug",
|
||||||
|
"info",
|
||||||
|
"warn",
|
||||||
|
"error",
|
||||||
|
"fatal",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function parseMaybeJsonString(value: unknown) {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as unknown;
|
||||||
|
if (!parsed || typeof parsed !== "object") return null;
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLevel(value: unknown): LogLevel | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const lowered = value.toLowerCase() as LogLevel;
|
||||||
|
return LEVELS.has(lowered) ? lowered : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLogLine(line: string): LogEntry {
|
||||||
|
if (!line.trim()) return { raw: line, message: line };
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(line) as Record<string, unknown>;
|
||||||
|
const meta =
|
||||||
|
obj && typeof obj._meta === "object" && obj._meta !== null
|
||||||
|
? (obj._meta as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const time =
|
||||||
|
typeof obj.time === "string"
|
||||||
|
? obj.time
|
||||||
|
: typeof meta?.date === "string"
|
||||||
|
? meta?.date
|
||||||
|
: null;
|
||||||
|
const level = normalizeLevel(meta?.logLevelName ?? meta?.level);
|
||||||
|
|
||||||
|
const contextCandidate =
|
||||||
|
typeof obj["0"] === "string"
|
||||||
|
? (obj["0"] as string)
|
||||||
|
: typeof meta?.name === "string"
|
||||||
|
? (meta?.name as string)
|
||||||
|
: null;
|
||||||
|
const contextObj = parseMaybeJsonString(contextCandidate);
|
||||||
|
let subsystem: string | null = null;
|
||||||
|
if (contextObj) {
|
||||||
|
if (typeof contextObj.subsystem === "string") subsystem = contextObj.subsystem;
|
||||||
|
else if (typeof contextObj.module === "string") subsystem = contextObj.module;
|
||||||
|
}
|
||||||
|
if (!subsystem && contextCandidate && contextCandidate.length < 120) {
|
||||||
|
subsystem = contextCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message: string | null = null;
|
||||||
|
if (typeof obj["1"] === "string") message = obj["1"] as string;
|
||||||
|
else if (!contextObj && typeof obj["0"] === "string") message = obj["0"] as string;
|
||||||
|
else if (typeof obj.message === "string") message = obj.message as string;
|
||||||
|
|
||||||
|
return {
|
||||||
|
raw: line,
|
||||||
|
time,
|
||||||
|
level,
|
||||||
|
subsystem,
|
||||||
|
message: message ?? line,
|
||||||
|
meta: meta ?? undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { raw: line, message: line };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadLogs(
|
||||||
|
state: LogsState,
|
||||||
|
opts?: { reset?: boolean; quiet?: boolean },
|
||||||
|
) {
|
||||||
|
if (!state.client || !state.connected) return;
|
||||||
|
if (state.logsLoading && !opts?.quiet) return;
|
||||||
|
if (!opts?.quiet) state.logsLoading = true;
|
||||||
|
state.logsError = null;
|
||||||
|
try {
|
||||||
|
const res = await state.client.request("logs.tail", {
|
||||||
|
cursor: opts?.reset ? undefined : state.logsCursor ?? undefined,
|
||||||
|
limit: state.logsLimit,
|
||||||
|
maxBytes: state.logsMaxBytes,
|
||||||
|
});
|
||||||
|
const payload = res as {
|
||||||
|
file?: string;
|
||||||
|
cursor?: number;
|
||||||
|
size?: number;
|
||||||
|
lines?: unknown;
|
||||||
|
truncated?: boolean;
|
||||||
|
reset?: boolean;
|
||||||
|
};
|
||||||
|
const lines = Array.isArray(payload.lines)
|
||||||
|
? (payload.lines.filter((line) => typeof line === "string") as string[])
|
||||||
|
: [];
|
||||||
|
const entries = lines.map(parseLogLine);
|
||||||
|
const shouldReset = Boolean(opts?.reset || payload.reset || state.logsCursor == null);
|
||||||
|
state.logsEntries = shouldReset
|
||||||
|
? entries
|
||||||
|
: [...state.logsEntries, ...entries].slice(-LOG_BUFFER_LIMIT);
|
||||||
|
if (typeof payload.cursor === "number") state.logsCursor = payload.cursor;
|
||||||
|
if (typeof payload.file === "string") state.logsFile = payload.file;
|
||||||
|
state.logsTruncated = Boolean(payload.truncated);
|
||||||
|
state.logsLastFetchAt = Date.now();
|
||||||
|
} catch (err) {
|
||||||
|
state.logsError = String(err);
|
||||||
|
} finally {
|
||||||
|
if (!opts?.quiet) state.logsLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ export const TAB_GROUPS = [
|
|||||||
tabs: ["overview", "connections", "instances", "sessions", "cron"],
|
tabs: ["overview", "connections", "instances", "sessions", "cron"],
|
||||||
},
|
},
|
||||||
{ label: "Agent", tabs: ["skills", "nodes"] },
|
{ label: "Agent", tabs: ["skills", "nodes"] },
|
||||||
{ label: "Settings", tabs: ["config", "debug"] },
|
{ label: "Settings", tabs: ["config", "debug", "logs"] },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Tab =
|
export type Tab =
|
||||||
@@ -18,7 +18,8 @@ export type Tab =
|
|||||||
| "nodes"
|
| "nodes"
|
||||||
| "chat"
|
| "chat"
|
||||||
| "config"
|
| "config"
|
||||||
| "debug";
|
| "debug"
|
||||||
|
| "logs";
|
||||||
|
|
||||||
const TAB_PATHS: Record<Tab, string> = {
|
const TAB_PATHS: Record<Tab, string> = {
|
||||||
overview: "/overview",
|
overview: "/overview",
|
||||||
@@ -31,6 +32,7 @@ const TAB_PATHS: Record<Tab, string> = {
|
|||||||
chat: "/chat",
|
chat: "/chat",
|
||||||
config: "/config",
|
config: "/config",
|
||||||
debug: "/debug",
|
debug: "/debug",
|
||||||
|
logs: "/logs",
|
||||||
};
|
};
|
||||||
|
|
||||||
const PATH_TO_TAB = new Map(
|
const PATH_TO_TAB = new Map(
|
||||||
@@ -118,6 +120,8 @@ export function titleForTab(tab: Tab) {
|
|||||||
return "Config";
|
return "Config";
|
||||||
case "debug":
|
case "debug":
|
||||||
return "Debug";
|
return "Debug";
|
||||||
|
case "logs":
|
||||||
|
return "Logs";
|
||||||
default:
|
default:
|
||||||
return "Control";
|
return "Control";
|
||||||
}
|
}
|
||||||
@@ -145,6 +149,8 @@ export function subtitleForTab(tab: Tab) {
|
|||||||
return "Edit ~/.clawdbot/clawdbot.json safely.";
|
return "Edit ~/.clawdbot/clawdbot.json safely.";
|
||||||
case "debug":
|
case "debug":
|
||||||
return "Gateway snapshots, events, and manual RPC calls.";
|
return "Gateway snapshots, events, and manual RPC calls.";
|
||||||
|
case "logs":
|
||||||
|
return "Live tail of the gateway file logs.";
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -380,3 +380,20 @@ export type SkillStatusReport = {
|
|||||||
export type StatusSummary = Record<string, unknown>;
|
export type StatusSummary = Record<string, unknown>;
|
||||||
|
|
||||||
export type HealthSnapshot = Record<string, unknown>;
|
export type HealthSnapshot = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type LogLevel =
|
||||||
|
| "trace"
|
||||||
|
| "debug"
|
||||||
|
| "info"
|
||||||
|
| "warn"
|
||||||
|
| "error"
|
||||||
|
| "fatal";
|
||||||
|
|
||||||
|
export type LogEntry = {
|
||||||
|
raw: string;
|
||||||
|
time?: string | null;
|
||||||
|
level?: LogLevel | null;
|
||||||
|
subsystem?: string | null;
|
||||||
|
message?: string | null;
|
||||||
|
meta?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|||||||
135
ui/src/ui/views/logs.ts
Normal file
135
ui/src/ui/views/logs.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { html, nothing } from "lit";
|
||||||
|
|
||||||
|
import type { LogEntry, LogLevel } from "../types";
|
||||||
|
|
||||||
|
const LEVELS: LogLevel[] = ["trace", "debug", "info", "warn", "error", "fatal"];
|
||||||
|
|
||||||
|
export type LogsProps = {
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
file: string | null;
|
||||||
|
entries: LogEntry[];
|
||||||
|
filterText: string;
|
||||||
|
levelFilters: Record<LogLevel, boolean>;
|
||||||
|
autoFollow: boolean;
|
||||||
|
truncated: boolean;
|
||||||
|
onFilterTextChange: (next: string) => void;
|
||||||
|
onLevelToggle: (level: LogLevel, enabled: boolean) => void;
|
||||||
|
onToggleAutoFollow: (next: boolean) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onExport: (lines: string[], label: string) => void;
|
||||||
|
onScroll: (event: Event) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTime(value?: string | null) {
|
||||||
|
if (!value) return "";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFilter(entry: LogEntry, needle: string) {
|
||||||
|
if (!needle) return true;
|
||||||
|
const haystack = [entry.message, entry.subsystem, entry.raw]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
return haystack.includes(needle);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderLogs(props: LogsProps) {
|
||||||
|
const needle = props.filterText.trim().toLowerCase();
|
||||||
|
const levelFiltered = LEVELS.some((level) => !props.levelFilters[level]);
|
||||||
|
const filtered = props.entries.filter((entry) => {
|
||||||
|
if (entry.level && !props.levelFilters[entry.level]) return false;
|
||||||
|
return matchesFilter(entry, needle);
|
||||||
|
});
|
||||||
|
const exportLabel = needle || levelFiltered ? "filtered" : "visible";
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<section class="card">
|
||||||
|
<div class="row" style="justify-content: space-between;">
|
||||||
|
<div>
|
||||||
|
<div class="card-title">Logs</div>
|
||||||
|
<div class="card-sub">Gateway file logs (JSONL).</div>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="gap: 8px;">
|
||||||
|
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||||
|
${props.loading ? "Loading…" : "Refresh"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
?disabled=${filtered.length === 0}
|
||||||
|
@click=${() => props.onExport(filtered.map((entry) => entry.raw), exportLabel)}
|
||||||
|
>
|
||||||
|
Export ${exportLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filters" style="margin-top: 14px;">
|
||||||
|
<label class="field" style="min-width: 220px;">
|
||||||
|
<span>Filter</span>
|
||||||
|
<input
|
||||||
|
.value=${props.filterText}
|
||||||
|
@input=${(e: Event) =>
|
||||||
|
props.onFilterTextChange((e.target as HTMLInputElement).value)}
|
||||||
|
placeholder="Search logs"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field checkbox">
|
||||||
|
<span>Auto-follow</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
.checked=${props.autoFollow}
|
||||||
|
@change=${(e: Event) =>
|
||||||
|
props.onToggleAutoFollow((e.target as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chip-row" style="margin-top: 12px;">
|
||||||
|
${LEVELS.map(
|
||||||
|
(level) => html`
|
||||||
|
<label class="chip log-chip ${level}">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
.checked=${props.levelFilters[level]}
|
||||||
|
@change=${(e: Event) =>
|
||||||
|
props.onLevelToggle(level, (e.target as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
<span>${level}</span>
|
||||||
|
</label>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${props.file
|
||||||
|
? html`<div class="muted" style="margin-top: 10px;">File: ${props.file}</div>`
|
||||||
|
: nothing}
|
||||||
|
${props.truncated
|
||||||
|
? html`<div class="callout" style="margin-top: 10px;">
|
||||||
|
Log output truncated; showing latest chunk.
|
||||||
|
</div>`
|
||||||
|
: nothing}
|
||||||
|
${props.error
|
||||||
|
? html`<div class="callout danger" style="margin-top: 10px;">${props.error}</div>`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
|
<div class="log-stream" style="margin-top: 12px;" @scroll=${props.onScroll}>
|
||||||
|
${filtered.length === 0
|
||||||
|
? html`<div class="muted" style="padding: 12px;">No log entries.</div>`
|
||||||
|
: filtered.map(
|
||||||
|
(entry) => html`
|
||||||
|
<div class="log-row">
|
||||||
|
<div class="log-time mono">${formatTime(entry.time)}</div>
|
||||||
|
<div class="log-level ${entry.level ?? ""}">${entry.level ?? ""}</div>
|
||||||
|
<div class="log-subsystem mono">${entry.subsystem ?? ""}</div>
|
||||||
|
<div class="log-message mono">${entry.message ?? entry.raw}</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user