From 892c6d843f8c3c2785af66bd658a045710ab481a Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Wed, 9 Sep 2026 15:02:39 +0800 Subject: [PATCH] Hydrate terminal drawer with current log history --- .../ServerManagementTerminalDrawer.test.tsx | 17 ++++++++ .../ServerManagementTerminalDrawer.tsx | 41 ++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/platform_web/components/ServerManagementTerminalDrawer.test.tsx b/platform_web/components/ServerManagementTerminalDrawer.test.tsx index b41f415..8301f08 100644 --- a/platform_web/components/ServerManagementTerminalDrawer.test.tsx +++ b/platform_web/components/ServerManagementTerminalDrawer.test.tsx @@ -89,6 +89,23 @@ describe("ServerManagementTerminalDrawer", () => { expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled(); }); + it("hydrates recent current-session output when the terminal opens after Run already attached", async () => { + const currentStream = logStream("stdout-current", "session-current", "process.stdout"); + currentStream.latestSeq = 900; + apiMocks.queryLogStream.mockResolvedValueOnce({ logStreamId: currentStream.id, entries: [logEntry(899, "existing SCUM output before drawer opened", "2026-08-14T00:14:59Z"), logEntry(900, "latest SCUM output before drawer opened", "2026-08-14T00:15:00Z")], nextSeq: 901, latestSeq: 900 }); + await renderDrawer(); + + await emitSession("session-current"); + await emitStream(currentStream); + await emitReady(); + await flushPromises(); + + expect(container?.textContent).toContain("existing SCUM output before drawer opened"); + expect(container?.textContent).toContain("latest SCUM output before drawer opened"); + expect(apiMocks.queryLogStream).toHaveBeenCalledWith({ logStreamId: currentStream.id, afterSeq: 400, limit: 500 }); + expect(apiMocks.listLogStreams).not.toHaveBeenCalled(); + }); + it("uses the server-provided clock for terminal system lines", async () => { const serverTime = "2001-02-03T04:05:06Z"; await renderDrawer(); diff --git a/platform_web/components/ServerManagementTerminalDrawer.tsx b/platform_web/components/ServerManagementTerminalDrawer.tsx index 549da58..8135be4 100644 --- a/platform_web/components/ServerManagementTerminalDrawer.tsx +++ b/platform_web/components/ServerManagementTerminalDrawer.tsx @@ -95,6 +95,9 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC const serverTimeRef = useRef(undefined); const initialHistoryPendingRef = useRef(false); const liveSessionRef = useRef(undefined); + const liveStreamsRef = useRef([]); + const hydratedLiveStreamKeysRef = useRef>(new Set()); + const liveHistoryRequestRef = useRef(0); const historyRequestRef = useRef(0); const appendLines = useCallback((incoming: TerminalLine[]) => { @@ -128,6 +131,9 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC setHistoryLines({ status: "idle" }); setSelectedHistoryStreamId(""); liveSessionRef.current = undefined; + liveStreamsRef.current = []; + hydratedLiveStreamKeysRef.current = new Set(); + liveHistoryRequestRef.current += 1; serverTimeRef.current = undefined; historyRequestRef.current += 1; initialHistoryPendingRef.current = true; @@ -145,6 +151,29 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC return () => window.cancelAnimationFrame(frame); }, [lines, open]); + const hydrateCurrentSessionHistory = useCallback((sessionId: string | null | undefined) => { + if (!sessionId) return; + const streamsToHydrate = liveStreamsRef.current.filter((stream) => eventBelongsToLiveSession(stream.logSessionId, sessionId) && stream.latestSeq > 0 && !hydratedLiveStreamKeysRef.current.has(liveHistoryStreamKey(sessionId, stream))); + if (streamsToHydrate.length === 0) return; + const requestId = liveHistoryRequestRef.current + 1; + liveHistoryRequestRef.current = requestId; + for (const stream of streamsToHydrate) hydratedLiveStreamKeysRef.current.add(liveHistoryStreamKey(sessionId, stream)); + void Promise.all(streamsToHydrate.map(async (stream) => { + const response = await platformApiClient.queryLogStream({ logStreamId: stream.id, afterSeq: Math.max(0, stream.latestSeq - terminalHistoryWindow), limit: terminalHistoryWindow }); + return response.entries.map((entry) => terminalLineFromLog(stream, entry)); + })).then((lineGroups) => { + if (liveHistoryRequestRef.current !== requestId || liveSessionRef.current !== sessionId) return; + const historicalLines = lineGroups.flat(); + if (historicalLines.length === 0) return; + appendLines(historicalLines); + lockTerminalFollow(); + }).catch(() => { + if (liveHistoryRequestRef.current !== requestId || liveSessionRef.current !== sessionId) return; + appendLines([terminalSystemLine("warn", "当前会话历史读取失败,继续等待实时输出。", "SYSTEM", `session-history-failed-${sessionId}`, serverTimeRef.current)]); + lockTerminalFollow(); + }); + }, [appendLines, lockTerminalFollow]); + useEffect(() => { if (!open) return undefined; let ready = false; @@ -159,6 +188,9 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC liveSessionRef.current = nextSessionId; setLiveSessionId(nextSessionId); if (previousSessionId === nextSessionId) return; + liveStreamsRef.current = []; + hydratedLiveStreamKeysRef.current = new Set(); + liveHistoryRequestRef.current += 1; setStreams({ status: "ready", data: [] }); setLines(nextSessionId ? [terminalSystemLine("info", previousSessionId === undefined ? "已跟随当前受管进程输出会话。" : "Run 已切换到新的受管进程输出会话。", "SYSTEM", `session-${nextSessionId}`, serverTimeRef.current)] @@ -169,11 +201,13 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC const stream = parseLogStreamEvent(event); if (!stream || !eventBelongsToLiveSession(stream.logSessionId, liveSessionRef.current)) return; ready = true; + liveStreamsRef.current = mergeLogStreams(liveStreamsRef.current, stream); setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) })); }); events.addEventListener("ready", () => { ready = true; setStreams((current) => current.status === "ready" ? current : { status: "ready", data: [] }); + hydrateCurrentSessionHistory(liveSessionRef.current); lockTerminalFollow(); }); events.addEventListener("log", (event) => { @@ -181,6 +215,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC if (!payload || !eventBelongsToLiveSession(payload.logSessionId, liveSessionRef.current)) return; ready = true; const stream = streamFromServerLogEvent(payload); + liveStreamsRef.current = mergeLogStreams(liveStreamsRef.current, stream); setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) })); appendLines([terminalLineFromLog(stream, payload.entry)]); }); @@ -188,7 +223,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" }); }; return () => events.close(); - }, [appendLines, lockTerminalFollow, open, serverId]); + }, [appendLines, hydrateCurrentSessionHistory, lockTerminalFollow, open, serverId]); useEffect(() => { if (!open || !historyOpen) return; @@ -381,6 +416,10 @@ function eventBelongsToLiveSession(eventSessionId: string | undefined, liveSessi return Boolean(normalizedEventSessionId && normalizedEventSessionId === liveSessionId); } +function liveHistoryStreamKey(sessionId: string, stream: LogStreamResponse): string { + return `${sessionId}:${stream.id}`; +} + function mergeTerminalLines(current: TerminalLine[], incoming: TerminalLine[]): TerminalLine[] { const seen = new Set(current.map((line) => line.id)); const merged = [...current];