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

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