import { History, ListChecks, Send, Sparkles, Terminal, Trash2, X } from "lucide-react"; import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, 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 { EmptyState, 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 }; type TerminalQuickCommand = { label: string; command: string; hint: string }; const terminalJobResultPollMs = 1000; const terminalJobResultPollAttempts = 30; const terminalHistoryWindow = 500; const maxTerminalLines = 10000; const terminalQuickCommandCatalog: Record = { "game.scum": [ { label: "查询玩家", command: "#ListPlayers", hint: "在线玩家列表" }, { label: "查询队伍", command: "#ListSquads 1", hint: "服务器队伍列表" }, { label: "查询车辆", command: "#ListSpawnedVehicles true", hint: "已生成车辆列表" }, { label: "设为中午", command: "#SetTime 12", hint: "设置游戏时间" } ] }; 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; pluginId: string; canManage: boolean; onClose: () => void; } export function ServerManagementTerminalDrawer({ open, serverId, serverName, pluginId, canManage, onClose }: ServerManagementTerminalDrawerProps) { const [command, setCommand] = useState(""); const [pending, setPending] = useState(false); const [lines, setLines] = useState([]); const [streams, setStreams] = useState>({ status: "loading" }); const [commandHistory, setCommandHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(null); const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null); 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 quickCommands = useMemo(() => terminalQuickCommandsForPlugin(pluginId), [pluginId]); const supportsCommands = quickCommands.length > 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); setHistoryIndex(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", supportsCommands ? "正在连接当前受管进程输出。" : "该插件暂未声明可用的终端命令通道。", "SYSTEM", undefined, serverTimeRef.current)]); }, [open, supportsCommands]); 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((current) => mergeTerminalLines(current, [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 selectQuickCommand(item: TerminalQuickCommand) { setCommand(item.command); setHistoryIndex(null); } function handleCommandKeyDown(event: ReactKeyboardEvent) { if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; if (commandHistory.length === 0) return; event.preventDefault(); const lastIndex = commandHistory.length - 1; if (event.key === "ArrowUp") { const nextIndex = historyIndex === null ? lastIndex : Math.max(0, historyIndex - 1); setHistoryIndex(nextIndex); setCommand(commandHistory[nextIndex] ?? ""); return; } if (historyIndex === null) return; const nextIndex = historyIndex + 1; if (nextIndex > lastIndex) { setHistoryIndex(null); setCommand(""); return; } setHistoryIndex(nextIndex); setCommand(commandHistory[nextIndex] ?? ""); } 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 (!supportsCommands || !canManage || pending || !command.trim()) return; const submitted = command.trim(); setCommand(""); setHistoryIndex(null); setCommandHistory((current) => [...current.filter((item) => item !== submitted), submitted].slice(-50)); setPending(true); setResult({ status: "pending", label: "正在提交命令" }); appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND", undefined, serverTimeRef.current)]); try { const response = await platformApiClient.dispatchSourceRCONCommand(serverId, scumSourceRCONCommandRequest(serverId, submitted)); const label = rconJobDispatchLabel(response.status, response.jobId); setResult({ status: "pending", label: `${label} · 等待 Run 返回结果` }); appendLines([terminalSystemLine("success", `${label} · Source RCON`, "PLATFORM", `ok-${response.jobId}`, serverTimeRef.current)]); const finalJob = await waitForRCONJobTerminal(response.jobId); if (finalJob) { const outcome = terminalLineFromJob(finalJob); setResult({ status: outcome.tone === "success" ? "succeeded" : "failed", label: outcome.text }); appendLines([outcome]); } else { const timeoutLine = terminalSystemLine("warn", `RCON 任务 ${response.jobId} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.jobId}`, serverTimeRef.current); setResult({ status: "pending", label: "等待 Run 返回结果" }); appendLines([timeoutLine]); } } catch (error) { const label = error instanceof Error ? error.message : "命令提交失败"; setResult({ status: "failed", label }); appendLines([terminalSystemLine("error", label, "ERROR", undefined, serverTimeRef.current)]); } finally { setPending(false); } } async function waitForRCONJobTerminal(jobId: string): Promise { for (let attempt = 0; attempt < terminalJobResultPollAttempts; attempt += 1) { const current = await platformApiClient.getJob(jobId); if (isTerminalJobState(current.state)) return current; await delay(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}
)}
{quickCommands.length > 0 ? "插件快捷指令" : "命令输入"} {result && }
{quickCommands.length > 0 &&
{quickCommands.map((item) => )}
} {!supportsCommands && } title="终端不可用" description="当前插件没有声明平台可调度的即时命令能力,因此不会开放浏览器直连 shell。" />} {supportsCommands && (
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 terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] { return terminalQuickCommandCatalog[pluginId] ?? []; } function rconJobDispatchLabel(state: string, jobId: string): string { return `已${state === "queued" ? "排队" : "提交"} · RCON 任务 ${jobId}`; } function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): TerminalLine { return { id: `log-${stream.id}-${entry.seq}`, tone: terminalTone(entry), text: entry.line, at: formatTerminalLogTime(entry.timestamp, entry.line), 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 isTerminalJobState(state: JobResponse["state"]): boolean { return state === "succeeded" || state === "failed" || state === "cancelled"; } function terminalLineFromJob(job: JobResponse): TerminalLine { const summary = job.progress.message || job.executionResult?.summary || job.cancelReason || jobStateLabel(job.state); const completed = job.updatedAt; const sortKey = Date.parse(completed) || Date.now(); const tone: TerminalLine["tone"] = job.state === "succeeded" ? "success" : job.state === "failed" ? "error" : "warn"; return { id: `rcon-job-${job.id}-${job.state}`, tone, text: `RCON 任务 ${job.id} · ${jobStateLabel(job.state)} · ${summary}`, at: formatTerminalServerTime(job.updatedAt), sortKey, streamKey: "PLATFORM" }; } function jobStateLabel(state: JobResponse["state"]): string { switch (state) { case "succeeded": return "已成功"; case "failed": return "已失败"; case "cancelled": return "已取消"; case "accepted": return "Run 已领取"; case "running": return "运行中"; case "retrying": return "等待重试"; case "queued": return "已排队"; } } function delay(ms: number): Promise { return new Promise((resolve) => window.setTimeout(resolve, ms)); } function terminalTone(entry: LogEntryBody): TerminalLine["tone"] { const value = `${entry.level ?? ""} ${entry.line}`.toLowerCase(); if (/\b(error|fatal|panic|exception|failed|failure)\b/.test(value)) return "error"; if (/\b(warn|warning|timeout|retry)\b/.test(value)) return "warn"; if (/\b(success|succeeded|ready|started|online|listening|accepted)\b/.test(value)) return "success"; return "info"; } 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); }