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: 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: 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.
|
||||
- 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.
|
||||
|
||||
@@ -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 Control UI Logs tab tails this file via the gateway (`logs.tail`).
|
||||
|
||||
**Verbose vs. log levels**
|
||||
|
||||
- **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 schema + form rendering (`config.schema`); Raw JSON editor remains available
|
||||
- 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
|
||||
|
||||
## Tailnet access (recommended)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -413,6 +413,10 @@
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.chip input {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.chip-ok {
|
||||
color: var(--ok);
|
||||
border-color: rgba(27, 217, 138, 0.4);
|
||||
@@ -450,6 +454,104 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -17,6 +17,8 @@ import type {
|
||||
CronRunLogEntry,
|
||||
CronStatus,
|
||||
HealthSnapshot,
|
||||
LogEntry,
|
||||
LogLevel,
|
||||
PresenceEntry,
|
||||
ProvidersStatusSnapshot,
|
||||
SessionsListResult,
|
||||
@@ -37,6 +39,7 @@ import { renderConnections } from "./views/connections";
|
||||
import { renderCron } from "./views/cron";
|
||||
import { renderDebug } from "./views/debug";
|
||||
import { renderInstances } from "./views/instances";
|
||||
import { renderLogs } from "./views/logs";
|
||||
import { renderNodes } from "./views/nodes";
|
||||
import { renderOverview } from "./views/overview";
|
||||
import { renderSessions } from "./views/sessions";
|
||||
@@ -69,6 +72,7 @@ import {
|
||||
} from "./controllers/config";
|
||||
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
|
||||
import { loadDebug, callDebugMethod } from "./controllers/debug";
|
||||
import { loadLogs } from "./controllers/logs";
|
||||
|
||||
export type EventLogEntry = {
|
||||
ts: number;
|
||||
@@ -172,6 +176,14 @@ export type AppViewState = {
|
||||
debugCallParams: string;
|
||||
debugCallResult: 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;
|
||||
connect: () => void;
|
||||
setTab: (tab: Tab) => void;
|
||||
@@ -185,6 +197,8 @@ export type AppViewState = {
|
||||
handleTelegramSave: () => Promise<void>;
|
||||
handleSendChat: () => Promise<void>;
|
||||
resetToolStream: () => void;
|
||||
handleLogsScroll: (event: Event) => void;
|
||||
exportLogs: (lines: string[], label: string) => void;
|
||||
};
|
||||
|
||||
export function renderApp(state: AppViewState) {
|
||||
@@ -478,6 +492,27 @@ export function renderApp(state: AppViewState) {
|
||||
onCall: () => callDebugMethod(state),
|
||||
})
|
||||
: 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>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -28,6 +28,8 @@ import type {
|
||||
CronRunLogEntry,
|
||||
CronStatus,
|
||||
HealthSnapshot,
|
||||
LogEntry,
|
||||
LogLevel,
|
||||
PresenceEntry,
|
||||
ProvidersStatusSnapshot,
|
||||
SessionsListResult,
|
||||
@@ -77,6 +79,7 @@ import {
|
||||
loadSkills,
|
||||
} from "./controllers/skills";
|
||||
import { loadDebug } from "./controllers/debug";
|
||||
import { loadLogs } from "./controllers/logs";
|
||||
|
||||
type EventLogEntry = {
|
||||
ts: number;
|
||||
@@ -85,6 +88,14 @@ type EventLogEntry = {
|
||||
};
|
||||
|
||||
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 = {
|
||||
runId: string;
|
||||
@@ -343,11 +354,29 @@ export class ClawdbotApp extends LitElement {
|
||||
@state() debugCallResult: 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;
|
||||
private chatScrollFrame: number | null = null;
|
||||
private chatScrollTimeout: number | null = null;
|
||||
private chatHasAutoScrolled = false;
|
||||
private nodesPollInterval: number | null = null;
|
||||
private logsPollInterval: number | null = null;
|
||||
private logsScrollFrame: number | null = null;
|
||||
private toolStreamById = new Map<string, ToolStreamEntry>();
|
||||
private toolStreamOrder: string[] = [];
|
||||
basePath = "";
|
||||
@@ -370,6 +399,7 @@ export class ClawdbotApp extends LitElement {
|
||||
this.applySettingsFromUrl();
|
||||
this.connect();
|
||||
this.startNodesPolling();
|
||||
if (this.tab === "logs") this.startLogsPolling();
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
@@ -379,6 +409,7 @@ export class ClawdbotApp extends LitElement {
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener("popstate", this.popStateHandler);
|
||||
this.stopNodesPolling();
|
||||
this.stopLogsPolling();
|
||||
this.detachThemeListener();
|
||||
this.topbarObserver?.disconnect();
|
||||
this.topbarObserver = null;
|
||||
@@ -401,6 +432,14 @@ export class ClawdbotApp extends LitElement {
|
||||
this.chatLoading === false;
|
||||
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() {
|
||||
@@ -505,6 +544,56 @@ export class ClawdbotApp extends LitElement {
|
||||
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() {
|
||||
this.toolStreamById.clear();
|
||||
this.toolStreamOrder = [];
|
||||
@@ -713,6 +802,8 @@ export class ClawdbotApp extends LitElement {
|
||||
setTab(next: Tab) {
|
||||
if (this.tab !== next) this.tab = next;
|
||||
if (next === "chat") this.chatHasAutoScrolled = false;
|
||||
if (next === "logs") this.startLogsPolling();
|
||||
else this.stopLogsPolling();
|
||||
void this.refreshActiveTab();
|
||||
this.syncUrlWithTab(next, false);
|
||||
}
|
||||
@@ -751,6 +842,9 @@ export class ClawdbotApp extends LitElement {
|
||||
await loadDebug(this);
|
||||
this.eventLog = this.eventLogBuffer;
|
||||
}
|
||||
if (this.tab === "logs") {
|
||||
await loadLogs(this, { reset: true });
|
||||
}
|
||||
}
|
||||
|
||||
private inferBasePath() {
|
||||
@@ -824,6 +918,8 @@ export class ClawdbotApp extends LitElement {
|
||||
private setTabFromRoute(next: Tab) {
|
||||
if (this.tab !== next) this.tab = next;
|
||||
if (next === "chat") this.chatHasAutoScrolled = false;
|
||||
if (next === "logs") this.startLogsPolling();
|
||||
else this.stopLogsPolling();
|
||||
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"],
|
||||
},
|
||||
{ label: "Agent", tabs: ["skills", "nodes"] },
|
||||
{ label: "Settings", tabs: ["config", "debug"] },
|
||||
{ label: "Settings", tabs: ["config", "debug", "logs"] },
|
||||
] as const;
|
||||
|
||||
export type Tab =
|
||||
@@ -18,7 +18,8 @@ export type Tab =
|
||||
| "nodes"
|
||||
| "chat"
|
||||
| "config"
|
||||
| "debug";
|
||||
| "debug"
|
||||
| "logs";
|
||||
|
||||
const TAB_PATHS: Record<Tab, string> = {
|
||||
overview: "/overview",
|
||||
@@ -31,6 +32,7 @@ const TAB_PATHS: Record<Tab, string> = {
|
||||
chat: "/chat",
|
||||
config: "/config",
|
||||
debug: "/debug",
|
||||
logs: "/logs",
|
||||
};
|
||||
|
||||
const PATH_TO_TAB = new Map(
|
||||
@@ -118,6 +120,8 @@ export function titleForTab(tab: Tab) {
|
||||
return "Config";
|
||||
case "debug":
|
||||
return "Debug";
|
||||
case "logs":
|
||||
return "Logs";
|
||||
default:
|
||||
return "Control";
|
||||
}
|
||||
@@ -145,6 +149,8 @@ export function subtitleForTab(tab: Tab) {
|
||||
return "Edit ~/.clawdbot/clawdbot.json safely.";
|
||||
case "debug":
|
||||
return "Gateway snapshots, events, and manual RPC calls.";
|
||||
case "logs":
|
||||
return "Live tail of the gateway file logs.";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -380,3 +380,20 @@ export type SkillStatusReport = {
|
||||
export type StatusSummary = 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