feat: add backend debug log panel

This commit is contained in:
2026-04-02 23:27:11 +08:00
parent 58555c5043
commit 7e102b3b76
2 changed files with 387 additions and 0 deletions

View File

@@ -7,6 +7,10 @@ import {
onMount,
Show,
} from "solid-js";
import {
resolveDebugLogWsUrl,
type DebugLogEntry,
} from "~/service/debugLog";
import { accountStore } from "~/store/account";
import { settingsStore } from "~/store/settings";
@@ -21,6 +25,10 @@ type LogRow = {
content: string;
};
type DebugSocketState = "connecting" | "open" | "closed" | "error";
const MAX_DEBUG_ENTRIES = 1000;
const extractTimestamp = (message: string) => {
const match = message.match(/^\[(\d{2}:\d{2}:\d{2})\]\s*/);
return match?.[1] ?? null;
@@ -48,12 +56,159 @@ const normalizeContent = (message: string) => {
return value || "-";
};
const debugStatusLabelMap: Record<DebugSocketState, string> = {
connecting: "连接中",
open: "已连接",
closed: "已断开",
error: "连接异常",
};
const debugStatusClassMap: Record<DebugSocketState, string> = {
connecting:
"border-amber-400/20 bg-amber-400/10 text-amber-100",
open: "border-emerald-400/20 bg-emerald-400/10 text-emerald-100",
closed: "border-white/10 bg-white/5 text-slate-300",
error: "border-rose-400/20 bg-rose-400/10 text-rose-100",
};
const stringifyDebugFields = (fields?: Record<string, unknown>) => {
if (!fields || Object.keys(fields).length === 0) {
return "";
}
return JSON.stringify(fields, null, 2);
};
const summarizeDebugFields = (fields?: Record<string, unknown>) => {
const value = stringifyDebugFields(fields);
if (!value) {
return "-";
}
return value.replace(/\s+/g, " ").slice(0, 180);
};
const Logs = () => {
const [accountState, setAccountState] = createSignal(accountStore.getState());
const [settingsState, setSettingsState] = createSignal(
settingsStore.getState(),
);
const [debugEntries, setDebugEntries] = createSignal<DebugLogEntry[]>([]);
const [debugSocketState, setDebugSocketState] =
createSignal<DebugSocketState>("connecting");
const [debugError, setDebugError] = createSignal("");
const [selectedDebugEntryId, setSelectedDebugEntryId] = createSignal<number>(0);
let logContainerRef: HTMLDivElement | undefined;
let debugLogContainerRef: HTMLDivElement | undefined;
let debugSocket: WebSocket | null = null;
let reconnectTimer: number | undefined;
let manualClose = false;
const debugEntryKeySet = new Set<number>();
const resetDebugEntries = () => {
debugEntryKeySet.clear();
setDebugEntries([]);
setSelectedDebugEntryId(0);
};
const appendDebugEntry = (entry: DebugLogEntry) => {
if (debugEntryKeySet.has(entry.id)) {
return;
}
debugEntryKeySet.add(entry.id);
setDebugEntries((prev) => {
const next = [...prev, entry];
while (next.length > MAX_DEBUG_ENTRIES) {
const removed = next.shift();
if (removed) {
debugEntryKeySet.delete(removed.id);
}
}
return next;
});
if (!selectedDebugEntryId()) {
setSelectedDebugEntryId(entry.id);
}
};
const scheduleReconnect = () => {
if (manualClose || reconnectTimer) {
return;
}
reconnectTimer = window.setTimeout(() => {
reconnectTimer = undefined;
connectDebugSocket();
}, 1800);
};
const disconnectDebugSocket = () => {
manualClose = true;
if (reconnectTimer) {
window.clearTimeout(reconnectTimer);
reconnectTimer = undefined;
}
if (debugSocket) {
debugSocket.close();
debugSocket = null;
}
setDebugSocketState("closed");
};
const connectDebugSocket = () => {
if (debugSocket) {
return;
}
manualClose = false;
setDebugSocketState("connecting");
setDebugError("");
try {
const socket = new WebSocket(resolveDebugLogWsUrl());
debugSocket = socket;
socket.addEventListener("open", () => {
resetDebugEntries();
setDebugSocketState("open");
setDebugError("");
});
socket.addEventListener("message", (event) => {
try {
const payload = JSON.parse(event.data) as DebugLogEntry;
if (!payload || typeof payload.id !== "number") {
return;
}
appendDebugEntry(payload);
} catch {
// Ignore malformed WS payloads to keep the stream alive.
}
});
socket.addEventListener("error", () => {
setDebugSocketState("error");
setDebugError("调试日志流连接失败,请确认后端处于 debug 模式。");
});
socket.addEventListener("close", () => {
debugSocket = null;
if (manualClose) {
setDebugSocketState("closed");
return;
}
setDebugSocketState("closed");
scheduleReconnect();
});
} catch (error) {
debugSocket = null;
setDebugSocketState("error");
setDebugError(error instanceof Error ? error.message : "连接失败");
scheduleReconnect();
}
};
onMount(() => {
const unsubscribeAccount = accountStore.subscribe((state) => {
@@ -62,10 +217,12 @@ const Logs = () => {
const unsubscribeSettings = settingsStore.subscribe((state) => {
setSettingsState(state);
});
connectDebugSocket();
onCleanup(() => {
unsubscribeAccount();
unsubscribeSettings();
disconnectDebugSocket();
});
});
@@ -133,6 +290,20 @@ const Logs = () => {
return rows.length > 0 ? rows[rows.length - 1] : null;
});
const latestDebugEntry = createMemo(() => {
const rows = debugEntries();
return rows.length > 0 ? rows[rows.length - 1] : null;
});
const selectedDebugEntry = createMemo(() => {
const currentId = selectedDebugEntryId();
return debugEntries().find((item) => item.id === currentId) ?? null;
});
const debugSourceCount = createMemo(() => {
return new Set(debugEntries().map((item) => item.source)).size;
});
createEffect(() => {
logRows().length;
@@ -148,16 +319,41 @@ const Logs = () => {
});
});
createEffect(() => {
debugEntries().length;
if (!settingsState().autoScrollLogs) {
return;
}
requestAnimationFrame(() => {
const element = debugLogContainerRef;
if (element) {
element.scrollTo({ top: element.scrollHeight, behavior: "smooth" });
}
});
});
createEffect(() => {
if (logContainerRef) {
logContainerRef.style.fontSize = `${Math.max(settingsState().logFontSize, 12)}px`;
}
if (debugLogContainerRef) {
debugLogContainerRef.style.fontSize = `${Math.max(settingsState().logFontSize, 12)}px`;
}
});
const clearAllLogs = () => {
accountStore.getState().clearAllStudyLogs();
};
const clearDebugLogs = () => {
resetDebugEntries();
if (debugSocketState() !== "open") {
connectDebugSocket();
}
};
return (
<div class="flex h-full min-h-0 flex-col overflow-hidden">
<div class="rounded-[26px] border border-white/80 bg-[linear-gradient(135deg,rgba(255,248,235,0.98),rgba(255,255,255,0.94))] px-5 py-4 shadow-[0_18px_50px_-30px_rgba(180,83,9,0.28)]">
@@ -196,6 +392,14 @@ const Logs = () => {
: {latestLog()?.timestamp} / {latestLog()?.accountName}
</div>
</Show>
<div class="flex items-center gap-2 rounded-full border border-white/80 bg-white/85 px-3 py-1.5 shadow-sm">
<span class="text-[10px] tracking-[0.18em] text-zinc-400 uppercase">
DEBUG STREAM
</span>
<span class="text-sm font-semibold text-zinc-950">
{debugEntries().length}
</span>
</div>
<button
type="button"
class="rounded-full border border-rose-200 bg-[linear-gradient(135deg,rgba(255,241,242,0.95),rgba(255,255,255,0.9))] px-3.5 py-1.5 text-sm font-medium text-rose-600 transition hover:-translate-y-px hover:bg-rose-50"
@@ -274,6 +478,158 @@ const Logs = () => {
</Show>
</div>
</section>
<section class="mt-3 flex min-h-0 flex-1 flex-col overflow-hidden rounded-[26px] border border-zinc-200/80 bg-[linear-gradient(180deg,rgba(8,15,30,0.98),rgba(15,23,42,1))] shadow-[0_24px_60px_-32px_rgba(15,23,42,0.45)]">
<div class="shrink-0 border-b border-white/10 px-4 py-3">
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div>
<p class="text-[10px] font-medium tracking-[0.28em] text-cyan-300/80 uppercase">
Backend Debug Stream
</p>
<h2 class="mt-1 text-xl font-semibold tracking-tight text-white">
</h2>
<p class="mt-1 text-xs leading-5 text-slate-400 sm:text-sm">
WebSocket debug
</p>
</div>
<div class="flex flex-wrap items-center gap-2 xl:justify-end">
<span
class={`rounded-full border px-3 py-1 text-xs ${debugStatusClassMap[debugSocketState()]}`}
>
{debugStatusLabelMap[debugSocketState()]}
</span>
<span class="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-slate-300">
Source {debugSourceCount()}
</span>
<Show when={latestDebugEntry()}>
<span class="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-slate-300">
: {latestDebugEntry()?.time} / {latestDebugEntry()?.source}
</span>
</Show>
<button
type="button"
class="rounded-full border border-cyan-300/25 bg-cyan-400/10 px-3 py-1.5 text-xs font-medium text-cyan-100 transition hover:bg-cyan-400/20"
onClick={connectDebugSocket}
disabled={debugSocketState() === "open" || debugSocketState() === "connecting"}
>
{debugSocketState() === "connecting" ? "连接中..." : "重新连接"}
</button>
<button
type="button"
class="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-slate-200 transition hover:bg-white/10"
onClick={disconnectDebugSocket}
disabled={debugSocketState() !== "open"}
>
</button>
<button
type="button"
class="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1.5 text-xs font-medium text-rose-100 transition hover:bg-rose-400/20"
onClick={clearDebugLogs}
>
</button>
</div>
</div>
<Show when={debugError()}>
<p class="mt-2 text-xs text-rose-300">{debugError()}</p>
</Show>
</div>
<div class="grid min-h-0 flex-1 gap-3 p-3 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.95fr)]">
<div class="flex min-h-0 flex-col overflow-hidden rounded-[22px] border border-white/10 bg-white/4">
<div class="grid grid-cols-[108px_88px_92px_minmax(240px,1fr)_minmax(220px,1fr)] gap-3 border-b border-white/10 bg-black/20 px-3 py-2 text-[11px] tracking-wide text-slate-300 uppercase">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<div
ref={debugLogContainerRef}
class="min-h-0 flex-1 overflow-auto px-2 py-2 font-mono text-slate-100"
>
<Show
when={debugEntries().length > 0}
fallback={
<div class="flex h-full items-center justify-center rounded-[18px] border border-dashed border-white/10 bg-white/3 px-6 text-slate-500">
</div>
}
>
<div class="min-w-[980px] space-y-1">
<For each={debugEntries()}>
{(entry) => (
<button
type="button"
class={`grid w-full grid-cols-[108px_88px_92px_minmax(240px,1fr)_minmax(220px,1fr)] items-start gap-3 rounded-[16px] border px-3 py-2 text-left transition ${
selectedDebugEntryId() === entry.id
? "border-cyan-300/35 bg-cyan-400/10"
: "border-transparent bg-white/3 hover:border-white/10 hover:bg-white/6"
}`}
onClick={() => setSelectedDebugEntryId(entry.id)}
>
<span class="truncate text-cyan-100">{entry.time}</span>
<span class="truncate text-amber-100">{entry.source}</span>
<span class="truncate text-emerald-200">
{entry.level.toUpperCase()}
</span>
<span class="truncate text-slate-100" title={entry.message}>
{entry.message}
</span>
<span
class="truncate text-slate-400"
title={stringifyDebugFields(entry.fields)}
>
{summarizeDebugFields(entry.fields)}
</span>
</button>
)}
</For>
</div>
</Show>
</div>
</div>
<div class="flex min-h-0 flex-col overflow-hidden rounded-[22px] border border-white/10 bg-black/20">
<div class="border-b border-white/10 px-4 py-3">
<p class="text-sm font-semibold text-white"></p>
<p class="mt-1 text-xs text-slate-400">
</p>
</div>
<div class="min-h-0 flex-1 overflow-auto px-4 py-3 font-mono text-sm text-slate-200">
<Show
when={selectedDebugEntry()}
fallback={<p class="text-slate-500"></p>}
>
<div class="space-y-3">
<div class="grid gap-2 rounded-[18px] border border-white/10 bg-white/5 px-4 py-3 text-xs text-slate-300">
<p>ID: {selectedDebugEntry()?.id}</p>
<p>: {selectedDebugEntry()?.time}</p>
<p>: {selectedDebugEntry()?.source}</p>
<p>: {selectedDebugEntry()?.level}</p>
<p>: {selectedDebugEntry()?.message}</p>
<Show when={selectedDebugEntry()?.caller}>
<p>: {selectedDebugEntry()?.caller}</p>
</Show>
<Show when={selectedDebugEntry()?.logger}>
<p>Logger: {selectedDebugEntry()?.logger}</p>
</Show>
</div>
<div class="rounded-[18px] border border-white/10 bg-zinc-950/90 p-3">
<pre class="whitespace-pre-wrap break-all text-xs leading-6 text-emerald-200">
{stringifyDebugFields(selectedDebugEntry()?.fields)}
</pre>
</div>
</div>
</Show>
</div>
</div>
</div>
</section>
</div>
);
};

31
src/service/debugLog.ts Normal file
View File

@@ -0,0 +1,31 @@
export type DebugLogEntry = {
id: number;
time: string;
level: string;
source: string;
message: string;
logger?: string;
caller?: string;
fields?: Record<string, unknown>;
};
const toWsProtocol = (protocol: string) => {
return protocol === "https:" ? "wss:" : "ws:";
};
export const resolveDebugLogWsUrl = () => {
const baseUrl = import.meta.env.VITE_BASE_URL as string | undefined;
if (baseUrl && /^https?:\/\//.test(baseUrl)) {
const url = new URL(baseUrl);
url.protocol = toWsProtocol(url.protocol);
url.pathname = "/api/debug/ws/logs";
url.search = "";
url.hash = "";
return url.toString();
}
const url = new URL("/api/debug/ws/logs", window.location.origin);
url.protocol = toWsProtocol(window.location.protocol);
return url.toString();
};