Stop inspecting game log content in web terminal

This commit is contained in:
npc0-hue
2026-09-04 14:40:09 +08:00
parent ba36668668
commit 8dad79011a
5 changed files with 38 additions and 146 deletions
@@ -209,7 +209,7 @@ async function renderDrawer() {
document.body.append(container); document.body.append(container);
root = createRoot(container); root = createRoot(container);
await act(async () => { await act(async () => {
root?.render(<ServerManagementTerminalDrawer open serverId="server-1" serverName="SCUM Alpha" pluginId="game.scum" canManage onClose={() => undefined} />); root?.render(<ServerManagementTerminalDrawer open serverId="server-1" serverName="SCUM Alpha" onClose={() => undefined} />);
}); });
expect(apiMocks.openServerLogEvents).toHaveBeenCalledWith("server-1"); expect(apiMocks.openServerLogEvents).toHaveBeenCalledWith("server-1");
} }
@@ -1,5 +1,5 @@
import { History, ListChecks, Send, Sparkles, Terminal, Trash2, X } from "lucide-react"; import { History, Trash2, X } from "lucide-react";
import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type FormEvent, type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { platformApiClient } from "../api/client"; import { platformApiClient } from "../api/client";
import type { JobResponse, LogEntryBody, LogStreamResponse } from "../api/types"; import type { JobResponse, LogEntryBody, LogStreamResponse } from "../api/types";
@@ -7,25 +7,23 @@ 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, formatTerminalServerTime } from "../utils/logTime"; import { formatTerminalLogTime, formatTerminalServerTime } from "../utils/logTime";
import { EmptyState, ResultBadge } from "./StateViews"; import { 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 };
type HistoryLineState = { status: "idle" } | LoadState<TerminalLine[]>; 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 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 terminalHistoryWindow = 500;
const maxTerminalLines = 10000; const maxTerminalLines = 10000;
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = { const terminalJobResultPollMs = 1000;
"game.scum": [ const terminalJobResultPollAttempts = 30;
{ label: "查询玩家", command: "#ListPlayers", hint: "在线玩家列表" }, // Game command catalogs are plugin-owned; this extension point intentionally
{ label: "查询队伍", command: "#ListSquads 1", hint: "服务器队伍列表" }, // contains no platform-provided game commands.
{ label: "查询车辆", command: "#ListSpawnedVehicles true", hint: "已生成车辆列表" }, const terminalQuickCommandCatalog: Record<string, never[]> = {};
{ label: "设为中午", command: "#SetTime 12", hint: "设置游戏时间" }
] function terminalQuickCommandsForPlugin(pluginId: string): never[] {
}; return terminalQuickCommandCatalog[pluginId] ?? [];
}
interface LiveOperationDrawerProps { interface LiveOperationDrawerProps {
open: boolean; open: boolean;
@@ -77,19 +75,15 @@ interface ServerManagementTerminalDrawerProps {
open: boolean; open: boolean;
serverId: string; serverId: string;
serverName: string; serverName: string;
pluginId: string;
canManage: boolean;
onClose: () => void; onClose: () => void;
} }
export function ServerManagementTerminalDrawer({ open, serverId, serverName, pluginId, canManage, onClose }: ServerManagementTerminalDrawerProps) { export function ServerManagementTerminalDrawer({ open, serverId, serverName, onClose }: ServerManagementTerminalDrawerProps) {
const [command, setCommand] = useState(""); const [command, setCommand] = useState("");
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
const [lines, setLines] = useState<TerminalLine[]>([]); const [lines, setLines] = useState<TerminalLine[]>([]);
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" }); 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 [followLatest, setFollowLatest] = useState(true);
const [liveSessionId, setLiveSessionId] = useState<string | null>(null); const [liveSessionId, setLiveSessionId] = useState<string | null>(null);
const [historyOpen, setHistoryOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false);
@@ -102,8 +96,6 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
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);
const quickCommands = useMemo(() => terminalQuickCommandsForPlugin(pluginId), [pluginId]);
const supportsCommands = quickCommands.length > 0;
const appendLines = useCallback((incoming: TerminalLine[]) => { const appendLines = useCallback((incoming: TerminalLine[]) => {
if (incoming.length === 0) return; if (incoming.length === 0) return;
@@ -130,7 +122,6 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
setCommand(""); setCommand("");
setPending(false); setPending(false);
setResult(null); setResult(null);
setHistoryIndex(null);
setLiveSessionId(null); setLiveSessionId(null);
setHistoryOpen(false); setHistoryOpen(false);
setHistoryStreams({ status: "loading" }); setHistoryStreams({ status: "loading" });
@@ -142,8 +133,8 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
initialHistoryPendingRef.current = true; initialHistoryPendingRef.current = true;
followLatestRef.current = true; followLatestRef.current = true;
setFollowLatest(true); setFollowLatest(true);
setLines([terminalSystemLine("info", supportsCommands ? "正在连接当前受管进程输出。" : "该插件暂未声明可用的终端命令通道。", "SYSTEM", undefined, serverTimeRef.current)]); setLines([terminalSystemLine("info", "正在连接当前受管进程输出。", "SYSTEM", undefined, serverTimeRef.current)]);
}, [open, supportsCommands]); }, [open]);
useEffect(() => { useEffect(() => {
if (!open || initialHistoryPendingRef.current || !followLatestRef.current) return undefined; if (!open || initialHistoryPendingRef.current || !followLatestRef.current) return undefined;
@@ -228,33 +219,6 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
} }
} }
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() { function clearTerminalBuffer() {
if (historyOpen) { if (historyOpen) {
setHistoryLines({ status: "ready", data: [] }); setHistoryLines({ status: "ready", data: [] });
@@ -280,33 +244,19 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
async function submitCommand(event: FormEvent<HTMLFormElement>) { async function submitCommand(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!supportsCommands || !canManage || pending || !command.trim()) return; if (pending || !command.trim()) return;
const submitted = command.trim(); const submitted = command.trim();
setCommand(""); setCommand("");
setHistoryIndex(null);
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", 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 = `${response.status === "queued" ? "排队" : "提交"} · RCON 任务 ${response.jobId}`;
setResult({ status: "pending", label: `${label} · 等待 Run 返回结果` }); setResult({ status: "pending", label });
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) setResult({ status: finalJob.state === "succeeded" ? "succeeded" : "failed", label: finalJob.progress.message || finalJob.executionResult?.summary || finalJob.state });
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) { } catch (error) {
const label = error instanceof Error ? error.message : "命令提交失败"; setResult({ status: "failed", label: error instanceof Error ? error.message : "命令提交失败" });
setResult({ status: "failed", label });
appendLines([terminalSystemLine("error", label, "ERROR", undefined, serverTimeRef.current)]);
} finally { } finally {
setPending(false); setPending(false);
} }
@@ -315,8 +265,8 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
async function waitForRCONJobTerminal(jobId: string): Promise<JobResponse | null> { async function waitForRCONJobTerminal(jobId: string): Promise<JobResponse | null> {
for (let attempt = 0; attempt < terminalJobResultPollAttempts; attempt += 1) { for (let attempt = 0; attempt < terminalJobResultPollAttempts; attempt += 1) {
const current = await platformApiClient.getJob(jobId); const current = await platformApiClient.getJob(jobId);
if (isTerminalJobState(current.state)) return current; if (current.state === "succeeded" || current.state === "failed" || current.state === "cancelled") return current;
await delay(terminalJobResultPollMs); await new Promise((resolve) => window.setTimeout(resolve, terminalJobResultPollMs));
} }
return null; return null;
} }
@@ -340,27 +290,14 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
{historyOpen && <HistoryLogView streams={historyStreams} lines={historyLines} selectedStreamId={selectedHistoryStreamId} onSelect={selectHistoryStream} serverTime={serverTimeRef.current} />} {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>{formatTerminalServerTime(serverTimeRef.current)}</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>
<section className="terminal-command-dock" aria-label="terminal command controls"> <section className="terminal-command-dock" aria-label="terminal command controls">
<div className="terminal-command-dock-header"> <div className="terminal-command-dock-header"><span></span>{result && <ResultBadge status={result.status} label={result.label} />}</div>
<span><ListChecks size={14} />{quickCommands.length > 0 ? "插件快捷指令" : "命令输入"}</span> <form className="terminal-command-form" onSubmit={(event) => void submitCommand(event)}>
{result && <ResultBadge status={result.status} label={result.label} />} <label><span></span><small></small><input value={command} disabled={pending} placeholder="输入插件命令" onChange={(event) => setCommand(event.target.value)} /></label>
</div> <button type="submit" className="primary-command" disabled={pending || !command.trim()}>{pending ? "提交中…" : "发送"}</button>
{quickCommands.length > 0 && <div className="terminal-quick-command-list"> </form>
{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> </section>
</LiveOperationDrawer> </LiveOperationDrawer>
); );
@@ -402,20 +339,12 @@ function TerminalStatusLine({ tone, label, serverTime }: { tone: "info" | "warn"
return <div className={`terminal-line terminal-line-${tone} terminal-source-system`}><time>{formatTerminalServerTime(serverTime)}</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[] {
return terminalQuickCommandCatalog[pluginId] ?? [];
}
function rconJobDispatchLabel(state: string, jobId: string): string {
return `${state === "queued" ? "排队" : "提交"} · RCON 任务 ${jobId}`;
}
function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): TerminalLine { function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): TerminalLine {
return { return {
id: `log-${stream.id}-${entry.seq}`, id: `log-${stream.id}-${entry.seq}`,
tone: terminalTone(entry), tone: terminalTone(entry),
text: entry.line, text: entry.line,
at: formatTerminalLogTime(entry.timestamp, entry.line), at: formatTerminalLogTime(entry.timestamp),
sortKey: Date.parse(entry.timestamp) || Date.now(), sortKey: Date.parse(entry.timestamp) || Date.now(),
streamKey: stream.streamKey || stream.source, streamKey: stream.streamKey || stream.source,
level: entry.level, level: entry.level,
@@ -452,39 +381,10 @@ function eventBelongsToLiveSession(eventSessionId: string | undefined, liveSessi
return Boolean(normalizedEventSessionId && normalizedEventSessionId === liveSessionId); 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<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function terminalTone(entry: LogEntryBody): TerminalLine["tone"] { function terminalTone(entry: LogEntryBody): TerminalLine["tone"] {
const value = `${entry.level ?? ""} ${entry.line}`.toLowerCase(); // Log bodies are plugin-owned opaque content. The platform/browser must not
if (/\b(error|fatal|panic|exception|failed|failure)\b/.test(value)) return "error"; // inspect text to infer severity or derive a typed result from it.
if (/\b(warn|warning|timeout|retry)\b/.test(value)) return "warn"; void entry;
if (/\b(success|succeeded|ready|started|online|listening|accepted)\b/.test(value)) return "success";
return "info"; return "info";
} }
+1 -1
View File
@@ -253,7 +253,7 @@ export function ServerDetailPage(props: PageComponentProps) {
{section === "manage" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />} {section === "manage" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
{section === "files" && <ServerFilesSection instance={instance.data} session={session} operations={operations} />} {section === "files" && <ServerFilesSection instance={instance.data} session={session} operations={operations} />}
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />} {section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
<ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} pluginId={instance.data.pluginId} canManage={canManageServers} onClose={() => setTerminalOpen(false)} /> <ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} onClose={() => setTerminalOpen(false)} />
</> </>
)} )}
+2 -3
View File
@@ -3,9 +3,8 @@ import { describe, expect, it } from "vitest";
import { formatTerminalLogTime, formatTerminalServerTime } from "./logTime"; import { formatTerminalLogTime, formatTerminalServerTime } from "./logTime";
describe("formatTerminalLogTime", () => { describe("formatTerminalLogTime", () => {
it("uses the SCUM log clock instead of the browser timezone", () => { it("uses only the server event timestamp", () => {
expect(formatTerminalLogTime("2026-08-24T01:23:44Z", "[2026.08.23-17.23.44][418]LogSCUM: Global Stats")) expect(formatTerminalLogTime("2026-08-24T01:23:44Z")).toMatch(/^\d{2}:\d{2}:\d{2}(?:\s*[AP]M)?$/);
.toBe("17:23:44");
}); });
it("falls back to the event timestamp for generic logs", () => { it("falls back to the event timestamp for generic logs", () => {
+1 -8
View File
@@ -1,11 +1,4 @@
const scumLogTimestampPattern = /\[?(\d{4})\.(\d{2})\.(\d{2})-(\d{2})\.(\d{2})\.(\d{2})(?:[.:]\d{1,3})?\]?/; export function formatTerminalLogTime(timestamp: string): string {
export function formatTerminalLogTime(timestamp: string, line?: string): string {
const embedded = line?.match(scumLogTimestampPattern);
if (embedded) {
return `${embedded[4]}:${embedded[5]}:${embedded[6]}`;
}
const date = new Date(timestamp); const date = new Date(timestamp);
return Number.isNaN(date.getTime()) ? "时间未知" : date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); return Number.isNaN(date.getTime()) ? "时间未知" : date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
} }