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 = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T }; type HistoryLineState = { status: "idle" } | LoadState; 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 = {}; 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 (
); } 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([]); const [streams, setStreams] = useState>({ status: "loading" }); const [followLatest, setFollowLatest] = useState(true); const [liveSessionId, setLiveSessionId] = useState(null); const [historyOpen, setHistoryOpen] = useState(false); const [historyStreams, setHistoryStreams] = useState>({ status: "loading" }); const [historyLines, setHistoryLines] = useState({ status: "idle" }); const [selectedHistoryStreamId, setSelectedHistoryStreamId] = useState(""); const outputRef = useRef(null); const followLatestRef = useRef(true); const serverTimeRef = useRef(undefined); const initialHistoryPendingRef = useRef(false); const liveSessionRef = useRef(undefined); 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; 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]); 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; 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; 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: [] }); lockTerminalFollow(); }); events.addEventListener("log", (event) => { const payload = parseServerLogEvent(event); if (!payload || !eventBelongsToLiveSession(payload.logSessionId, liveSessionRef.current)) return; ready = true; const stream = streamFromServerLogEvent(payload); 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, 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) { 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 { 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 (
{serverName} {historyOpen ? "历史日志(独立于实时终端)" : `当前受管进程会话${liveSessionId ? " · SSE 实时推送" : " · 等待 Run 输出"}`} · {streams.status === "ready" ? "已连接" : streams.status === "loading" ? "连接日志流" : "日志流异常"} · {followLatest ? "自动置底" : "已解锁滚动"}
{!historyOpen && streams.status === "error" &&
{streams.reason}
} {historyOpen && } {!historyOpen && streams.status === "ready" && lines.length === 0 &&
{liveSessionId ? "当前受管进程会话暂无输出,后续输出会自动追加。" : "当前没有可跟随的受管进程输出;旧日志可从历史查看。"}
} {!historyOpen && lines.map((line) =>
{line.text}
)}
插件命令通道{result && }
void submitCommand(event)}>
); } interface HistoryLogViewProps { streams: LoadState; lines: HistoryLineState; selectedStreamId: string; onSelect: (streamId: string) => Promise; serverTime?: string; } function HistoryLogView({ streams, lines, selectedStreamId, onSelect, serverTime }: HistoryLogViewProps) { if (streams.status === "loading") return ; if (streams.status === "error") return ; if (streams.data.length === 0) return ; return ( <>
{lines.status === "idle" && } {lines.status === "loading" && } {lines.status === "error" && } {lines.status === "ready" && lines.data.length === 0 && } {lines.status === "ready" && lines.data.map((line) =>
{line.text}
)} ); } function TerminalStatusLine({ tone, label, serverTime }: { tone: "info" | "warn" | "error"; label: string; serverTime?: string }) { return
{label}
; } 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 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); }