From 7e102b3b7647a9ef995ecffd3a1b66ebf737c89f Mon Sep 17 00:00:00 2001 From: zhilv666 Date: Thu, 2 Apr 2026 23:27:11 +0800 Subject: [PATCH] feat: add backend debug log panel --- src/pages/logs/Logs.tsx | 356 ++++++++++++++++++++++++++++++++++++++++ src/service/debugLog.ts | 31 ++++ 2 files changed, 387 insertions(+) create mode 100644 src/service/debugLog.ts diff --git a/src/pages/logs/Logs.tsx b/src/pages/logs/Logs.tsx index f974b65..cbf47d7 100644 --- a/src/pages/logs/Logs.tsx +++ b/src/pages/logs/Logs.tsx @@ -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 = { + connecting: "连接中", + open: "已连接", + closed: "已断开", + error: "连接异常", +}; + +const debugStatusClassMap: Record = { + 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) => { + if (!fields || Object.keys(fields).length === 0) { + return ""; + } + + return JSON.stringify(fields, null, 2); +}; + +const summarizeDebugFields = (fields?: Record) => { + 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([]); + const [debugSocketState, setDebugSocketState] = + createSignal("connecting"); + const [debugError, setDebugError] = createSignal(""); + const [selectedDebugEntryId, setSelectedDebugEntryId] = createSignal(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(); + + 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 (
@@ -196,6 +392,14 @@ const Logs = () => { 最新: {latestLog()?.timestamp} / {latestLog()?.accountName}
+
+ + DEBUG STREAM + + + {debugEntries().length} + +
+ +
+
+
+
+

+ Backend Debug Stream +

+

+ 后端实时日志 +

+

+ 通过 WebSocket 读取后端 debug 模式下的请求、响应和运行日志。 +

+
+ +
+ + {debugStatusLabelMap[debugSocketState()]} + + + Source {debugSourceCount()} + + + + 最新: {latestDebugEntry()?.time} / {latestDebugEntry()?.source} + + + + + +
+
+ +

{debugError()}

+
+
+ +
+
+
+ 时间 + 来源 + 等级 + 消息 + 摘要 +
+
+ 0} + fallback={ +
+ 暂无后端调试日志 +
+ } + > +
+ + {(entry) => ( + + )} + +
+
+
+
+ +
+
+

日志详情

+

+ 查看当前选中日志的完整字段与上下文。 +

+
+
+ 请选择左侧一条日志查看详情。

} + > +
+
+

ID: {selectedDebugEntry()?.id}

+

时间: {selectedDebugEntry()?.time}

+

来源: {selectedDebugEntry()?.source}

+

等级: {selectedDebugEntry()?.level}

+

消息: {selectedDebugEntry()?.message}

+ +

调用位置: {selectedDebugEntry()?.caller}

+
+ +

Logger: {selectedDebugEntry()?.logger}

+
+
+
+
+                      {stringifyDebugFields(selectedDebugEntry()?.fields)}
+                    
+
+
+
+
+
+
+
); }; diff --git a/src/service/debugLog.ts b/src/service/debugLog.ts new file mode 100644 index 0000000..0a8a95b --- /dev/null +++ b/src/service/debugLog.ts @@ -0,0 +1,31 @@ +export type DebugLogEntry = { + id: number; + time: string; + level: string; + source: string; + message: string; + logger?: string; + caller?: string; + fields?: Record; +}; + +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(); +};