feat: split backend debug logs into dedicated page
This commit is contained in:
31
src/App.tsx
31
src/App.tsx
@@ -24,12 +24,6 @@ import {
|
||||
} from "~/service/update";
|
||||
import { versionApi } from "~/service/wk";
|
||||
|
||||
const asideList = [
|
||||
{ label: "账号", url: "/account" },
|
||||
{ label: "日志", url: "/logs" },
|
||||
{ label: "设置", url: "/setting" },
|
||||
];
|
||||
|
||||
type DownloadState = "idle" | "downloading" | "done" | "error";
|
||||
type UpdateCheckState =
|
||||
| "idle"
|
||||
@@ -175,6 +169,23 @@ const App: ParentComponent = (props) => {
|
||||
const buildText = createMemo(() => version()?.data.BuildAt ?? "unknown");
|
||||
const authorText = createMemo(() => version()?.data.GitAuthor ?? "unknown");
|
||||
const emailText = createMemo(() => version()?.data.GitEmail ?? "unknown");
|
||||
const modeText = createMemo(() => version()?.data.Mode ?? "unknown");
|
||||
const isDebugMode = createMemo(
|
||||
() => modeText().toLowerCase() === "debug",
|
||||
);
|
||||
const asideList = createMemo(() => {
|
||||
const items = [
|
||||
{ label: "账号", url: "/account" },
|
||||
{ label: "日志", url: "/logs" },
|
||||
];
|
||||
|
||||
if (isDebugMode()) {
|
||||
items.push({ label: "后端日志", url: "/debug-logs" });
|
||||
}
|
||||
|
||||
items.push({ label: "设置", url: "/setting" });
|
||||
return items;
|
||||
});
|
||||
const versionErrorText = createMemo(() => {
|
||||
const error = version.error;
|
||||
if (!error) {
|
||||
@@ -186,6 +197,7 @@ const App: ParentComponent = (props) => {
|
||||
const versionPayloadText = createMemo(() =>
|
||||
[
|
||||
`Version: ${versionText()}`,
|
||||
`Mode: ${modeText()}`,
|
||||
`Commit: ${commitText()}`,
|
||||
`Build: ${buildText()}`,
|
||||
`Author: ${authorText()}`,
|
||||
@@ -355,7 +367,7 @@ const App: ParentComponent = (props) => {
|
||||
</div>
|
||||
|
||||
<nav class="flex gap-2 overflow-x-auto pb-1 xl:flex-col xl:overflow-visible xl:pb-0">
|
||||
{asideList.map((item) => {
|
||||
{asideList().map((item) => {
|
||||
const active = isActive(item.url);
|
||||
|
||||
return (
|
||||
@@ -387,7 +399,10 @@ const App: ParentComponent = (props) => {
|
||||
<div class="mt-3 rounded-2xl border border-zinc-200/80 bg-zinc-50/90 px-4 py-4 xl:mt-auto">
|
||||
<p class="text-sm font-medium text-zinc-800">当前页面</p>
|
||||
<p class="mt-1 text-sm text-zinc-500">
|
||||
{asideList.find((item) => isActive(item.url))?.label ?? "账号"}
|
||||
{asideList().find((item) => isActive(item.url))?.label ?? "账号"}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-zinc-500">
|
||||
模式: {modeText()}
|
||||
</p>
|
||||
<p class="mt-3 text-xs font-medium tracking-[0.18em] text-cyan-700/75 uppercase">
|
||||
Runtime
|
||||
|
||||
@@ -23,6 +23,10 @@ render(
|
||||
path="logs"
|
||||
component={lazy(() => import("./pages/logs/Logs.tsx"))}
|
||||
/>
|
||||
<Route
|
||||
path="debug-logs"
|
||||
component={lazy(() => import("./pages/debug-logs/DebugLogs.tsx"))}
|
||||
/>
|
||||
<Route
|
||||
path="setting"
|
||||
component={lazy(() => import("./pages/settings/Setting.tsx"))}
|
||||
|
||||
477
src/pages/debug-logs/DebugLogs.tsx
Normal file
477
src/pages/debug-logs/DebugLogs.tsx
Normal file
@@ -0,0 +1,477 @@
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
} from "solid-js";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { versionApi } from "~/service/wk";
|
||||
import {
|
||||
fetchDebugLogSnapshot,
|
||||
resolveDebugLogDownloadUrl,
|
||||
resolveDebugLogWsUrl,
|
||||
type DebugLogEntry,
|
||||
} from "~/service/debugLog";
|
||||
import { settingsStore } from "~/store/settings";
|
||||
|
||||
type DebugSocketState = "connecting" | "open" | "closed" | "error";
|
||||
|
||||
const MAX_DEBUG_ENTRIES = 1000;
|
||||
|
||||
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 DebugLogs = () => {
|
||||
const navigate = useNavigate();
|
||||
const [version] = createResource(versionApi);
|
||||
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(0);
|
||||
let debugLogContainerRef: HTMLDivElement | undefined;
|
||||
let debugSocket: WebSocket | null = null;
|
||||
let reconnectTimer: number | undefined;
|
||||
let manualClose = false;
|
||||
const debugEntryKeySet = new Set<number>();
|
||||
|
||||
const isDebugMode = createMemo(() => {
|
||||
return (version()?.data.Mode ?? "").toLowerCase() === "debug";
|
||||
});
|
||||
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;
|
||||
});
|
||||
|
||||
const resetDebugEntries = () => {
|
||||
debugEntryKeySet.clear();
|
||||
setDebugEntries([]);
|
||||
setSelectedDebugEntryId(0);
|
||||
};
|
||||
|
||||
const replaceDebugEntries = (entries: DebugLogEntry[]) => {
|
||||
const nextEntries = entries.slice(-MAX_DEBUG_ENTRIES);
|
||||
debugEntryKeySet.clear();
|
||||
for (const entry of nextEntries) {
|
||||
debugEntryKeySet.add(entry.id);
|
||||
}
|
||||
setDebugEntries(nextEntries);
|
||||
setSelectedDebugEntryId((prev) => {
|
||||
if (prev && nextEntries.some((entry) => entry.id === prev)) {
|
||||
return prev;
|
||||
}
|
||||
return nextEntries[nextEntries.length - 1]?.id ?? 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 loadDebugSnapshot = async () => {
|
||||
try {
|
||||
const entries = await fetchDebugLogSnapshot();
|
||||
replaceDebugEntries(entries);
|
||||
setDebugError("");
|
||||
} catch (error) {
|
||||
setDebugError(
|
||||
error instanceof Error ? error.message : "获取调试日志快照失败",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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 (!isDebugMode() || debugSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
manualClose = false;
|
||||
setDebugSocketState("connecting");
|
||||
setDebugError("");
|
||||
|
||||
try {
|
||||
const socket = new WebSocket(resolveDebugLogWsUrl());
|
||||
debugSocket = socket;
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
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 payloads.
|
||||
}
|
||||
});
|
||||
|
||||
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 unsubscribeSettings = settingsStore.subscribe((state) => {
|
||||
setSettingsState(state);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
unsubscribeSettings();
|
||||
disconnectDebugSocket();
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (version.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDebugMode()) {
|
||||
disconnectDebugSocket();
|
||||
resetDebugEntries();
|
||||
void navigate("/logs", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
void loadDebugSnapshot();
|
||||
connectDebugSocket();
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
debugEntries().length;
|
||||
|
||||
if (!settingsState().autoScrollLogs) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const element = debugLogContainerRef;
|
||||
if (element) {
|
||||
element.scrollTo({ top: element.scrollHeight, behavior: "smooth" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (debugLogContainerRef) {
|
||||
debugLogContainerRef.style.fontSize = `${Math.max(settingsState().logFontSize, 12)}px`;
|
||||
}
|
||||
});
|
||||
|
||||
const clearDebugLogs = () => {
|
||||
resetDebugEntries();
|
||||
};
|
||||
|
||||
const downloadDebugLogs = () => {
|
||||
window.open(resolveDebugLogDownloadUrl(), "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
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(236,254,255,0.95),rgba(255,255,255,0.94))] px-5 py-4 shadow-[0_18px_50px_-30px_rgba(8,145,178,0.22)]">
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div class="max-w-xl">
|
||||
<p class="text-[10px] font-medium tracking-[0.28em] text-cyan-700/80 uppercase">
|
||||
Backend Observer
|
||||
</p>
|
||||
<h1 class="mt-1.5 text-2xl font-semibold tracking-tight text-zinc-950">
|
||||
后端日志
|
||||
</h1>
|
||||
<p class="mt-1 text-xs leading-5 text-zinc-600 sm:text-sm">
|
||||
独立查看后端 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/80 bg-white/85 px-3 py-1 text-xs text-zinc-700">
|
||||
TOTAL {debugEntries().length}
|
||||
</span>
|
||||
<span class="rounded-full border border-white/80 bg-white/85 px-3 py-1 text-xs text-zinc-700">
|
||||
SOURCE {debugSourceCount()}
|
||||
</span>
|
||||
<Show when={latestDebugEntry()}>
|
||||
<span class="rounded-full border border-white/80 bg-white/85 px-3 py-1 text-xs text-zinc-600">
|
||||
最新: {latestDebugEntry()?.time} / {latestDebugEntry()?.source}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={isDebugMode()}
|
||||
fallback={
|
||||
<div class="mt-3 flex flex-1 items-center justify-center rounded-[26px] border border-dashed border-zinc-300 bg-white/60 px-6 text-zinc-500">
|
||||
当前不是 debug 模式,后端日志页已隐藏。
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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">
|
||||
Debug Stream
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-5 text-slate-400 sm:text-sm">
|
||||
先读取快照,再通过 WebSocket 持续接收增量日志。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 xl:justify-end">
|
||||
<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={() => void loadDebugSnapshot()}
|
||||
>
|
||||
刷新快照
|
||||
</button>
|
||||
<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={downloadDebugLogs}
|
||||
>
|
||||
下载日志
|
||||
</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>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DebugLogs;
|
||||
@@ -7,10 +7,6 @@ import {
|
||||
onMount,
|
||||
Show,
|
||||
} from "solid-js";
|
||||
import {
|
||||
resolveDebugLogWsUrl,
|
||||
type DebugLogEntry,
|
||||
} from "~/service/debugLog";
|
||||
import { accountStore } from "~/store/account";
|
||||
import { settingsStore } from "~/store/settings";
|
||||
|
||||
@@ -25,10 +21,6 @@ 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;
|
||||
@@ -56,159 +48,12 @@ 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) => {
|
||||
@@ -217,12 +62,10 @@ const Logs = () => {
|
||||
const unsubscribeSettings = settingsStore.subscribe((state) => {
|
||||
setSettingsState(state);
|
||||
});
|
||||
connectDebugSocket();
|
||||
|
||||
onCleanup(() => {
|
||||
unsubscribeAccount();
|
||||
unsubscribeSettings();
|
||||
disconnectDebugSocket();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -290,20 +133,6 @@ 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;
|
||||
|
||||
@@ -319,54 +148,29 @@ 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)]">
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div class="max-w-xl">
|
||||
<p class="text-[10px] font-medium tracking-[0.28em] text-amber-700/80 uppercase">
|
||||
Unified Stream
|
||||
Study Stream
|
||||
</p>
|
||||
<h1 class="mt-1.5 text-2xl font-semibold tracking-tight text-zinc-950">
|
||||
日志中心
|
||||
学习日志
|
||||
</h1>
|
||||
<p class="mt-1 text-xs leading-5 text-zinc-600 sm:text-sm">
|
||||
单行并列展示时间、账号、姓名与日志内容。
|
||||
单行并列展示时间、账号、姓名与学习日志内容。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -392,14 +196,6 @@ 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"
|
||||
@@ -454,10 +250,7 @@ const Logs = () => {
|
||||
? row.timestamp
|
||||
: "--:--:--"}
|
||||
</span>
|
||||
<span
|
||||
class="truncate text-amber-200"
|
||||
title={row.accountName}
|
||||
>
|
||||
<span class="truncate text-amber-200" title={row.accountName}>
|
||||
{row.accountName}
|
||||
</span>
|
||||
<span class="truncate text-slate-300" title={row.accountId}>
|
||||
@@ -478,158 +271,6 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,23 +9,55 @@ export type DebugLogEntry = {
|
||||
fields?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type DebugLogListResponse = {
|
||||
code: number;
|
||||
message: string;
|
||||
data: {
|
||||
list: DebugLogEntry[];
|
||||
};
|
||||
};
|
||||
|
||||
const toWsProtocol = (protocol: string) => {
|
||||
return protocol === "https:" ? "wss:" : "ws:";
|
||||
};
|
||||
|
||||
export const resolveDebugLogWsUrl = () => {
|
||||
const resolveDebugLogUrl = (pathname: string) => {
|
||||
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.pathname = pathname;
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
const url = new URL("/api/debug/ws/logs", window.location.origin);
|
||||
url.protocol = toWsProtocol(window.location.protocol);
|
||||
const url = new URL(pathname, window.location.origin);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
export const resolveDebugLogWsUrl = () => {
|
||||
const url = new URL(resolveDebugLogUrl("/api/debug/logs/ws"));
|
||||
url.protocol = toWsProtocol(url.protocol);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
export const resolveDebugLogDownloadUrl = () => {
|
||||
return resolveDebugLogUrl("/api/debug/logs/download");
|
||||
};
|
||||
|
||||
export const fetchDebugLogSnapshot = async () => {
|
||||
const response = await fetch(resolveDebugLogUrl("/api/debug/logs"), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取调试日志失败: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as DebugLogListResponse;
|
||||
return payload.data.list ?? [];
|
||||
};
|
||||
|
||||
@@ -104,11 +104,12 @@ export type HostRes = ApiResponse<{
|
||||
}>;
|
||||
|
||||
export type VersionData = {
|
||||
BuildAt: string;
|
||||
GitAuthor: string;
|
||||
GitCommit: string;
|
||||
GitEmail: string;
|
||||
Version: string;
|
||||
BuildAt: string;
|
||||
GitAuthor: string;
|
||||
GitCommit: string;
|
||||
GitEmail: string;
|
||||
Mode: string;
|
||||
Version: string;
|
||||
};
|
||||
|
||||
export type VersionRes = ApiResponse<VersionData>;
|
||||
|
||||
Reference in New Issue
Block a user