diff --git a/CHANGELOG.md b/CHANGELOG.md index 658392067..712b2a226 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/gateway/logging.md b/docs/gateway/logging.md index a67d18500..a761afb82 100644 --- a/docs/gateway/logging.md +++ b/docs/gateway/logging.md @@ -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`. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index a0c7b8a27..348b2f9d2 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -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) diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index c80b9f515..5f76ee5ed 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -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(CronRunParamsSchema); export const validateCronRunsParams = ajv.compile(CronRunsParamsSchema); +export const validateLogsTailParams = + ajv.compile(LogsTailParamsSchema); export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema); export const validateChatSendParams = ajv.compile(ChatSendParamsSchema); export const validateChatAbortParams = ajv.compile( @@ -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, }; diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 884adcfc2..eb384e060 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -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 = { CronRunParams: CronRunParamsSchema, CronRunsParams: CronRunsParamsSchema, CronRunLogEntry: CronRunLogEntrySchema, + LogsTailParams: LogsTailParamsSchema, + LogsTailResult: LogsTailResultSchema, ChatHistoryParams: ChatHistoryParamsSchema, ChatSendParams: ChatSendParamsSchema, ChatAbortParams: ChatAbortParamsSchema, @@ -991,6 +1014,8 @@ export type CronRemoveParams = Static; export type CronRunParams = Static; export type CronRunsParams = Static; export type CronRunLogEntry = Static; +export type LogsTailParams = Static; +export type LogsTailResult = Static; export type ChatAbortParams = Static; export type ChatEvent = Static; export type UpdateRunParams = Static; diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index a174aa966..39a19a748 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -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, diff --git a/src/gateway/server-methods/logs.ts b/src/gateway/server-methods/logs.ts new file mode 100644 index 000000000..4d227ed5e --- /dev/null +++ b/src/gateway/server-methods/logs.ts @@ -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)}`, + ), + ); + } + }, +}; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 397834937..6ab603f45 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -212,6 +212,7 @@ type Client = { const METHODS = [ "health", + "logs.tail", "providers.status", "status", "usage.status", diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 1e94013bb..6baea286e 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -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; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 9cc9e5b17..4b977098f 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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; + logsAutoFollow: boolean; + logsTruncated: boolean; client: GatewayBrowserClient | null; connect: () => void; setTab: (tab: Tab) => void; @@ -185,6 +197,8 @@ export type AppViewState = { handleTelegramSave: () => Promise; handleSendChat: () => Promise; 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} `; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 702cf69ee..cb6eac52b 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -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 = { + 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 = { + ...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(); 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(); } diff --git a/ui/src/ui/controllers/logs.ts b/ui/src/ui/controllers/logs.ts new file mode 100644 index 000000000..48f78dd23 --- /dev/null +++ b/ui/src/ui/controllers/logs.ts @@ -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([ + "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; + } 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; + const meta = + obj && typeof obj._meta === "object" && obj._meta !== null + ? (obj._meta as Record) + : 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; + } +} diff --git a/ui/src/ui/navigation.ts b/ui/src/ui/navigation.ts index 9999d527b..0bb7718ec 100644 --- a/ui/src/ui/navigation.ts +++ b/ui/src/ui/navigation.ts @@ -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 = { overview: "/overview", @@ -31,6 +32,7 @@ const TAB_PATHS: Record = { 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 ""; } diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 8a0cd5854..96d1c30a6 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -380,3 +380,20 @@ export type SkillStatusReport = { export type StatusSummary = Record; export type HealthSnapshot = Record; + +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 | null; +}; diff --git a/ui/src/ui/views/logs.ts b/ui/src/ui/views/logs.ts new file mode 100644 index 000000000..6da434dbe --- /dev/null +++ b/ui/src/ui/views/logs.ts @@ -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; + 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` +
+
+
+
Logs
+
Gateway file logs (JSONL).
+
+
+ + +
+
+ +
+ + +
+ +
+ ${LEVELS.map( + (level) => html` + + `, + )} +
+ + ${props.file + ? html`
File: ${props.file}
` + : nothing} + ${props.truncated + ? html`
+ Log output truncated; showing latest chunk. +
` + : nothing} + ${props.error + ? html`
${props.error}
` + : nothing} + +
+ ${filtered.length === 0 + ? html`
No log entries.
` + : filtered.map( + (entry) => html` +
+
${formatTime(entry.time)}
+
${entry.level ?? ""}
+
${entry.subsystem ?? ""}
+
${entry.message ?? entry.raw}
+
+ `, + )} +
+
+ `; +}