From 0154f42485a3b9dc1bc610dc634f8bda3e9e353d Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Mon, 10 Aug 2026 23:43:17 +0800 Subject: [PATCH] Restore interactive server terminal drawer --- platform_web/api/client.test.ts | 7 + platform_web/api/client.ts | 12 + .../ServerManagementTerminalDrawer.tsx | 368 ++++++++++++++++++ platform_web/pages/ConsolePages.test.tsx | 18 +- platform_web/pages/ServerDetailPage.test.tsx | 12 +- platform_web/pages/ServerDetailPage.tsx | 74 +--- platform_web/routes/routes.md | 2 +- 7 files changed, 410 insertions(+), 83 deletions(-) create mode 100644 platform_web/components/ServerManagementTerminalDrawer.tsx diff --git a/platform_web/api/client.test.ts b/platform_web/api/client.test.ts index 07dad86..7889b6a 100644 --- a/platform_web/api/client.test.ts +++ b/platform_web/api/client.test.ts @@ -771,6 +771,13 @@ describe("PlatformApiClient AI providers", () => { }); }); + it("builds encoded server log event stream URLs for the terminal drawer", () => { + const client = new PlatformApiClient("/api/v1"); + + expect(client.serverLogEventsUrl("server/scum 1", { historyLimit: 500 })).toBe("/api/v1/server-instances/server%2Fscum%201/logs/events?historyLimit=500"); + expect(client.serverLogEventsUrl("server-1")).toBe("/api/v1/server-instances/server-1/logs/events"); + }); + it("surfaces password confirmation denials without exposing generic forbidden text", async () => { vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ code: "forbidden", diff --git a/platform_web/api/client.ts b/platform_web/api/client.ts index 2ff216d..9b6e806 100644 --- a/platform_web/api/client.ts +++ b/platform_web/api/client.ts @@ -56,6 +56,7 @@ import type { LlmConfigSuggestionResponse, LogStreamCursorRequest, LogStreamCursorResponse, + LogStreamEventOptions, LogStreamListResponse, LoginRequest, MarketplacePluginFilterRequest, @@ -637,6 +638,17 @@ export class PlatformApiClient { return this.request("/log-streams"); } + openServerLogEvents(id: string, options: LogStreamEventOptions = {}): EventSource { + return new EventSource(this.serverLogEventsUrl(id, options), { withCredentials: true }); + } + + serverLogEventsUrl(id: string, options: LogStreamEventOptions = {}): string { + const params = new URLSearchParams(); + if (options.historyLimit !== undefined) params.set("historyLimit", String(options.historyLimit)); + const query = params.toString(); + return `${this.baseUrl}/server-instances/${encodeURIComponent(id)}/logs/events${query ? `?${query}` : ""}`; + } + async queryLogStream(request: LogStreamCursorRequest): Promise { return this.request("/log-streams/query", { method: "POST", body: request }); } diff --git a/platform_web/components/ServerManagementTerminalDrawer.tsx b/platform_web/components/ServerManagementTerminalDrawer.tsx new file mode 100644 index 0000000..9f92fb8 --- /dev/null +++ b/platform_web/components/ServerManagementTerminalDrawer.tsx @@ -0,0 +1,368 @@ +import { 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 { GameClientBridgeCommandResponse, LogEntryBody, LogStreamResponse } from "../api/types"; +import { scumManagementRCONCommandRequest } from "../schemas/scumManagementRcon"; +import { cx } from "../utils/classes"; +import { mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents"; +import { EmptyState, ResultBadge } from "./StateViews"; + +type LoadState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T }; +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 terminalBridgeResultPollMs = 1000; +const terminalBridgeResultPollAttempts = 30; +const terminalInitialHistoryWindow = 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: "设置游戏时间" } + ] +}; + +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 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 [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 outputRef = useRef(null); + const followLatestRef = useRef(true); + const initialHistoryPendingRef = useRef(false); + 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); + 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("stream", (event) => { + const stream = parseLogStreamEvent(event); + if (!stream) 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) 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]); + + 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([]); + } + + 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) { + 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.queueGameClientBridgeCommand(serverId, scumManagementRCONCommandRequest(serverId, submitted)); + const label = bridgeCommandDispatchLabel(response.state, response.id); + setResult({ status: "pending", label: `${label} · 等待 Run 返回结果` }); + appendLines([terminalSystemLine("success", `${label} · protected RCON`, "PLATFORM", `ok-${response.id}`)]); + const finalCommand = await waitForBridgeCommandTerminal(response.id); + if (finalCommand) { + const outcome = terminalLineFromBridgeCommand(finalCommand); + setResult({ status: outcome.tone === "success" ? "succeeded" : "failed", label: outcome.text }); + appendLines([outcome]); + } else { + const timeoutLine = terminalSystemLine("warn", `桥接命令 ${response.id} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.id}`); + 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 waitForBridgeCommandTerminal(commandId: string): Promise { + for (let attempt = 0; attempt < terminalBridgeResultPollAttempts; attempt += 1) { + const current = await platformApiClient.getGameClientBridgeCommand(serverId, commandId); + if (isTerminalBridgeCommandState(current.state)) return current; + await delay(terminalBridgeResultPollMs); + } + return null; + } + + return ( + +
+
+
+ {serverName} + 当前服务器 Run 日志 · 已接受历史 + SSE 实时推送 · {streams.status === "ready" ? "等待当前输出" : streams.status === "loading" ? "连接日志流" : "日志流异常"} · {followLatest ? "自动置底" : "已解锁滚动"} +
+
+ + +
+
+
+ {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 terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] { + return terminalQuickCommandCatalog[pluginId] ?? []; +} + +function bridgeCommandDispatchLabel(state: string, commandId: string): string { + return `已${state === "pending" ? "排队" : "提交"} · 桥接命令 ${commandId}`; +} + +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 isTerminalBridgeCommandState(state: GameClientBridgeCommandResponse["state"]): boolean { + return state === "succeeded" || state === "failed" || state === "cancelled" || state === "expired" || state === "unknown"; +} + +function terminalLineFromBridgeCommand(command: GameClientBridgeCommandResponse): TerminalLine { + const summary = command.result?.summary || command.resultSummary || command.cancellation?.reason || bridgeCommandStateLabel(command.state); + const completed = command.completedAt || command.result?.completedAt || command.cancellation?.cancelledAt || command.updatedAt; + const sortKey = Date.parse(completed) || Date.now(); + const tone: TerminalLine["tone"] = command.state === "succeeded" ? "success" : command.state === "failed" ? "error" : "warn"; + return { id: `bridge-${command.id}-${command.state}`, tone, text: `桥接命令 ${command.id} · ${bridgeCommandStateLabel(command.state)} · ${summary}`, at: new Date(sortKey).toLocaleTimeString(), sortKey, streamKey: "BRIDGE" }; +} + +function bridgeCommandStateLabel(state: GameClientBridgeCommandResponse["state"]): string { + switch (state) { + case "succeeded": return "已成功"; + case "failed": return "已失败"; + case "cancelled": return "已取消"; + case "expired": return "已过期"; + case "unknown": return "状态未知"; + case "claimed": return "Run 已领取"; + case "pending": 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"; + 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); +} diff --git a/platform_web/pages/ConsolePages.test.tsx b/platform_web/pages/ConsolePages.test.tsx index a149d17..ba29018 100644 --- a/platform_web/pages/ConsolePages.test.tsx +++ b/platform_web/pages/ConsolePages.test.tsx @@ -10,6 +10,7 @@ import { ServersPage } from "./ServersPage"; import { UsersPage } from "./UsersPage"; import runtimeTaskProgressSource from "../components/RuntimeTaskProgress.tsx?raw"; import serverDeploymentWorkflowSource from "../components/ServerDeploymentWorkflow.tsx?raw"; +import serverManagementTerminalSource from "../components/ServerManagementTerminalDrawer.tsx?raw"; import serverCreateSchemaSource from "../schemas/serverManagement.ts?raw"; import serversPageSource from "./ServersPage.tsx?raw"; import serverDetailPageSource from "./ServerDetailPage.tsx?raw"; @@ -213,23 +214,26 @@ describe("first-party console pages", () => { } }); - it("removes raw log and command panels while preserving the safe terminal entry", () => { + it("removes page-level raw panels while preserving the historical terminal drawer", () => { expect(serversPageSource).not.toContain("ServerLiveLogDrawer"); expect(serversPageSource).not.toContain("ServerManagementTerminalDrawer"); expect(serversPageSource).not.toContain("live-logs"); expect(serversPageSource).not.toContain("historical-logs"); expect(serversPageSource).not.toContain("requestLogBackfill"); expect(serversPageSource).not.toContain("管理终端"); - expect(serverDetailPageSource).not.toContain("ServerManagementTerminalDrawer"); + expect(serverDetailPageSource).toContain("ServerManagementTerminalDrawer"); expect(serverDetailPageSource).not.toContain("SourceRCONCommandPanel"); expect(serverDetailPageSource).not.toContain("ServerLiveLogDrawer"); expect(serverDetailPageSource).not.toContain('实时日志'); - expect(serverDetailPageSource).not.toContain("openServerLogEvents"); expect(serverDetailPageSource).not.toContain("previewServerConfigDiff"); expect(serverDetailPageSource).not.toContain("approveServerConfigWrite"); expect(serverDetailPageSource).not.toContain("操作历史"); expect(serverDetailPageSource).toContain("打开终端"); - expect(serverDetailPageSource).toContain("RemoteTerminalEntryDialog"); + expect(serverManagementTerminalSource).toContain("openServerLogEvents"); + expect(serverManagementTerminalSource).toContain("queueGameClientBridgeCommand"); + expect(serverManagementTerminalSource).toContain("terminalQuickCommandCatalog"); + expect(serverManagementTerminalSource).not.toContain("password"); + expect(serverManagementTerminalSource).not.toContain("direct socket"); expect(serverDetailPageSource).toContain("runtimeObservationFreshness"); expect(serverDetailPageSource).toContain("Run 未验证"); expect(serverDetailPageSource).toContain("PluginPageSection"); @@ -322,7 +326,7 @@ describe("first-party console pages", () => { expect(html).not.toContain("概览"); expect(html).toContain("管理"); expect(html).not.toContain("日志"); - expect(html).not.toContain("管理终端"); + expect(serverDetailPageSource).toContain("打开终端"); expect(html).not.toContain("配置"); expect(html).not.toContain("运行操作"); expect(html).not.toContain("插件控制"); @@ -335,9 +339,9 @@ describe("first-party console pages", () => { expect(serverDetailPageSource).toContain("plugin:${page.key}"); expect(serverDetailPageSource).toContain("section === \"llm\""); expect(serverDetailPageSource).toContain("打开终端"); - expect(serverDetailPageSource).not.toContain("terminalQuickCommandCatalog"); + expect(serverManagementTerminalSource).toContain("terminalQuickCommandCatalog"); expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest"); - expect(serverDetailPageSource).not.toContain("queueGameClientBridgeCommand"); + expect(serverManagementTerminalSource).toContain("queueGameClientBridgeCommand"); expect(serverDetailPageSource).not.toContain("commandHistory"); }); diff --git a/platform_web/pages/ServerDetailPage.test.tsx b/platform_web/pages/ServerDetailPage.test.tsx index be8c907..b6aad4c 100644 --- a/platform_web/pages/ServerDetailPage.test.tsx +++ b/platform_web/pages/ServerDetailPage.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { configDiffViewFromPreview } from "./ServerDetailPage"; +import serverManagementTerminalSource from "../components/ServerManagementTerminalDrawer.tsx?raw"; import serverDetailPageSource from "./ServerDetailPage.tsx?raw"; import type { ServerConfigDiffPreviewResponse } from "../api/types"; @@ -79,7 +80,7 @@ describe("ServerDetailPage config write approval", () => { expect(serverDetailPageSource).not.toContain("pushRunUpdate"); expect(serverDetailPageSource).not.toContain("generateClientManager"); expect(serverDetailPageSource).not.toContain("ClientManagerLifecyclePanel"); - expect(serverDetailPageSource).not.toContain("openServerLogEvents"); + expect(serverManagementTerminalSource).toContain("openServerLogEvents"); expect(serverDetailPageSource).not.toContain("authKey"); expect(serverDetailPageSource).not.toContain("password="); expect(serverDetailPageSource).not.toContain("unix://"); @@ -95,13 +96,14 @@ describe("ServerDetailPage config write approval", () => { expect(serverDetailPageSource).not.toContain("RuntimeDLLExtensionsPanel"); }); - it("does not expose raw SCUM RCON command panels", () => { + it("uses the historical terminal drawer without restoring separate raw command panels", () => { expect(serverDetailPageSource).not.toContain("SourceRCONCommandPanel"); - expect(serverDetailPageSource).not.toContain("queueGameClientBridgeCommand"); + expect(serverManagementTerminalSource).toContain("queueGameClientBridgeCommand"); expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest"); - expect(serverDetailPageSource).not.toContain("管理终端"); expect(serverDetailPageSource).toContain("打开终端"); - expect(serverDetailPageSource).toContain("RemoteTerminalEntryDialog"); + expect(serverDetailPageSource).toContain("ServerManagementTerminalDrawer"); + expect(serverManagementTerminalSource).toContain("terminal-output-topbar"); + expect(serverManagementTerminalSource).toContain("terminalQuickCommandCatalog"); expect(serverDetailPageSource).toContain("PluginPageHostPage"); }); diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index 84caf09..19c2b79 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -6,7 +6,6 @@ import type { ConfigDiffLineResponse, GamePluginResponse, JobResponse, - RemoteAdapterDeclarationResponse, ServerInstanceResponse, ServerMemberResponse, ServerMetricsResponse, @@ -14,7 +13,8 @@ import type { ServerConfigDiffPreviewResponse, RunEndpointResponse } from "../api/types"; -import { ConfirmDialog, ManagementDialog, UsageMeter } from "../components/OperationControls"; +import { ConfirmDialog, UsageMeter } from "../components/OperationControls"; +import { ServerManagementTerminalDrawer } from "../components/ServerManagementTerminalDrawer"; import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews"; import type { PageComponentProps } from "../contracts/page"; import { canStartServer, canStopServer, runtimeObservationFreshness, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement"; @@ -51,7 +51,6 @@ export function ServerDetailPage(props: PageComponentProps) { const [confirm, setConfirm] = useState Promise }>(null); const [confirmBusy, setConfirmBusy] = useState(false); const [terminalOpen, setTerminalOpen] = useState(false); - const [terminalAdapters, setTerminalAdapters] = useState>({ status: "loading" }); const refresh = useCallback(async () => { if (!serverId) { @@ -160,15 +159,6 @@ export function ServerDetailPage(props: PageComponentProps) { }); } - function openTerminalEntry(current: ServerInstanceResponse) { - setTerminalOpen(true); - setTerminalAdapters({ status: "loading" }); - void platformApiClient - .listRemoteAdapters(current.id) - .then((response) => setTerminalAdapters({ status: "ready", data: response.items })) - .catch((error) => setTerminalAdapters({ status: "error", reason: error instanceof Error ? error.message : "终端能力加载失败" })); - } - if (!serverId) { return ( onNavigate("servers")} /> @@ -226,7 +216,7 @@ export function ServerDetailPage(props: PageComponentProps) { className="icon-command" disabled={!canManageServers} title={canManageServers ? "打开终端" : "当前账号没有管理权限"} - onClick={() => openTerminalEntry(instance.data)} + onClick={() => setTerminalOpen(true)} > 打开终端 @@ -291,6 +281,7 @@ export function ServerDetailPage(props: PageComponentProps) { )} {section === "manage" && setInstance({ status: "ready", data: next })} />} {section === "llm" && } + setTerminalOpen(false)} /> )} @@ -313,67 +304,10 @@ export function ServerDetailPage(props: PageComponentProps) { }); }} /> - {instance.status === "ready" && ( - openTerminalEntry(instance.data)} - onClose={() => setTerminalOpen(false)} - /> - )} ); } -interface RemoteTerminalEntryDialogProps { - open: boolean; - instance: ServerInstanceResponse; - adapters: LoadState; - onRefresh: () => void; - onClose: () => void; -} - -function RemoteTerminalEntryDialog({ open, instance, adapters, onRefresh, onClose }: RemoteTerminalEntryDialogProps) { - const terminalReadyAdapters = adapters.status === "ready" ? adapters.data.filter(isTerminalReadyAdapter) : []; - return ( - -
-
服务器{instance.id}
-
Run 节点{instance.runEndpointId || "未绑定"}
-
终端会话{terminalReadyAdapters.length > 0 ? "等待 Run 会话实现" : "尚未声明交互会话"}
-
-

直接命令面板已拆掉;真正的交互终端要由 Run 返回受控会话,再由平台打开。当前先展示已声明的适配能力,避免凭空创建连接或命令。

-
- -
- {adapters.status === "loading" && } - {adapters.status === "error" && } - {adapters.status === "ready" && adapters.data.length === 0 && } - {adapters.status === "ready" && adapters.data.length > 0 && ( -
- {adapters.data.map((adapter) => ( -
- {adapter.key} · {adapter.kind} - {adapter.capabilities.join(" / ")} -
- ))} -
- )} -
- ); -} - -function isTerminalReadyAdapter(adapter: RemoteAdapterDeclarationResponse): boolean { - return adapter.kind === "run-process" || adapter.capabilities.some((capability) => capability === "remote.run.process.start" || capability === "remote.run.process.stop" || capability === "remote.run.program.command"); -} - function serverDetailSectionEntries(plugin?: GamePluginResponse): Array<{ id: ServerDetailSection; label: string }> { const pluginPages = (plugin?.pages ?? []).map((page) => ({ id: `plugin:${page.key}` as ServerDetailSection, label: page.title })); return [...pluginPages, ...serverDetailSections]; diff --git a/platform_web/routes/routes.md b/platform_web/routes/routes.md index 8da6ab2..fcdafff 100644 --- a/platform_web/routes/routes.md +++ b/platform_web/routes/routes.md @@ -6,7 +6,7 @@ First-party routes must be declared here before page implementation. - `/`: 平台概览(平台管理员默认落地页). - `/servers`: 服务器管理(服主/服务器管理员默认落地页). -- `/servers/:serverId`: 服务器详情 route(日常运维工作台:插件声明页面、管理、AI 助手、打开终端安全入口;不暴露 raw 日志、旧命令面板、raw 配置或操作历史页). +- `/servers/:serverId`: 服务器详情 route(日常运维工作台:插件声明页面、管理、AI 助手、打开终端抽屉;终端聚合平台日志 SSE 与插件声明的受限命令通道,不暴露主机 shell、凭据、raw 配置或操作历史页). - `/plugins`: 插件市场. - `/users`: 用户管理. - `/ai-providers`: AI 提供商管理.