import { ListChecks, Pause, Play, RotateCw, Send, Sparkles, Terminal, Trash2, X } from "lucide-react"; import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { platformApiClient } from "../api/client"; import type { LogEntryBody, LogStreamResponse } from "../api/types"; import { sourceRCONRawCommandRequest } from "../schemas/sourceRcon"; import { cx } from "../utils/classes"; import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews"; type LoadState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T }; type LiveLogEntry = LogEntryBody & { source: string; streamId: string; streamKey: string }; 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 liveLogPollMs = 1000; const terminalLogPollMs = 1000; const logStreamPollMs = 5000; const liveLogHistoryWindow = 100; const terminalLogQueryLimit = 150; const terminalHistoryWindow = 150; const maxLogEntries = 500; const maxTerminalLines = 600; const terminalQuickCommandCatalog: Record = { "game.scum": [ { label: "查询玩家", command: "ListPlayers", hint: "SCUM RCON" }, { label: "服务器信息", command: "ServerInfo", hint: "SCUM RCON" }, { label: "设为中午", command: "SetTime 12", hint: "SCUM RCON" } ] }; 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 ServerLiveLogDrawerProps { open: boolean; serverId: string; serverName: string; onClose: () => void; } export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: ServerLiveLogDrawerProps) { const [streams, setStreams] = useState>({ status: "loading" }); const [selectedStreamId, setSelectedStreamId] = useState(""); const [entries, setEntries] = useState([]); const [cursorByStream, setCursorByStream] = useState>({}); const [paused, setPaused] = useState(false); const [keyword, setKeyword] = useState(""); const [lastRefreshAt, setLastRefreshAt] = useState(""); const loadStreams = useCallback(async (showLoading = true) => { if (!open) return; if (showLoading) setStreams({ status: "loading" }); try { const response = await platformApiClient.listServerLiveLogs(serverId); setStreams({ status: "ready", data: response.items }); setSelectedStreamId((current) => response.items.some((stream) => stream.id === current) ? current : response.items[0]?.id ?? ""); } catch (error) { setStreams({ status: "error", reason: error instanceof Error ? error.message : "实时日志源加载失败" }); } }, [open, serverId]); useEffect(() => { if (!open) return; setEntries([]); setCursorByStream({}); setPaused(false); void loadStreams(); }, [loadStreams, open]); useEffect(() => { if (!open) return undefined; const timer = window.setInterval(() => void loadStreams(false).catch(() => undefined), logStreamPollMs); return () => window.clearInterval(timer); }, [loadStreams, open]); const selectedStream = streams.status === "ready" ? streams.data.find((stream) => stream.id === selectedStreamId) : undefined; const tailSelectedStream = useCallback(async () => { if (!open || !selectedStream) return; const afterSeq = cursorByStream[selectedStream.id] ?? initialLogCursor(selectedStream, liveLogHistoryWindow); const cursor = await platformApiClient.queryLogStream({ logStreamId: selectedStream.id, afterSeq, limit: 100 }); const nextSeq = nextCursorSeq(afterSeq, cursor.entries, cursor.nextSeq); setCursorByStream((current) => updateCursor(current, selectedStream.id, nextSeq)); setLastRefreshAt(new Date().toLocaleTimeString()); if (cursor.entries.length === 0) return; setEntries((current) => [ ...current, ...cursor.entries.map((entry) => ({ ...entry, source: selectedStream.source || selectedStream.streamKey, streamId: selectedStream.id, streamKey: selectedStream.streamKey })) ].slice(-maxLogEntries)); }, [cursorByStream, open, selectedStream]); useEffect(() => { if (!open || paused || !selectedStream) return undefined; void tailSelectedStream().catch(() => undefined); const timer = window.setInterval(() => void tailSelectedStream().catch(() => undefined), liveLogPollMs); return () => window.clearInterval(timer); }, [open, paused, selectedStream, tailSelectedStream]); const visibleEntries = useMemo(() => { const query = keyword.trim().toLowerCase(); return entries.filter((entry) => entry.streamId === selectedStreamId && (!query || entry.line.toLowerCase().includes(query) || (entry.level ?? "info").toLowerCase().includes(query))); }, [entries, keyword, selectedStreamId]); function clearVisibleBuffer() { setEntries((current) => current.filter((entry) => entry.streamId !== selectedStreamId)); if (selectedStream) setCursorByStream((current) => ({ ...current, [selectedStream.id]: selectedStream.latestSeq })); } function selectLogStream(nextStreamId: string) { setSelectedStreamId(nextStreamId); setEntries([]); setCursorByStream((current) => { const next = { ...current }; delete next[nextStreamId]; return next; }); } return (
setKeyword(event.target.value)} />
状态:{paused ? "已暂停" : "自动刷新"} · 最新刷新 {lastRefreshAt || "等待"} · 游标 {selectedStream ? cursorByStream[selectedStream.id] ?? 0 : "--"} {streams.status === "loading" && } {streams.status === "error" && void loadStreams()} compact />} {streams.status === "ready" && streams.data.length === 0 && } {streams.status === "ready" && streams.data.length > 0 && visibleEntries.length === 0 && } {visibleEntries.length > 0 && (
{visibleEntries.map((entry) => (
{(entry.level ?? "info").toUpperCase()} {entry.line}
))}
)}
); } 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 [cursorByStream, setCursorByStream] = useState>({}); const [commandHistory, setCommandHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(null); const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null); const quickCommands = useMemo(() => terminalQuickCommandsForPlugin(pluginId), [pluginId]); const supportsCommands = quickCommands.length > 0; const terminalStreams = useMemo(() => streams.status === "ready" ? terminalRelevantStreams(streams.data) : [], [streams]); const appendLines = useCallback((incoming: TerminalLine[]) => { if (incoming.length === 0) return; setLines((current) => mergeTerminalLines(current, incoming)); }, []); const loadStreams = useCallback(async (showLoading = true) => { if (!open) return; if (showLoading) setStreams({ status: "loading" }); try { const response = await platformApiClient.listServerLiveLogs(serverId); setStreams({ status: "ready", data: response.items }); } catch (error) { setStreams({ status: "error", reason: error instanceof Error ? error.message : "实时日志源加载失败" }); } }, [open, serverId]); const tailTerminalLogs = useCallback(async (targetStreams = terminalStreams) => { if (!open || targetStreams.length === 0) return; const cursorUpdates: Record = {}; const batches = await Promise.all(targetStreams.map(async (stream) => { const afterSeq = cursorByStream[stream.id] ?? initialLogCursor(stream, terminalHistoryWindow); const cursor = await platformApiClient.queryLogStream({ logStreamId: stream.id, afterSeq, limit: terminalLogQueryLimit }); cursorUpdates[stream.id] = nextCursorSeq(afterSeq, cursor.entries, cursor.nextSeq); return cursor.entries.map((entry) => terminalLineFromLog(stream, entry)); })); setCursorByStream((current) => { let changed = false; const next = { ...current }; for (const [streamId, cursor] of Object.entries(cursorUpdates)) { const nextCursor = Math.max(next[streamId] ?? 0, cursor); if (nextCursor !== next[streamId]) { next[streamId] = nextCursor; changed = true; } } return changed ? next : current; }); appendLines(batches.flat().sort(compareTerminalLines)); }, [appendLines, cursorByStream, open, terminalStreams]); useEffect(() => { if (!open) return; setCommand(""); setPending(false); setResult(null); setCursorByStream({}); setHistoryIndex(null); setLines([terminalSystemLine("info", supportsCommands ? "读取平台历史日志,后续按游标实时追加。" : "该插件暂未声明可用的管理终端命令通道。", "SYSTEM")]); void loadStreams(); }, [loadStreams, open, supportsCommands]); useEffect(() => { if (!open) return undefined; const timer = window.setInterval(() => void loadStreams(false).catch(() => undefined), logStreamPollMs); return () => window.clearInterval(timer); }, [loadStreams, open]); useEffect(() => { if (!open || streams.status !== "ready") return; void tailTerminalLogs(terminalRelevantStreams(streams.data)).catch((error) => appendLines([terminalSystemLine("error", error instanceof Error ? error.message : "历史日志读取失败", "LOGS")])); }, [appendLines, open, streams]); useEffect(() => { if (!open || terminalStreams.length === 0) return undefined; const timer = window.setInterval(() => void tailTerminalLogs().catch(() => undefined), terminalLogPollMs); return () => window.clearInterval(timer); }, [open, tailTerminalLogs, terminalStreams]); 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() { setLines([]); } 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")]); try { const response = await platformApiClient.sendSourceRCONCommand(serverId, sourceRCONRawCommandRequest(serverId, submitted)); const label = `已排队 · 任务 ${response.jobId}`; setResult({ status: "succeeded", label }); appendLines([terminalSystemLine("success", `${label} · ${response.message || response.status}`, "PLATFORM", `ok-${response.jobId}`)]); void tailTerminalLogs().catch(() => undefined); } catch (error) { const label = error instanceof Error ? error.message : "命令提交失败"; setResult({ status: "failed", label }); appendLines([terminalSystemLine("error", label, "ERROR")]); } finally { setPending(false); } } return (
{serverName} 最近历史 + {terminalLogPollMs / 1000}s 实时刷新 · 日志源 {logStreamPollMs / 1000}s 探测 · {streams.status === "ready" ? `${terminalStreams.length} 个日志源` : streams.status === "loading" ? "读取日志源" : "日志源异常"}
{streams.status === "error" &&
LOGS{streams.reason}
} {streams.status === "ready" && streams.data.length === 0 &&
LOGS暂无日志源。需要 Run 上报或历史日志回填后,这里才会持续追加。
} {lines.map((line) =>
{line.streamKey || line.level || "LOG"}{line.text}
)}
{quickCommands.length > 0 ? "插件快捷指令" : "命令输入"} {result && }
{quickCommands.length > 0 &&
{quickCommands.map((item) => )}
} {!supportsCommands && } title="终端不可用" description="当前插件没有声明平台可调度的即时命令能力,因此不会开放浏览器直连 shell。" />} {supportsCommands && (
void submitCommand(event)}>
)}
); } function levelClass(level?: string): string { const normalized = (level ?? "info").toLowerCase(); if (normalized === "error" || normalized === "fatal") return "log-level-error"; if (normalized === "warn" || normalized === "warning") return "log-level-warn"; return "log-level-info"; } function initialLogCursor(stream: LogStreamResponse, historyWindow: number): number { return Math.max(0, stream.latestSeq - historyWindow); } function nextCursorSeq(afterSeq: number, entries: LogEntryBody[], nextSeq: number): number { if (entries.length === 0) return Math.max(afterSeq, nextSeq); return Math.max(afterSeq, nextSeq, entries[entries.length - 1]?.seq ?? afterSeq); } function updateCursor(current: Record, streamId: string, cursor: number): Record { const nextCursor = Math.max(current[streamId] ?? 0, cursor); if (nextCursor === current[streamId]) return current; return { ...current, [streamId]: nextCursor }; } function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] { return terminalQuickCommandCatalog[pluginId] ?? []; } function terminalRelevantStreams(streams: LogStreamResponse[]): LogStreamResponse[] { return [...streams].sort(compareTerminalStreams).slice(0, 8); } function compareTerminalStreams(a: LogStreamResponse, b: LogStreamResponse): number { return terminalStreamRank(a) - terminalStreamRank(b) || a.streamKey.localeCompare(b.streamKey) || a.id.localeCompare(b.id); } function terminalStreamRank(stream: LogStreamResponse): number { const key = `${stream.source}:${stream.streamKey}`.toLowerCase(); if (key.includes("management-program")) return 0; if (key.includes("stderr")) return 1; if (key.includes("stdout")) return 2; return 3; } function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): TerminalLine { return { id: `log-${stream.id}-${entry.seq}`, tone: terminalTone(entry), text: entry.line, at: new Date(entry.timestamp).toLocaleTimeString(), 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)}`): TerminalLine { const now = Date.now(); return { id, tone, text, at: new Date(now).toLocaleTimeString(), sortKey: now, streamKey }; } 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); }