feat: add gateway logs tab
This commit is contained in:
@@ -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