From 8dad79011ae1d4a4c3903c4be6f8af8b48a8145c Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Fri, 4 Sep 2026 14:40:09 +0800 Subject: [PATCH] Stop inspecting game log content in web terminal --- .../ServerManagementTerminalDrawer.test.tsx | 2 +- .../ServerManagementTerminalDrawer.tsx | 166 ++++-------------- platform_web/pages/ServerDetailPage.tsx | 2 +- platform_web/utils/logTime.test.ts | 5 +- platform_web/utils/logTime.ts | 9 +- 5 files changed, 38 insertions(+), 146 deletions(-) diff --git a/platform_web/components/ServerManagementTerminalDrawer.test.tsx b/platform_web/components/ServerManagementTerminalDrawer.test.tsx index bb6aa8b..b41f415 100644 --- a/platform_web/components/ServerManagementTerminalDrawer.test.tsx +++ b/platform_web/components/ServerManagementTerminalDrawer.test.tsx @@ -209,7 +209,7 @@ async function renderDrawer() { document.body.append(container); root = createRoot(container); await act(async () => { - root?.render( undefined} />); + root?.render( undefined} />); }); expect(apiMocks.openServerLogEvents).toHaveBeenCalledWith("server-1"); } diff --git a/platform_web/components/ServerManagementTerminalDrawer.tsx b/platform_web/components/ServerManagementTerminalDrawer.tsx index adb1980..20b54c9 100644 --- a/platform_web/components/ServerManagementTerminalDrawer.tsx +++ b/platform_web/components/ServerManagementTerminalDrawer.tsx @@ -1,5 +1,5 @@ -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 { History, Trash2, X } from "lucide-react"; +import { type FormEvent, type ReactNode, useCallback, useEffect, useRef, useState } from "react"; import { platformApiClient } from "../api/client"; import type { JobResponse, LogEntryBody, LogStreamResponse } from "../api/types"; @@ -7,25 +7,23 @@ 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"; +import { 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: "设置游戏时间" } - ] -}; +const terminalJobResultPollMs = 1000; +const terminalJobResultPollAttempts = 30; +// Game command catalogs are plugin-owned; this extension point intentionally +// contains no platform-provided game commands. +const terminalQuickCommandCatalog: Record = {}; + +function terminalQuickCommandsForPlugin(pluginId: string): never[] { + return terminalQuickCommandCatalog[pluginId] ?? []; +} interface LiveOperationDrawerProps { open: boolean; @@ -77,19 +75,15 @@ interface ServerManagementTerminalDrawerProps { open: boolean; serverId: string; serverName: string; - pluginId: string; - canManage: boolean; 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 [pending, setPending] = useState(false); + const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null); 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); @@ -102,8 +96,6 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu 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; @@ -130,7 +122,6 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu setCommand(""); setPending(false); setResult(null); - setHistoryIndex(null); setLiveSessionId(null); setHistoryOpen(false); setHistoryStreams({ status: "loading" }); @@ -142,8 +133,8 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu initialHistoryPendingRef.current = true; followLatestRef.current = true; setFollowLatest(true); - setLines([terminalSystemLine("info", supportsCommands ? "正在连接当前受管进程输出。" : "该插件暂未声明可用的终端命令通道。", "SYSTEM", undefined, serverTimeRef.current)]); - }, [open, supportsCommands]); + setLines([terminalSystemLine("info", "正在连接当前受管进程输出。", "SYSTEM", undefined, serverTimeRef.current)]); + }, [open]); useEffect(() => { 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) { - 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: [] }); @@ -280,33 +244,19 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu async function submitCommand(event: FormEvent) { event.preventDefault(); - if (!supportsCommands || !canManage || pending || !command.trim()) return; + if (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 label = `已${response.status === "queued" ? "排队" : "提交"} · RCON 任务 ${response.jobId}`; + setResult({ status: "pending", label }); 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]); - } + if (finalJob) setResult({ status: finalJob.state === "succeeded" ? "succeeded" : "failed", label: finalJob.progress.message || finalJob.executionResult?.summary || finalJob.state }); } catch (error) { - const label = error instanceof Error ? error.message : "命令提交失败"; - setResult({ status: "failed", label }); - appendLines([terminalSystemLine("error", label, "ERROR", undefined, serverTimeRef.current)]); + setResult({ status: "failed", label: error instanceof Error ? error.message : "命令提交失败" }); } finally { setPending(false); } @@ -315,8 +265,8 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu 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); + if (current.state === "succeeded" || current.state === "failed" || current.state === "cancelled") return current; + await new Promise((resolve) => window.setTimeout(resolve, terminalJobResultPollMs)); } return null; } @@ -340,27 +290,14 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu {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)}> - - -
- )} +
插件命令通道{result && }
+
void submitCommand(event)}> + + +
); @@ -402,20 +339,12 @@ function TerminalStatusLine({ tone, label, serverTime }: { tone: "info" | "warn" 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), + at: formatTerminalLogTime(entry.timestamp), sortKey: Date.parse(entry.timestamp) || Date.now(), streamKey: stream.streamKey || stream.source, level: entry.level, @@ -452,39 +381,10 @@ function eventBelongsToLiveSession(eventSessionId: string | undefined, liveSessi 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"; + // Log bodies are plugin-owned opaque content. The platform/browser must not + // inspect text to infer severity or derive a typed result from it. + void entry; return "info"; } diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index 582889a..d51d8c9 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -253,7 +253,7 @@ export function ServerDetailPage(props: PageComponentProps) { {section === "manage" && setInstance({ status: "ready", data: next })} />} {section === "files" && } {section === "llm" && } - setTerminalOpen(false)} /> + setTerminalOpen(false)} /> )} diff --git a/platform_web/utils/logTime.test.ts b/platform_web/utils/logTime.test.ts index 4712b2a..6ee8ad2 100644 --- a/platform_web/utils/logTime.test.ts +++ b/platform_web/utils/logTime.test.ts @@ -3,9 +3,8 @@ import { describe, expect, it } from "vitest"; import { formatTerminalLogTime, formatTerminalServerTime } from "./logTime"; describe("formatTerminalLogTime", () => { - it("uses the SCUM log clock instead of the browser timezone", () => { - expect(formatTerminalLogTime("2026-08-24T01:23:44Z", "[2026.08.23-17.23.44][418]LogSCUM: Global Stats")) - .toBe("17:23:44"); + it("uses only the server event timestamp", () => { + expect(formatTerminalLogTime("2026-08-24T01:23:44Z")).toMatch(/^\d{2}:\d{2}:\d{2}(?:\s*[AP]M)?$/); }); it("falls back to the event timestamp for generic logs", () => { diff --git a/platform_web/utils/logTime.ts b/platform_web/utils/logTime.ts index 9e7b9aa..342ff20 100644 --- a/platform_web/utils/logTime.ts +++ b/platform_web/utils/logTime.ts @@ -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, line?: string): string { - const embedded = line?.match(scumLogTimestampPattern); - if (embedded) { - return `${embedded[4]}:${embedded[5]}:${embedded[6]}`; - } - +export function formatTerminalLogTime(timestamp: string): string { const date = new Date(timestamp); return Number.isNaN(date.getTime()) ? "时间未知" : date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); }