437 lines
22 KiB
TypeScript
437 lines
22 KiB
TypeScript
import { History, Trash2, X } from "lucide-react";
|
|
import { type FormEvent, type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
import { platformApiClient } from "../api/client";
|
|
import type { JobResponse, LogEntryBody, LogStreamResponse } from "../api/types";
|
|
import { scumSourceRCONCommandRequest } from "../schemas/scumManagementRcon";
|
|
import { cx } from "../utils/classes";
|
|
import { mergeLogStreams, parseLogSessionEvent, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents";
|
|
import { formatTerminalLogTime, formatTerminalServerTime } from "../utils/logTime";
|
|
import { ResultBadge } from "./StateViews";
|
|
|
|
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
|
type HistoryLineState = { status: "idle" } | LoadState<TerminalLine[]>;
|
|
type TerminalLine = { id: string; tone: "input" | "info" | "success" | "warn" | "error"; text: string; at: string; sortKey: number; streamKey?: string; level?: string; seq?: number };
|
|
|
|
const terminalHistoryWindow = 500;
|
|
const maxTerminalLines = 10000;
|
|
const terminalJobResultPollMs = 1000;
|
|
const terminalJobResultPollAttempts = 30;
|
|
// Game command catalogs are plugin-owned; this extension point intentionally
|
|
// contains no platform-provided game commands.
|
|
const terminalQuickCommandCatalog: Record<string, never[]> = {};
|
|
|
|
function terminalQuickCommandsForPlugin(pluginId: string): never[] {
|
|
return terminalQuickCommandCatalog[pluginId] ?? [];
|
|
}
|
|
|
|
interface LiveOperationDrawerProps {
|
|
open: boolean;
|
|
title: string;
|
|
description?: string;
|
|
onClose: () => void;
|
|
children: ReactNode;
|
|
backdropClassName?: string;
|
|
panelClassName?: string;
|
|
bodyClassName?: string;
|
|
hideHeader?: boolean;
|
|
}
|
|
|
|
function LiveOperationDrawer({ open, title, description, onClose, children, backdropClassName, panelClassName, bodyClassName, hideHeader }: LiveOperationDrawerProps) {
|
|
useEffect(() => {
|
|
if (!open) return undefined;
|
|
const previous = document.body.style.overflow;
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") onClose();
|
|
};
|
|
document.body.style.overflow = "hidden";
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => {
|
|
document.body.style.overflow = previous;
|
|
document.removeEventListener("keydown", handleKeyDown);
|
|
};
|
|
}, [onClose, open]);
|
|
|
|
if (!open) return null;
|
|
return (
|
|
<div className={cx("drawer-backdrop", backdropClassName)} role="presentation" onClick={onClose}>
|
|
<aside className={cx("drawer-panel live-operation-drawer", panelClassName)} role="dialog" aria-modal="true" aria-label={title} onClick={(event) => event.stopPropagation()}>
|
|
{!hideHeader && (
|
|
<div className="panel-header">
|
|
<div>
|
|
<h2>{title}</h2>
|
|
{description && <p className="page-status">{description}</p>}
|
|
</div>
|
|
<button type="button" className="theme-upload drawer-close" aria-label={`关闭${title}`} onClick={onClose}><X size={14} /><span>关闭</span></button>
|
|
</div>
|
|
)}
|
|
<div className={cx("live-operation-content", bodyClassName)}>{children}</div>
|
|
</aside>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ServerManagementTerminalDrawerProps {
|
|
open: boolean;
|
|
serverId: string;
|
|
serverName: string;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function ServerManagementTerminalDrawer({ open, serverId, serverName, onClose }: ServerManagementTerminalDrawerProps) {
|
|
const [command, setCommand] = useState("");
|
|
const [pending, setPending] = useState(false);
|
|
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
|
const [lines, setLines] = useState<TerminalLine[]>([]);
|
|
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
|
|
const [followLatest, setFollowLatest] = useState(true);
|
|
const [liveSessionId, setLiveSessionId] = useState<string | null>(null);
|
|
const [historyOpen, setHistoryOpen] = useState(false);
|
|
const [historyStreams, setHistoryStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
|
|
const [historyLines, setHistoryLines] = useState<HistoryLineState>({ status: "idle" });
|
|
const [selectedHistoryStreamId, setSelectedHistoryStreamId] = useState("");
|
|
const outputRef = useRef<HTMLDivElement>(null);
|
|
const followLatestRef = useRef(true);
|
|
const serverTimeRef = useRef<string | undefined>(undefined);
|
|
const initialHistoryPendingRef = useRef(false);
|
|
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 appendLines = useCallback((incoming: TerminalLine[]) => {
|
|
if (incoming.length === 0) return;
|
|
setLines((current) => mergeTerminalLines(current, incoming));
|
|
}, []);
|
|
|
|
const lockTerminalFollow = useCallback(() => {
|
|
followLatestRef.current = true;
|
|
setFollowLatest(true);
|
|
window.requestAnimationFrame(() => {
|
|
const output = outputRef.current;
|
|
if (output) output.scrollTop = output.scrollHeight;
|
|
window.requestAnimationFrame(() => {
|
|
const innerOutput = outputRef.current;
|
|
if (innerOutput) innerOutput.scrollTop = innerOutput.scrollHeight;
|
|
initialHistoryPendingRef.current = false;
|
|
});
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setStreams({ status: "loading" });
|
|
setCommand("");
|
|
setPending(false);
|
|
setResult(null);
|
|
setLiveSessionId(null);
|
|
setHistoryOpen(false);
|
|
setHistoryStreams({ status: "loading" });
|
|
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;
|
|
followLatestRef.current = true;
|
|
setFollowLatest(true);
|
|
setLines([terminalSystemLine("info", "正在连接当前受管进程输出。", "SYSTEM", undefined, serverTimeRef.current)]);
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
if (!open || initialHistoryPendingRef.current || !followLatestRef.current) return undefined;
|
|
const frame = window.requestAnimationFrame(() => {
|
|
const output = outputRef.current;
|
|
if (output) output.scrollTop = output.scrollHeight;
|
|
});
|
|
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;
|
|
const events = platformApiClient.openServerLogEvents(serverId);
|
|
events.addEventListener("session", (event) => {
|
|
const session = parseLogSessionEvent(event);
|
|
if (!session) return;
|
|
ready = true;
|
|
serverTimeRef.current = session.serverTime;
|
|
const nextSessionId = normalizeLogSessionId(session.logSessionId);
|
|
const previousSessionId = liveSessionRef.current;
|
|
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)]
|
|
: [terminalSystemLine("warn", "当前没有可跟随的受管进程输出;旧日志可从历史查看。", "SYSTEM", "session-empty", serverTimeRef.current)]);
|
|
lockTerminalFollow();
|
|
});
|
|
events.addEventListener("stream", (event) => {
|
|
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) => {
|
|
const payload = parseServerLogEvent(event);
|
|
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)]);
|
|
});
|
|
events.onerror = () => {
|
|
if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" });
|
|
};
|
|
return () => events.close();
|
|
}, [appendLines, hydrateCurrentSessionHistory, lockTerminalFollow, open, serverId]);
|
|
|
|
useEffect(() => {
|
|
if (!open || !historyOpen) return;
|
|
let cancelled = false;
|
|
setHistoryStreams({ status: "loading" });
|
|
void platformApiClient.listLogStreams(serverId).then((response) => {
|
|
if (!cancelled) setHistoryStreams({ status: "ready", data: [...response.items].sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)) });
|
|
}).catch((error) => {
|
|
if (!cancelled) setHistoryStreams({ status: "error", reason: error instanceof Error ? error.message : "历史日志列表加载失败" });
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [historyOpen, open, serverId]);
|
|
|
|
async function selectHistoryStream(streamId: string) {
|
|
const stream = historyStreams.status === "ready" ? historyStreams.data.find((item) => item.id === streamId) : undefined;
|
|
if (!stream) return;
|
|
const requestId = historyRequestRef.current + 1;
|
|
historyRequestRef.current = requestId;
|
|
setSelectedHistoryStreamId(streamId);
|
|
setHistoryLines({ status: "loading" });
|
|
try {
|
|
const response = await platformApiClient.queryLogStream({ logStreamId: streamId, afterSeq: Math.max(0, stream.latestSeq - terminalHistoryWindow), limit: terminalHistoryWindow });
|
|
if (historyRequestRef.current !== requestId) return;
|
|
setHistoryLines({ status: "ready", data: response.entries.map((entry) => terminalLineFromLog(stream, entry)) });
|
|
} catch (error) {
|
|
if (historyRequestRef.current !== requestId) return;
|
|
setHistoryLines({ status: "error", reason: error instanceof Error ? error.message : "历史日志加载失败" });
|
|
}
|
|
}
|
|
|
|
function clearTerminalBuffer() {
|
|
if (historyOpen) {
|
|
setHistoryLines({ status: "ready", data: [] });
|
|
return;
|
|
}
|
|
setLines([]);
|
|
}
|
|
|
|
function toggleHistory() {
|
|
historyRequestRef.current += 1;
|
|
setHistoryOpen((current) => !current);
|
|
setSelectedHistoryStreamId("");
|
|
setHistoryLines({ status: "idle" });
|
|
}
|
|
|
|
function handleTerminalScroll() {
|
|
const output = outputRef.current;
|
|
if (!output || initialHistoryPendingRef.current) return;
|
|
const nextFollowLatest = output.scrollHeight - output.clientHeight - output.scrollTop <= 24;
|
|
followLatestRef.current = nextFollowLatest;
|
|
setFollowLatest(nextFollowLatest);
|
|
}
|
|
|
|
async function submitCommand(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
if (pending || !command.trim()) return;
|
|
const submitted = command.trim();
|
|
setCommand("");
|
|
setPending(true);
|
|
setResult({ status: "pending", label: "正在提交命令" });
|
|
try {
|
|
const response = await platformApiClient.dispatchSourceRCONCommand(serverId, scumSourceRCONCommandRequest(serverId, submitted));
|
|
const label = `已${response.status === "queued" ? "排队" : "提交"} · RCON 任务 ${response.jobId}`;
|
|
setResult({ status: "pending", label });
|
|
const finalJob = await waitForRCONJobTerminal(response.jobId);
|
|
if (finalJob) setResult({ status: finalJob.state === "succeeded" ? "succeeded" : "failed", label: finalJob.progress.message || finalJob.executionResult?.summary || finalJob.state });
|
|
} catch (error) {
|
|
setResult({ status: "failed", label: error instanceof Error ? error.message : "命令提交失败" });
|
|
} finally {
|
|
setPending(false);
|
|
}
|
|
}
|
|
|
|
async function waitForRCONJobTerminal(jobId: string): Promise<JobResponse | null> {
|
|
for (let attempt = 0; attempt < terminalJobResultPollAttempts; attempt += 1) {
|
|
const current = await platformApiClient.getJob(jobId);
|
|
if (current.state === "succeeded" || current.state === "failed" || current.state === "cancelled") return current;
|
|
await new Promise((resolve) => window.setTimeout(resolve, terminalJobResultPollMs));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<LiveOperationDrawer open={open} title="打开终端" onClose={onClose} backdropClassName="terminal-drawer-backdrop" panelClassName="management-terminal-drawer" bodyClassName="management-terminal-body" hideHeader>
|
|
<section className="terminal-output-panel" aria-label="terminal output">
|
|
<div className="terminal-output-topbar">
|
|
<div>
|
|
<strong>{serverName}</strong>
|
|
<span>{historyOpen ? "历史日志(独立于实时终端)" : `当前受管进程会话${liveSessionId ? " · SSE 实时推送" : " · 等待 Run 输出"}`} · {streams.status === "ready" ? "已连接" : streams.status === "loading" ? "连接日志流" : "日志流异常"} · {followLatest ? "自动置底" : "已解锁滚动"}</span>
|
|
</div>
|
|
<div>
|
|
<button type="button" className="terminal-output-action" onClick={clearTerminalBuffer}><Trash2 size={14} /><span>清屏</span></button>
|
|
<button type="button" className="terminal-output-action" onClick={toggleHistory}><History size={14} /><span>{historyOpen ? "实时输出" : "查看历史"}</span></button>
|
|
<button type="button" className="terminal-output-action" aria-label="关闭打开终端" onClick={onClose}><X size={14} /><span>关闭</span></button>
|
|
</div>
|
|
</div>
|
|
<div ref={outputRef} className="terminal-output" role="log" aria-live="polite" onScroll={handleTerminalScroll}>
|
|
{!historyOpen && streams.status === "error" && <div className="terminal-line terminal-line-error terminal-source-system"><time>{formatTerminalServerTime(serverTimeRef.current)}</time><span className="terminal-text">{streams.reason}</span></div>}
|
|
{historyOpen && <HistoryLogView streams={historyStreams} lines={historyLines} selectedStreamId={selectedHistoryStreamId} onSelect={selectHistoryStream} serverTime={serverTimeRef.current} />}
|
|
{!historyOpen && streams.status === "ready" && lines.length === 0 && <div className="terminal-line terminal-line-warn terminal-source-system"><time>{formatTerminalServerTime(serverTimeRef.current)}</time><span className="terminal-text">{liveSessionId ? "当前受管进程会话暂无输出,后续输出会自动追加。" : "当前没有可跟随的受管进程输出;旧日志可从历史查看。"}</span></div>}
|
|
{!historyOpen && lines.map((line) => <div key={line.id} className={terminalLineClassName(line)}><time>{line.at}</time><span className="terminal-text">{line.text}</span></div>)}
|
|
</div>
|
|
</section>
|
|
<section className="terminal-command-dock" aria-label="terminal command controls">
|
|
<div className="terminal-command-dock-header"><span>插件命令通道</span>{result && <ResultBadge status={result.status} label={result.label} />}</div>
|
|
<form className="terminal-command-form" onSubmit={(event) => void submitCommand(event)}>
|
|
<label><span>插件声明的命令</span><small>命令正文由插件解释,平台仅负责授权、排队和转发。</small><input value={command} disabled={pending} placeholder="输入插件命令" onChange={(event) => setCommand(event.target.value)} /></label>
|
|
<button type="submit" className="primary-command" disabled={pending || !command.trim()}>{pending ? "提交中…" : "发送"}</button>
|
|
</form>
|
|
</section>
|
|
</LiveOperationDrawer>
|
|
);
|
|
}
|
|
|
|
interface HistoryLogViewProps {
|
|
streams: LoadState<LogStreamResponse[]>;
|
|
lines: HistoryLineState;
|
|
selectedStreamId: string;
|
|
onSelect: (streamId: string) => Promise<void>;
|
|
serverTime?: string;
|
|
}
|
|
|
|
function HistoryLogView({ streams, lines, selectedStreamId, onSelect, serverTime }: HistoryLogViewProps) {
|
|
if (streams.status === "loading") return <TerminalStatusLine tone="info" label="正在加载历史日志列表。" serverTime={serverTime} />;
|
|
if (streams.status === "error") return <TerminalStatusLine tone="error" label={streams.reason} serverTime={serverTime} />;
|
|
if (streams.data.length === 0) return <TerminalStatusLine tone="warn" label="暂无可查看的历史日志流。" serverTime={serverTime} />;
|
|
return (
|
|
<>
|
|
<div className="terminal-line terminal-line-info terminal-source-system">
|
|
<time>历史</time>
|
|
<span className="terminal-text">
|
|
<select aria-label="选择历史日志流" value={selectedStreamId} onChange={(event) => void onSelect(event.target.value)}>
|
|
<option value="" disabled>选择一个历史日志流</option>
|
|
{streams.data.map((stream) => <option key={stream.id} value={stream.id}>{stream.streamKey} · {stream.updatedAt}</option>)}
|
|
</select>
|
|
</span>
|
|
</div>
|
|
{lines.status === "idle" && <TerminalStatusLine tone="info" label="请选择一个历史日志流。" serverTime={serverTime} />}
|
|
{lines.status === "loading" && <TerminalStatusLine tone="info" label="正在读取所选历史日志。" serverTime={serverTime} />}
|
|
{lines.status === "error" && <TerminalStatusLine tone="error" label={lines.reason} serverTime={serverTime} />}
|
|
{lines.status === "ready" && lines.data.length === 0 && <TerminalStatusLine tone="warn" label="所选历史日志流暂无保留内容。" serverTime={serverTime} />}
|
|
{lines.status === "ready" && lines.data.map((line) => <div key={line.id} className={terminalLineClassName(line)}><time>{line.at}</time><span className="terminal-text">{line.text}</span></div>)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function TerminalStatusLine({ tone, label, serverTime }: { tone: "info" | "warn" | "error"; label: string; serverTime?: string }) {
|
|
return <div className={`terminal-line terminal-line-${tone} terminal-source-system`}><time>{formatTerminalServerTime(serverTime)}</time><span className="terminal-text">{label}</span></div>;
|
|
}
|
|
|
|
function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): TerminalLine {
|
|
return {
|
|
id: `log-${stream.id}-${entry.seq}`,
|
|
tone: "info",
|
|
text: entry.line,
|
|
at: formatTerminalLogTime(entry.timestamp),
|
|
sortKey: Date.parse(entry.timestamp) || Date.now(),
|
|
streamKey: stream.streamKey || stream.source,
|
|
level: entry.level,
|
|
seq: entry.seq
|
|
};
|
|
}
|
|
|
|
function terminalSystemLine(tone: TerminalLine["tone"], text: string, streamKey: string, id = `${streamKey.toLowerCase()}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, serverTime?: string): TerminalLine {
|
|
const sortKey = serverTime ? Date.parse(serverTime) : 0;
|
|
return { id, tone, text, at: formatTerminalServerTime(serverTime), sortKey, streamKey };
|
|
}
|
|
|
|
function terminalLineClassName(line: TerminalLine): string {
|
|
return `terminal-line terminal-line-${line.tone} terminal-source-${terminalSourceClass(line.streamKey || line.level)}`;
|
|
}
|
|
|
|
function terminalSourceClass(value?: string): string {
|
|
const key = (value ?? "").toLowerCase();
|
|
if (key.includes("stderr") || key === "error") return "stderr";
|
|
if (key.includes("stdout")) return "stdout";
|
|
if (key.includes("command")) return "command";
|
|
if (key.includes("platform")) return "platform";
|
|
if (key.includes("system")) return "system";
|
|
return "log";
|
|
}
|
|
|
|
function normalizeLogSessionId(value?: string): string | null {
|
|
const normalized = value?.trim();
|
|
return normalized || null;
|
|
}
|
|
|
|
function eventBelongsToLiveSession(eventSessionId: string | undefined, liveSessionId: string | null | undefined): boolean {
|
|
const normalizedEventSessionId = normalizeLogSessionId(eventSessionId);
|
|
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];
|
|
for (const line of incoming) {
|
|
if (seen.has(line.id)) continue;
|
|
seen.add(line.id);
|
|
merged.push(line);
|
|
}
|
|
return merged.sort(compareTerminalLines).slice(-maxTerminalLines);
|
|
}
|
|
|
|
function compareTerminalLines(a: TerminalLine, b: TerminalLine): number {
|
|
return a.sortKey - b.sortKey || (a.seq ?? 0) - (b.seq ?? 0) || a.id.localeCompare(b.id);
|
|
}
|