feat: add gateway logs tab

This commit is contained in:
Peter Steinberger
2026-01-08 03:43:46 +00:00
parent ad6095c807
commit 64fc3c068d
15 changed files with 721 additions and 2 deletions

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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)

View File

@@ -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,
};

View File

@@ -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>;

View File

@@ -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,

View 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)}`,
),
);
}
},
};

View File

@@ -212,6 +212,7 @@ type Client = {
const METHODS = [
"health",
"logs.tail",
"providers.status",
"status",
"usage.status",

View File

@@ -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;

View File

@@ -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>
`;

View File

@@ -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();
}

View 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;
}
}

View File

@@ -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 "";
}

View File

@@ -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
View 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>
`;
}