|
|
@@ -6,7 +6,7 @@ import type { JobResponse, LogEntryBody, LogStreamResponse } from "../api/types"
|
|
|
|
import { scumSourceRCONCommandRequest } from "../schemas/scumManagementRcon";
|
|
|
|
import { scumSourceRCONCommandRequest } from "../schemas/scumManagementRcon";
|
|
|
|
import { cx } from "../utils/classes";
|
|
|
|
import { cx } from "../utils/classes";
|
|
|
|
import { mergeLogStreams, parseLogSessionEvent, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents";
|
|
|
|
import { mergeLogStreams, parseLogSessionEvent, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents";
|
|
|
|
import { formatTerminalLogTime } from "../utils/logTime";
|
|
|
|
import { formatTerminalLogTime, formatTerminalServerTime } from "../utils/logTime";
|
|
|
|
import { EmptyState, ResultBadge } from "./StateViews";
|
|
|
|
import { EmptyState, ResultBadge } from "./StateViews";
|
|
|
|
|
|
|
|
|
|
|
|
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
|
|
|
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
|
|
@@ -98,6 +98,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|
|
|
const [selectedHistoryStreamId, setSelectedHistoryStreamId] = useState("");
|
|
|
|
const [selectedHistoryStreamId, setSelectedHistoryStreamId] = useState("");
|
|
|
|
const outputRef = useRef<HTMLDivElement>(null);
|
|
|
|
const outputRef = useRef<HTMLDivElement>(null);
|
|
|
|
const followLatestRef = useRef(true);
|
|
|
|
const followLatestRef = useRef(true);
|
|
|
|
|
|
|
|
const serverTimeRef = useRef<string | undefined>(undefined);
|
|
|
|
const initialHistoryPendingRef = useRef(false);
|
|
|
|
const initialHistoryPendingRef = useRef(false);
|
|
|
|
const liveSessionRef = useRef<string | null | undefined>(undefined);
|
|
|
|
const liveSessionRef = useRef<string | null | undefined>(undefined);
|
|
|
|
const historyRequestRef = useRef(0);
|
|
|
|
const historyRequestRef = useRef(0);
|
|
|
@@ -136,11 +137,12 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|
|
|
setHistoryLines({ status: "idle" });
|
|
|
|
setHistoryLines({ status: "idle" });
|
|
|
|
setSelectedHistoryStreamId("");
|
|
|
|
setSelectedHistoryStreamId("");
|
|
|
|
liveSessionRef.current = undefined;
|
|
|
|
liveSessionRef.current = undefined;
|
|
|
|
|
|
|
|
serverTimeRef.current = undefined;
|
|
|
|
historyRequestRef.current += 1;
|
|
|
|
historyRequestRef.current += 1;
|
|
|
|
initialHistoryPendingRef.current = true;
|
|
|
|
initialHistoryPendingRef.current = true;
|
|
|
|
followLatestRef.current = true;
|
|
|
|
followLatestRef.current = true;
|
|
|
|
setFollowLatest(true);
|
|
|
|
setFollowLatest(true);
|
|
|
|
setLines([terminalSystemLine("info", supportsCommands ? "正在连接当前受管进程输出。" : "该插件暂未声明可用的终端命令通道。", "SYSTEM")]);
|
|
|
|
setLines([terminalSystemLine("info", supportsCommands ? "正在连接当前受管进程输出。" : "该插件暂未声明可用的终端命令通道。", "SYSTEM", undefined, serverTimeRef.current)]);
|
|
|
|
}, [open, supportsCommands]);
|
|
|
|
}, [open, supportsCommands]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
useEffect(() => {
|
|
|
@@ -160,6 +162,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|
|
|
const session = parseLogSessionEvent(event);
|
|
|
|
const session = parseLogSessionEvent(event);
|
|
|
|
if (!session) return;
|
|
|
|
if (!session) return;
|
|
|
|
ready = true;
|
|
|
|
ready = true;
|
|
|
|
|
|
|
|
serverTimeRef.current = session.serverTime;
|
|
|
|
const nextSessionId = normalizeLogSessionId(session.logSessionId);
|
|
|
|
const nextSessionId = normalizeLogSessionId(session.logSessionId);
|
|
|
|
const previousSessionId = liveSessionRef.current;
|
|
|
|
const previousSessionId = liveSessionRef.current;
|
|
|
|
liveSessionRef.current = nextSessionId;
|
|
|
|
liveSessionRef.current = nextSessionId;
|
|
|
@@ -167,8 +170,8 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|
|
|
if (previousSessionId === nextSessionId) return;
|
|
|
|
if (previousSessionId === nextSessionId) return;
|
|
|
|
setStreams({ status: "ready", data: [] });
|
|
|
|
setStreams({ status: "ready", data: [] });
|
|
|
|
setLines(nextSessionId
|
|
|
|
setLines(nextSessionId
|
|
|
|
? [terminalSystemLine("info", previousSessionId === undefined ? "已跟随当前受管进程输出会话。" : "Run 已切换到新的受管进程输出会话。", "SYSTEM", `session-${nextSessionId}`)]
|
|
|
|
? [terminalSystemLine("info", previousSessionId === undefined ? "已跟随当前受管进程输出会话。" : "Run 已切换到新的受管进程输出会话。", "SYSTEM", `session-${nextSessionId}`, serverTimeRef.current)]
|
|
|
|
: [terminalSystemLine("warn", "当前没有可跟随的受管进程输出;旧日志可从历史查看。", "SYSTEM", "session-empty")]);
|
|
|
|
: [terminalSystemLine("warn", "当前没有可跟随的受管进程输出;旧日志可从历史查看。", "SYSTEM", "session-empty", serverTimeRef.current)]);
|
|
|
|
lockTerminalFollow();
|
|
|
|
lockTerminalFollow();
|
|
|
|
});
|
|
|
|
});
|
|
|
|
events.addEventListener("stream", (event) => {
|
|
|
|
events.addEventListener("stream", (event) => {
|
|
|
@@ -284,26 +287,26 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|
|
|
setCommandHistory((current) => [...current.filter((item) => item !== submitted), submitted].slice(-50));
|
|
|
|
setCommandHistory((current) => [...current.filter((item) => item !== submitted), submitted].slice(-50));
|
|
|
|
setPending(true);
|
|
|
|
setPending(true);
|
|
|
|
setResult({ status: "pending", label: "正在提交命令" });
|
|
|
|
setResult({ status: "pending", label: "正在提交命令" });
|
|
|
|
appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND")]);
|
|
|
|
appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND", undefined, serverTimeRef.current)]);
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
const response = await platformApiClient.dispatchSourceRCONCommand(serverId, scumSourceRCONCommandRequest(serverId, submitted));
|
|
|
|
const response = await platformApiClient.dispatchSourceRCONCommand(serverId, scumSourceRCONCommandRequest(serverId, submitted));
|
|
|
|
const label = rconJobDispatchLabel(response.status, response.jobId);
|
|
|
|
const label = rconJobDispatchLabel(response.status, response.jobId);
|
|
|
|
setResult({ status: "pending", label: `${label} · 等待 Run 返回结果` });
|
|
|
|
setResult({ status: "pending", label: `${label} · 等待 Run 返回结果` });
|
|
|
|
appendLines([terminalSystemLine("success", `${label} · Source RCON`, "PLATFORM", `ok-${response.jobId}`)]);
|
|
|
|
appendLines([terminalSystemLine("success", `${label} · Source RCON`, "PLATFORM", `ok-${response.jobId}`, serverTimeRef.current)]);
|
|
|
|
const finalJob = await waitForRCONJobTerminal(response.jobId);
|
|
|
|
const finalJob = await waitForRCONJobTerminal(response.jobId);
|
|
|
|
if (finalJob) {
|
|
|
|
if (finalJob) {
|
|
|
|
const outcome = terminalLineFromJob(finalJob);
|
|
|
|
const outcome = terminalLineFromJob(finalJob);
|
|
|
|
setResult({ status: outcome.tone === "success" ? "succeeded" : "failed", label: outcome.text });
|
|
|
|
setResult({ status: outcome.tone === "success" ? "succeeded" : "failed", label: outcome.text });
|
|
|
|
appendLines([outcome]);
|
|
|
|
appendLines([outcome]);
|
|
|
|
} else {
|
|
|
|
} else {
|
|
|
|
const timeoutLine = terminalSystemLine("warn", `RCON 任务 ${response.jobId} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.jobId}`);
|
|
|
|
const timeoutLine = terminalSystemLine("warn", `RCON 任务 ${response.jobId} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.jobId}`, serverTimeRef.current);
|
|
|
|
setResult({ status: "pending", label: "等待 Run 返回结果" });
|
|
|
|
setResult({ status: "pending", label: "等待 Run 返回结果" });
|
|
|
|
appendLines([timeoutLine]);
|
|
|
|
appendLines([timeoutLine]);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
} catch (error) {
|
|
|
|
const label = error instanceof Error ? error.message : "命令提交失败";
|
|
|
|
const label = error instanceof Error ? error.message : "命令提交失败";
|
|
|
|
setResult({ status: "failed", label });
|
|
|
|
setResult({ status: "failed", label });
|
|
|
|
appendLines([terminalSystemLine("error", label, "ERROR")]);
|
|
|
|
appendLines([terminalSystemLine("error", label, "ERROR", undefined, serverTimeRef.current)]);
|
|
|
|
} finally {
|
|
|
|
} finally {
|
|
|
|
setPending(false);
|
|
|
|
setPending(false);
|
|
|
|
}
|
|
|
|
}
|
|
|
@@ -333,9 +336,9 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div ref={outputRef} className="terminal-output" role="log" aria-live="polite" onScroll={handleTerminalScroll}>
|
|
|
|
<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 && 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} />}
|
|
|
|
{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>{new Date().toLocaleTimeString()}</time><span className="terminal-text">{liveSessionId ? "当前受管进程会话暂无输出,后续输出会自动追加。" : "当前没有可跟随的受管进程输出;旧日志可从历史查看。"}</span></div>}
|
|
|
|
{!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>)}
|
|
|
|
{!historyOpen && lines.map((line) => <div key={line.id} className={terminalLineClassName(line)}><time>{line.at}</time><span className="terminal-text">{line.text}</span></div>)}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</section>
|
|
|
|
</section>
|
|
|
@@ -368,12 +371,13 @@ interface HistoryLogViewProps {
|
|
|
|
lines: HistoryLineState;
|
|
|
|
lines: HistoryLineState;
|
|
|
|
selectedStreamId: string;
|
|
|
|
selectedStreamId: string;
|
|
|
|
onSelect: (streamId: string) => Promise<void>;
|
|
|
|
onSelect: (streamId: string) => Promise<void>;
|
|
|
|
|
|
|
|
serverTime?: string;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function HistoryLogView({ streams, lines, selectedStreamId, onSelect }: HistoryLogViewProps) {
|
|
|
|
function HistoryLogView({ streams, lines, selectedStreamId, onSelect, serverTime }: HistoryLogViewProps) {
|
|
|
|
if (streams.status === "loading") return <TerminalStatusLine tone="info" label="正在加载历史日志列表。" />;
|
|
|
|
if (streams.status === "loading") return <TerminalStatusLine tone="info" label="正在加载历史日志列表。" serverTime={serverTime} />;
|
|
|
|
if (streams.status === "error") return <TerminalStatusLine tone="error" label={streams.reason} />;
|
|
|
|
if (streams.status === "error") return <TerminalStatusLine tone="error" label={streams.reason} serverTime={serverTime} />;
|
|
|
|
if (streams.data.length === 0) return <TerminalStatusLine tone="warn" label="暂无可查看的历史日志流。" />;
|
|
|
|
if (streams.data.length === 0) return <TerminalStatusLine tone="warn" label="暂无可查看的历史日志流。" serverTime={serverTime} />;
|
|
|
|
return (
|
|
|
|
return (
|
|
|
|
<>
|
|
|
|
<>
|
|
|
|
<div className="terminal-line terminal-line-info terminal-source-system">
|
|
|
|
<div className="terminal-line terminal-line-info terminal-source-system">
|
|
|
@@ -385,17 +389,17 @@ function HistoryLogView({ streams, lines, selectedStreamId, onSelect }: HistoryL
|
|
|
|
</select>
|
|
|
|
</select>
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{lines.status === "idle" && <TerminalStatusLine tone="info" label="请选择一个历史日志流。" />}
|
|
|
|
{lines.status === "idle" && <TerminalStatusLine tone="info" label="请选择一个历史日志流。" serverTime={serverTime} />}
|
|
|
|
{lines.status === "loading" && <TerminalStatusLine tone="info" label="正在读取所选历史日志。" />}
|
|
|
|
{lines.status === "loading" && <TerminalStatusLine tone="info" label="正在读取所选历史日志。" serverTime={serverTime} />}
|
|
|
|
{lines.status === "error" && <TerminalStatusLine tone="error" label={lines.reason} />}
|
|
|
|
{lines.status === "error" && <TerminalStatusLine tone="error" label={lines.reason} serverTime={serverTime} />}
|
|
|
|
{lines.status === "ready" && lines.data.length === 0 && <TerminalStatusLine tone="warn" label="所选历史日志流暂无保留内容。" />}
|
|
|
|
{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>)}
|
|
|
|
{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 }) {
|
|
|
|
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>{new Date().toLocaleTimeString()}</time><span className="terminal-text">{label}</span></div>;
|
|
|
|
return <div className={`terminal-line terminal-line-${tone} terminal-source-system`}><time>{formatTerminalServerTime(serverTime)}</time><span className="terminal-text">{label}</span></div>;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] {
|
|
|
|
function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] {
|
|
|
@@ -419,9 +423,9 @@ function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): Te
|
|
|
|
};
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function terminalSystemLine(tone: TerminalLine["tone"], text: string, streamKey: string, id = `${streamKey.toLowerCase()}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`): TerminalLine {
|
|
|
|
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 now = Date.now();
|
|
|
|
const sortKey = serverTime ? Date.parse(serverTime) : 0;
|
|
|
|
return { id, tone, text, at: new Date(now).toLocaleTimeString(), sortKey: now, streamKey };
|
|
|
|
return { id, tone, text, at: formatTerminalServerTime(serverTime), sortKey, streamKey };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function terminalLineClassName(line: TerminalLine): string {
|
|
|
|
function terminalLineClassName(line: TerminalLine): string {
|
|
|
@@ -457,7 +461,7 @@ function terminalLineFromJob(job: JobResponse): TerminalLine {
|
|
|
|
const completed = job.updatedAt;
|
|
|
|
const completed = job.updatedAt;
|
|
|
|
const sortKey = Date.parse(completed) || Date.now();
|
|
|
|
const sortKey = Date.parse(completed) || Date.now();
|
|
|
|
const tone: TerminalLine["tone"] = job.state === "succeeded" ? "success" : job.state === "failed" ? "error" : "warn";
|
|
|
|
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" };
|
|
|
|
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 {
|
|
|
|
function jobStateLabel(state: JobResponse["state"]): string {
|
|
|
|