Restore interactive server terminal drawer
This commit is contained in:
@@ -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 () => {
|
it("surfaces password confirmation denials without exposing generic forbidden text", async () => {
|
||||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
|
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
|
||||||
code: "forbidden",
|
code: "forbidden",
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import type {
|
|||||||
LlmConfigSuggestionResponse,
|
LlmConfigSuggestionResponse,
|
||||||
LogStreamCursorRequest,
|
LogStreamCursorRequest,
|
||||||
LogStreamCursorResponse,
|
LogStreamCursorResponse,
|
||||||
|
LogStreamEventOptions,
|
||||||
LogStreamListResponse,
|
LogStreamListResponse,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
MarketplacePluginFilterRequest,
|
MarketplacePluginFilterRequest,
|
||||||
@@ -637,6 +638,17 @@ export class PlatformApiClient {
|
|||||||
return this.request<LogStreamListResponse>("/log-streams");
|
return this.request<LogStreamListResponse>("/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<LogStreamCursorResponse> {
|
async queryLogStream(request: LogStreamCursorRequest): Promise<LogStreamCursorResponse> {
|
||||||
return this.request<LogStreamCursorResponse>("/log-streams/query", { method: "POST", body: request });
|
return this.request<LogStreamCursorResponse>("/log-streams/query", { method: "POST", body: request });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<T> = { 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<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 outputRef = useRef<HTMLDivElement>(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<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() {
|
||||||
|
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<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.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<GameClientBridgeCommandResponse | null> {
|
||||||
|
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 (
|
||||||
|
<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>当前服务器 Run 日志 · 已接受历史 + SSE 实时推送 · {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" 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}>
|
||||||
|
{streams.status === "error" && <div className="terminal-line terminal-line-error"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-stream">LOGS</span><span className="terminal-text">{streams.reason}</span></div>}
|
||||||
|
{streams.status === "ready" && streams.data.length === 0 && <div className="terminal-line terminal-line-warn"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-stream">LOGS</span><span className="terminal-text">暂无已接受日志。Run 恢复连接并完成日志水位校准后,新输出会继续追加。</span></div>}
|
||||||
|
{lines.map((line) => <div key={line.id} className={`terminal-line terminal-line-${line.tone}`}><time>{line.at}</time><span className="terminal-stream">{line.streamKey || line.level || "LOG"}</span><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 发送,↑/↓ 调出历史;命令结果以 Run 日志追加为准。</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { ServersPage } from "./ServersPage";
|
|||||||
import { UsersPage } from "./UsersPage";
|
import { UsersPage } from "./UsersPage";
|
||||||
import runtimeTaskProgressSource from "../components/RuntimeTaskProgress.tsx?raw";
|
import runtimeTaskProgressSource from "../components/RuntimeTaskProgress.tsx?raw";
|
||||||
import serverDeploymentWorkflowSource from "../components/ServerDeploymentWorkflow.tsx?raw";
|
import serverDeploymentWorkflowSource from "../components/ServerDeploymentWorkflow.tsx?raw";
|
||||||
|
import serverManagementTerminalSource from "../components/ServerManagementTerminalDrawer.tsx?raw";
|
||||||
import serverCreateSchemaSource from "../schemas/serverManagement.ts?raw";
|
import serverCreateSchemaSource from "../schemas/serverManagement.ts?raw";
|
||||||
import serversPageSource from "./ServersPage.tsx?raw";
|
import serversPageSource from "./ServersPage.tsx?raw";
|
||||||
import serverDetailPageSource from "./ServerDetailPage.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("ServerLiveLogDrawer");
|
||||||
expect(serversPageSource).not.toContain("ServerManagementTerminalDrawer");
|
expect(serversPageSource).not.toContain("ServerManagementTerminalDrawer");
|
||||||
expect(serversPageSource).not.toContain("live-logs");
|
expect(serversPageSource).not.toContain("live-logs");
|
||||||
expect(serversPageSource).not.toContain("historical-logs");
|
expect(serversPageSource).not.toContain("historical-logs");
|
||||||
expect(serversPageSource).not.toContain("requestLogBackfill");
|
expect(serversPageSource).not.toContain("requestLogBackfill");
|
||||||
expect(serversPageSource).not.toContain("管理终端");
|
expect(serversPageSource).not.toContain("管理终端");
|
||||||
expect(serverDetailPageSource).not.toContain("ServerManagementTerminalDrawer");
|
expect(serverDetailPageSource).toContain("ServerManagementTerminalDrawer");
|
||||||
expect(serverDetailPageSource).not.toContain("SourceRCONCommandPanel");
|
expect(serverDetailPageSource).not.toContain("SourceRCONCommandPanel");
|
||||||
expect(serverDetailPageSource).not.toContain("ServerLiveLogDrawer");
|
expect(serverDetailPageSource).not.toContain("ServerLiveLogDrawer");
|
||||||
expect(serverDetailPageSource).not.toContain('<span>实时日志</span>');
|
expect(serverDetailPageSource).not.toContain('<span>实时日志</span>');
|
||||||
expect(serverDetailPageSource).not.toContain("openServerLogEvents");
|
|
||||||
expect(serverDetailPageSource).not.toContain("previewServerConfigDiff");
|
expect(serverDetailPageSource).not.toContain("previewServerConfigDiff");
|
||||||
expect(serverDetailPageSource).not.toContain("approveServerConfigWrite");
|
expect(serverDetailPageSource).not.toContain("approveServerConfigWrite");
|
||||||
expect(serverDetailPageSource).not.toContain("操作历史");
|
expect(serverDetailPageSource).not.toContain("操作历史");
|
||||||
expect(serverDetailPageSource).toContain("<span>打开终端</span>");
|
expect(serverDetailPageSource).toContain("<span>打开终端</span>");
|
||||||
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("runtimeObservationFreshness");
|
||||||
expect(serverDetailPageSource).toContain("Run 未验证");
|
expect(serverDetailPageSource).toContain("Run 未验证");
|
||||||
expect(serverDetailPageSource).toContain("PluginPageSection");
|
expect(serverDetailPageSource).toContain("PluginPageSection");
|
||||||
@@ -322,7 +326,7 @@ describe("first-party console pages", () => {
|
|||||||
expect(html).not.toContain("概览");
|
expect(html).not.toContain("概览");
|
||||||
expect(html).toContain("管理");
|
expect(html).toContain("管理");
|
||||||
expect(html).not.toContain("日志");
|
expect(html).not.toContain("日志");
|
||||||
expect(html).not.toContain("管理终端");
|
expect(serverDetailPageSource).toContain("打开终端");
|
||||||
expect(html).not.toContain("配置");
|
expect(html).not.toContain("配置");
|
||||||
expect(html).not.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("plugin:${page.key}");
|
||||||
expect(serverDetailPageSource).toContain("section === \"llm\"");
|
expect(serverDetailPageSource).toContain("section === \"llm\"");
|
||||||
expect(serverDetailPageSource).toContain("打开终端");
|
expect(serverDetailPageSource).toContain("打开终端");
|
||||||
expect(serverDetailPageSource).not.toContain("terminalQuickCommandCatalog");
|
expect(serverManagementTerminalSource).toContain("terminalQuickCommandCatalog");
|
||||||
expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest");
|
expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest");
|
||||||
expect(serverDetailPageSource).not.toContain("queueGameClientBridgeCommand");
|
expect(serverManagementTerminalSource).toContain("queueGameClientBridgeCommand");
|
||||||
expect(serverDetailPageSource).not.toContain("commandHistory");
|
expect(serverDetailPageSource).not.toContain("commandHistory");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { configDiffViewFromPreview } from "./ServerDetailPage";
|
import { configDiffViewFromPreview } from "./ServerDetailPage";
|
||||||
|
import serverManagementTerminalSource from "../components/ServerManagementTerminalDrawer.tsx?raw";
|
||||||
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
|
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
|
||||||
import type { ServerConfigDiffPreviewResponse } from "../api/types";
|
import type { ServerConfigDiffPreviewResponse } from "../api/types";
|
||||||
|
|
||||||
@@ -79,7 +80,7 @@ describe("ServerDetailPage config write approval", () => {
|
|||||||
expect(serverDetailPageSource).not.toContain("pushRunUpdate");
|
expect(serverDetailPageSource).not.toContain("pushRunUpdate");
|
||||||
expect(serverDetailPageSource).not.toContain("generateClientManager");
|
expect(serverDetailPageSource).not.toContain("generateClientManager");
|
||||||
expect(serverDetailPageSource).not.toContain("ClientManagerLifecyclePanel");
|
expect(serverDetailPageSource).not.toContain("ClientManagerLifecyclePanel");
|
||||||
expect(serverDetailPageSource).not.toContain("openServerLogEvents");
|
expect(serverManagementTerminalSource).toContain("openServerLogEvents");
|
||||||
expect(serverDetailPageSource).not.toContain("authKey");
|
expect(serverDetailPageSource).not.toContain("authKey");
|
||||||
expect(serverDetailPageSource).not.toContain("password=");
|
expect(serverDetailPageSource).not.toContain("password=");
|
||||||
expect(serverDetailPageSource).not.toContain("unix://");
|
expect(serverDetailPageSource).not.toContain("unix://");
|
||||||
@@ -95,13 +96,14 @@ describe("ServerDetailPage config write approval", () => {
|
|||||||
expect(serverDetailPageSource).not.toContain("RuntimeDLLExtensionsPanel");
|
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("SourceRCONCommandPanel");
|
||||||
expect(serverDetailPageSource).not.toContain("queueGameClientBridgeCommand");
|
expect(serverManagementTerminalSource).toContain("queueGameClientBridgeCommand");
|
||||||
expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest");
|
expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest");
|
||||||
expect(serverDetailPageSource).not.toContain("管理终端");
|
|
||||||
expect(serverDetailPageSource).toContain("<span>打开终端</span>");
|
expect(serverDetailPageSource).toContain("<span>打开终端</span>");
|
||||||
expect(serverDetailPageSource).toContain("RemoteTerminalEntryDialog");
|
expect(serverDetailPageSource).toContain("ServerManagementTerminalDrawer");
|
||||||
|
expect(serverManagementTerminalSource).toContain("terminal-output-topbar");
|
||||||
|
expect(serverManagementTerminalSource).toContain("terminalQuickCommandCatalog");
|
||||||
expect(serverDetailPageSource).toContain("PluginPageHostPage");
|
expect(serverDetailPageSource).toContain("PluginPageHostPage");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type {
|
|||||||
ConfigDiffLineResponse,
|
ConfigDiffLineResponse,
|
||||||
GamePluginResponse,
|
GamePluginResponse,
|
||||||
JobResponse,
|
JobResponse,
|
||||||
RemoteAdapterDeclarationResponse,
|
|
||||||
ServerInstanceResponse,
|
ServerInstanceResponse,
|
||||||
ServerMemberResponse,
|
ServerMemberResponse,
|
||||||
ServerMetricsResponse,
|
ServerMetricsResponse,
|
||||||
@@ -14,7 +13,8 @@ import type {
|
|||||||
ServerConfigDiffPreviewResponse,
|
ServerConfigDiffPreviewResponse,
|
||||||
RunEndpointResponse
|
RunEndpointResponse
|
||||||
} from "../api/types";
|
} 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 { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||||
import type { PageComponentProps } from "../contracts/page";
|
import type { PageComponentProps } from "../contracts/page";
|
||||||
import { canStartServer, canStopServer, runtimeObservationFreshness, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
|
import { canStartServer, canStopServer, runtimeObservationFreshness, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
|
||||||
@@ -51,7 +51,6 @@ export function ServerDetailPage(props: PageComponentProps) {
|
|||||||
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
|
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
|
||||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||||
const [terminalAdapters, setTerminalAdapters] = useState<LoadState<RemoteAdapterDeclarationResponse[]>>({ status: "loading" });
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (!serverId) {
|
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) {
|
if (!serverId) {
|
||||||
return (
|
return (
|
||||||
<EmptyState title="未选择服务器" description="请从服务器列表进入详情页。" actionLabel="返回服务器列表" onAction={() => onNavigate("servers")} />
|
<EmptyState title="未选择服务器" description="请从服务器列表进入详情页。" actionLabel="返回服务器列表" onAction={() => onNavigate("servers")} />
|
||||||
@@ -226,7 +216,7 @@ export function ServerDetailPage(props: PageComponentProps) {
|
|||||||
className="icon-command"
|
className="icon-command"
|
||||||
disabled={!canManageServers}
|
disabled={!canManageServers}
|
||||||
title={canManageServers ? "打开终端" : "当前账号没有管理权限"}
|
title={canManageServers ? "打开终端" : "当前账号没有管理权限"}
|
||||||
onClick={() => openTerminalEntry(instance.data)}
|
onClick={() => setTerminalOpen(true)}
|
||||||
>
|
>
|
||||||
<Terminal size={15} />
|
<Terminal size={15} />
|
||||||
<span>打开终端</span>
|
<span>打开终端</span>
|
||||||
@@ -291,6 +281,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 === "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)} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -313,67 +304,10 @@ export function ServerDetailPage(props: PageComponentProps) {
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{instance.status === "ready" && (
|
|
||||||
<RemoteTerminalEntryDialog
|
|
||||||
open={terminalOpen}
|
|
||||||
instance={instance.data}
|
|
||||||
adapters={terminalAdapters}
|
|
||||||
onRefresh={() => openTerminalEntry(instance.data)}
|
|
||||||
onClose={() => setTerminalOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RemoteTerminalEntryDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
instance: ServerInstanceResponse;
|
|
||||||
adapters: LoadState<RemoteAdapterDeclarationResponse[]>;
|
|
||||||
onRefresh: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RemoteTerminalEntryDialog({ open, instance, adapters, onRefresh, onClose }: RemoteTerminalEntryDialogProps) {
|
|
||||||
const terminalReadyAdapters = adapters.status === "ready" ? adapters.data.filter(isTerminalReadyAdapter) : [];
|
|
||||||
return (
|
|
||||||
<ManagementDialog
|
|
||||||
open={open}
|
|
||||||
title="打开终端"
|
|
||||||
description={`服务器 ${instance.name} 的快捷入口已保留;这里仅使用 Run 声明的安全远程能力,不向浏览器暴露主机连接细节。`}
|
|
||||||
wide
|
|
||||||
onClose={onClose}
|
|
||||||
>
|
|
||||||
<div className="console-row-list">
|
|
||||||
<div className="console-row"><span>服务器</span><strong>{instance.id}</strong></div>
|
|
||||||
<div className="console-row"><span>Run 节点</span><strong>{instance.runEndpointId || "未绑定"}</strong></div>
|
|
||||||
<div className="console-row"><span>终端会话</span><strong>{terminalReadyAdapters.length > 0 ? "等待 Run 会话实现" : "尚未声明交互会话"}</strong></div>
|
|
||||||
</div>
|
|
||||||
<p className="section-copy">直接命令面板已拆掉;真正的交互终端要由 Run 返回受控会话,再由平台打开。当前先展示已声明的适配能力,避免凭空创建连接或命令。</p>
|
|
||||||
<div className="action-strip" style={{ marginTop: 12 }}>
|
|
||||||
<button type="button" className="icon-command" onClick={onRefresh}><Sparkles size={14} /><span>刷新能力</span></button>
|
|
||||||
</div>
|
|
||||||
{adapters.status === "loading" && <LoadingState label="正在读取 Run 远程能力…" compact />}
|
|
||||||
{adapters.status === "error" && <ErrorState title="终端能力不可用" reason={adapters.reason} diagnosticId={`remote-terminal:${instance.id}`} onRetry={onRefresh} compact />}
|
|
||||||
{adapters.status === "ready" && adapters.data.length === 0 && <EmptyState title="暂无远程适配能力" description="Run 还没有为这个服务器上报可用的安全远程入口。" />}
|
|
||||||
{adapters.status === "ready" && adapters.data.length > 0 && (
|
|
||||||
<div className="console-row-list" style={{ marginTop: 12 }}>
|
|
||||||
{adapters.data.map((adapter) => (
|
|
||||||
<div key={adapter.key} className="console-row">
|
|
||||||
<span>{adapter.key} · {adapter.kind}</span>
|
|
||||||
<strong>{adapter.capabilities.join(" / ")}</strong>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ManagementDialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }> {
|
function serverDetailSectionEntries(plugin?: GamePluginResponse): Array<{ id: ServerDetailSection; label: string }> {
|
||||||
const pluginPages = (plugin?.pages ?? []).map((page) => ({ id: `plugin:${page.key}` as ServerDetailSection, label: page.title }));
|
const pluginPages = (plugin?.pages ?? []).map((page) => ({ id: `plugin:${page.key}` as ServerDetailSection, label: page.title }));
|
||||||
return [...pluginPages, ...serverDetailSections];
|
return [...pluginPages, ...serverDetailSections];
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ First-party routes must be declared here before page implementation.
|
|||||||
|
|
||||||
- `/`: 平台概览(平台管理员默认落地页).
|
- `/`: 平台概览(平台管理员默认落地页).
|
||||||
- `/servers`: 服务器管理(服主/服务器管理员默认落地页).
|
- `/servers`: 服务器管理(服主/服务器管理员默认落地页).
|
||||||
- `/servers/:serverId`: 服务器详情 route(日常运维工作台:插件声明页面、管理、AI 助手、打开终端安全入口;不暴露 raw 日志、旧命令面板、raw 配置或操作历史页).
|
- `/servers/:serverId`: 服务器详情 route(日常运维工作台:插件声明页面、管理、AI 助手、打开终端抽屉;终端聚合平台日志 SSE 与插件声明的受限命令通道,不暴露主机 shell、凭据、raw 配置或操作历史页).
|
||||||
- `/plugins`: 插件市场.
|
- `/plugins`: 插件市场.
|
||||||
- `/users`: 用户管理.
|
- `/users`: 用户管理.
|
||||||
- `/ai-providers`: AI 提供商管理.
|
- `/ai-providers`: AI 提供商管理.
|
||||||
|
|||||||
Reference in New Issue
Block a user