Files
browser/platform_web/components/ServerManagementTerminalDrawer.tsx
T

501 lines
25 KiB
TypeScript

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 } from "../utils/logTime";
import { EmptyState, 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 };
type TerminalQuickCommand = { label: string; command: string; hint: string };
const terminalJobResultPollMs = 1000;
const terminalJobResultPollAttempts = 30;
const terminalInitialHistoryWindow = 500;
const maxTerminalLines = 10000;
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
"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 (
<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;
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<TerminalLine[]>([]);
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
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 initialHistoryPendingRef = useRef(false);
const liveSessionRef = useRef<string | null | undefined>(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;
historyRequestRef.current += 1;
initialHistoryPendingRef.current = true;
followLatestRef.current = true;
setFollowLatest(true);
setLines([terminalSystemLine("info", supportsCommands ? "正在连接当前受管进程输出。" : "该插件暂未声明可用的终端命令通道。", "SYSTEM")]);
}, [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, { historyLimit: terminalInitialHistoryWindow });
events.addEventListener("session", (event) => {
const session = parseLogSessionEvent(event);
if (!session) return;
ready = true;
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}`)]
: [terminalSystemLine("warn", "当前没有可跟随的受管进程输出;旧日志可从历史查看。", "SYSTEM", "session-empty")]);
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 - terminalInitialHistoryWindow), limit: terminalInitialHistoryWindow });
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<HTMLInputElement>) {
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<HTMLFormElement>) {
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.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}`)]);
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}`);
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")]);
} 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 (isTerminalJobState(current.state)) return current;
await delay(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>{new Date().toLocaleTimeString()}</time><span className="terminal-text">{streams.reason}</span></div>}
{historyOpen && <HistoryLogView streams={historyStreams} lines={historyLines} selectedStreamId={selectedHistoryStreamId} onSelect={selectHistoryStream} />}
{!historyOpen && streams.status === "ready" && lines.length === 0 && <div className="terminal-line terminal-line-warn terminal-source-system"><time>{new Date().toLocaleTimeString()}</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><ListChecks size={14} />{quickCommands.length > 0 ? "插件快捷指令" : "命令输入"}</span>
{result && <ResultBadge status={result.status} label={result.label} />}
</div>
{quickCommands.length > 0 && <div className="terminal-quick-command-list">
{quickCommands.map((item) => <button key={item.command} type="button" className="terminal-quick-command" disabled={!canManage || pending} onClick={() => selectQuickCommand(item)}><strong>{item.label}</strong><span>{item.command}</span><small>{item.hint}</small></button>)}
</div>}
{!supportsCommands && <EmptyState icon={<Terminal size={24} />} title="终端不可用" description="当前插件没有声明平台可调度的即时命令能力,因此不会开放浏览器直连 shell。" />}
{supportsCommands && (
<form className="terminal-command-form" onSubmit={(event) => void submitCommand(event)}>
<label>
<span>SCUM 管理命令</span>
<small>Enter 发送,↑/ 调出历史;实时日志来自受管进程输出。</small>
<input value={command} disabled={!canManage || pending} placeholder={canManage ? "输入单行命令" : "当前账号没有运行操作权限"} onKeyDown={handleCommandKeyDown} onChange={(event) => { setCommand(event.target.value); setHistoryIndex(null); }} />
</label>
<button type="submit" className="primary-command" disabled={!canManage || pending || !command.trim()}>{pending ? <Sparkles size={15} /> : <Send size={15} />}<span>{pending ? "提交中…" : "发送"}</span></button>
</form>
)}
</section>
</LiveOperationDrawer>
);
}
interface HistoryLogViewProps {
streams: LoadState<LogStreamResponse[]>;
lines: HistoryLineState;
selectedStreamId: string;
onSelect: (streamId: string) => Promise<void>;
}
function HistoryLogView({ streams, lines, selectedStreamId, onSelect }: HistoryLogViewProps) {
if (streams.status === "loading") return <TerminalStatusLine tone="info" label="正在加载历史日志列表。" />;
if (streams.status === "error") return <TerminalStatusLine tone="error" label={streams.reason} />;
if (streams.data.length === 0) return <TerminalStatusLine tone="warn" label="暂无可查看的历史日志流。" />;
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="请选择一个历史日志流。" />}
{lines.status === "loading" && <TerminalStatusLine tone="info" label="正在读取所选历史日志。" />}
{lines.status === "error" && <TerminalStatusLine tone="error" label={lines.reason} />}
{lines.status === "ready" && lines.data.length === 0 && <TerminalStatusLine tone="warn" label="所选历史日志流暂无保留内容。" />}
{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 }: { tone: "info" | "warn" | "error"; label: string }) {
return <div className={`terminal-line terminal-line-${tone} terminal-source-system`}><time>{new Date().toLocaleTimeString()}</time><span className="terminal-text">{label}</span></div>;
}
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)}`): TerminalLine {
const now = Date.now();
return { id, tone, text, at: new Date(now).toLocaleTimeString(), sortKey: now, 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: new Date(sortKey).toLocaleTimeString(), 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<void> {
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);
}