Hydrate terminal drawer with current log history
This commit is contained in:
@@ -89,6 +89,23 @@ describe("ServerManagementTerminalDrawer", () => {
|
|||||||
expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled();
|
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 () => {
|
it("uses the server-provided clock for terminal system lines", async () => {
|
||||||
const serverTime = "2001-02-03T04:05:06Z";
|
const serverTime = "2001-02-03T04:05:06Z";
|
||||||
await renderDrawer();
|
await renderDrawer();
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC
|
|||||||
const serverTimeRef = useRef<string | undefined>(undefined);
|
const serverTimeRef = useRef<string | undefined>(undefined);
|
||||||
const initialHistoryPendingRef = useRef(false);
|
const initialHistoryPendingRef = useRef(false);
|
||||||
const liveSessionRef = useRef<string | null | undefined>(undefined);
|
const liveSessionRef = useRef<string | null | undefined>(undefined);
|
||||||
|
const liveStreamsRef = useRef<LogStreamResponse[]>([]);
|
||||||
|
const hydratedLiveStreamKeysRef = useRef<Set<string>>(new Set());
|
||||||
|
const liveHistoryRequestRef = useRef(0);
|
||||||
const historyRequestRef = useRef(0);
|
const historyRequestRef = useRef(0);
|
||||||
|
|
||||||
const appendLines = useCallback((incoming: TerminalLine[]) => {
|
const appendLines = useCallback((incoming: TerminalLine[]) => {
|
||||||
@@ -128,6 +131,9 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC
|
|||||||
setHistoryLines({ status: "idle" });
|
setHistoryLines({ status: "idle" });
|
||||||
setSelectedHistoryStreamId("");
|
setSelectedHistoryStreamId("");
|
||||||
liveSessionRef.current = undefined;
|
liveSessionRef.current = undefined;
|
||||||
|
liveStreamsRef.current = [];
|
||||||
|
hydratedLiveStreamKeysRef.current = new Set();
|
||||||
|
liveHistoryRequestRef.current += 1;
|
||||||
serverTimeRef.current = undefined;
|
serverTimeRef.current = undefined;
|
||||||
historyRequestRef.current += 1;
|
historyRequestRef.current += 1;
|
||||||
initialHistoryPendingRef.current = true;
|
initialHistoryPendingRef.current = true;
|
||||||
@@ -145,6 +151,29 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC
|
|||||||
return () => window.cancelAnimationFrame(frame);
|
return () => window.cancelAnimationFrame(frame);
|
||||||
}, [lines, open]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!open) return undefined;
|
if (!open) return undefined;
|
||||||
let ready = false;
|
let ready = false;
|
||||||
@@ -159,6 +188,9 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC
|
|||||||
liveSessionRef.current = nextSessionId;
|
liveSessionRef.current = nextSessionId;
|
||||||
setLiveSessionId(nextSessionId);
|
setLiveSessionId(nextSessionId);
|
||||||
if (previousSessionId === nextSessionId) return;
|
if (previousSessionId === nextSessionId) return;
|
||||||
|
liveStreamsRef.current = [];
|
||||||
|
hydratedLiveStreamKeysRef.current = new Set();
|
||||||
|
liveHistoryRequestRef.current += 1;
|
||||||
setStreams({ status: "ready", data: [] });
|
setStreams({ status: "ready", data: [] });
|
||||||
setLines(nextSessionId
|
setLines(nextSessionId
|
||||||
? [terminalSystemLine("info", previousSessionId === undefined ? "已跟随当前受管进程输出会话。" : "Run 已切换到新的受管进程输出会话。", "SYSTEM", `session-${nextSessionId}`, serverTimeRef.current)]
|
? [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);
|
const stream = parseLogStreamEvent(event);
|
||||||
if (!stream || !eventBelongsToLiveSession(stream.logSessionId, liveSessionRef.current)) return;
|
if (!stream || !eventBelongsToLiveSession(stream.logSessionId, liveSessionRef.current)) return;
|
||||||
ready = true;
|
ready = true;
|
||||||
|
liveStreamsRef.current = mergeLogStreams(liveStreamsRef.current, stream);
|
||||||
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
|
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
|
||||||
});
|
});
|
||||||
events.addEventListener("ready", () => {
|
events.addEventListener("ready", () => {
|
||||||
ready = true;
|
ready = true;
|
||||||
setStreams((current) => current.status === "ready" ? current : { status: "ready", data: [] });
|
setStreams((current) => current.status === "ready" ? current : { status: "ready", data: [] });
|
||||||
|
hydrateCurrentSessionHistory(liveSessionRef.current);
|
||||||
lockTerminalFollow();
|
lockTerminalFollow();
|
||||||
});
|
});
|
||||||
events.addEventListener("log", (event) => {
|
events.addEventListener("log", (event) => {
|
||||||
@@ -181,6 +215,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC
|
|||||||
if (!payload || !eventBelongsToLiveSession(payload.logSessionId, liveSessionRef.current)) return;
|
if (!payload || !eventBelongsToLiveSession(payload.logSessionId, liveSessionRef.current)) return;
|
||||||
ready = true;
|
ready = true;
|
||||||
const stream = streamFromServerLogEvent(payload);
|
const stream = streamFromServerLogEvent(payload);
|
||||||
|
liveStreamsRef.current = mergeLogStreams(liveStreamsRef.current, stream);
|
||||||
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
|
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
|
||||||
appendLines([terminalLineFromLog(stream, payload.entry)]);
|
appendLines([terminalLineFromLog(stream, payload.entry)]);
|
||||||
});
|
});
|
||||||
@@ -188,7 +223,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, onC
|
|||||||
if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" });
|
if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" });
|
||||||
};
|
};
|
||||||
return () => events.close();
|
return () => events.close();
|
||||||
}, [appendLines, lockTerminalFollow, open, serverId]);
|
}, [appendLines, hydrateCurrentSessionHistory, lockTerminalFollow, open, serverId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !historyOpen) return;
|
if (!open || !historyOpen) return;
|
||||||
@@ -381,6 +416,10 @@ function eventBelongsToLiveSession(eventSessionId: string | undefined, liveSessi
|
|||||||
return Boolean(normalizedEventSessionId && normalizedEventSessionId === liveSessionId);
|
return Boolean(normalizedEventSessionId && normalizedEventSessionId === liveSessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function liveHistoryStreamKey(sessionId: string, stream: LogStreamResponse): string {
|
||||||
|
return `${sessionId}:${stream.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
function mergeTerminalLines(current: TerminalLine[], incoming: TerminalLine[]): TerminalLine[] {
|
function mergeTerminalLines(current: TerminalLine[], incoming: TerminalLine[]): TerminalLine[] {
|
||||||
const seen = new Set(current.map((line) => line.id));
|
const seen = new Set(current.map((line) => line.id));
|
||||||
const merged = [...current];
|
const merged = [...current];
|
||||||
|
|||||||
Reference in New Issue
Block a user