Restore safe server terminal entry
This commit is contained in:
@@ -213,7 +213,7 @@ describe("first-party console pages", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes raw log and management terminal entry points from server pages", () => {
|
it("removes raw log and command panels while preserving the safe terminal entry", () => {
|
||||||
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");
|
||||||
@@ -228,6 +228,8 @@ describe("first-party console pages", () => {
|
|||||||
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("RemoteTerminalEntryDialog");
|
||||||
expect(serverDetailPageSource).toContain("runtimeObservationFreshness");
|
expect(serverDetailPageSource).toContain("runtimeObservationFreshness");
|
||||||
expect(serverDetailPageSource).toContain("Run 未验证");
|
expect(serverDetailPageSource).toContain("Run 未验证");
|
||||||
expect(serverDetailPageSource).toContain("PluginPageSection");
|
expect(serverDetailPageSource).toContain("PluginPageSection");
|
||||||
@@ -332,6 +334,7 @@ describe("first-party console pages", () => {
|
|||||||
expect(serverDetailPageSource).toContain("PluginPageHostPage");
|
expect(serverDetailPageSource).toContain("PluginPageHostPage");
|
||||||
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).not.toContain("terminalQuickCommandCatalog");
|
expect(serverDetailPageSource).not.toContain("terminalQuickCommandCatalog");
|
||||||
expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest");
|
expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest");
|
||||||
expect(serverDetailPageSource).not.toContain("queueGameClientBridgeCommand");
|
expect(serverDetailPageSource).not.toContain("queueGameClientBridgeCommand");
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ describe("ServerDetailPage config write approval", () => {
|
|||||||
expect(serverDetailPageSource).not.toContain("queueGameClientBridgeCommand");
|
expect(serverDetailPageSource).not.toContain("queueGameClientBridgeCommand");
|
||||||
expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest");
|
expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest");
|
||||||
expect(serverDetailPageSource).not.toContain("管理终端");
|
expect(serverDetailPageSource).not.toContain("管理终端");
|
||||||
|
expect(serverDetailPageSource).toContain("<span>打开终端</span>");
|
||||||
|
expect(serverDetailPageSource).toContain("RemoteTerminalEntryDialog");
|
||||||
expect(serverDetailPageSource).toContain("PluginPageHostPage");
|
expect(serverDetailPageSource).toContain("PluginPageHostPage");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
|
import { MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, Terminal, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
|
||||||
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { platformApiClient } from "../api/client";
|
import { platformApiClient } from "../api/client";
|
||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
ConfigDiffLineResponse,
|
ConfigDiffLineResponse,
|
||||||
GamePluginResponse,
|
GamePluginResponse,
|
||||||
JobResponse,
|
JobResponse,
|
||||||
|
RemoteAdapterDeclarationResponse,
|
||||||
ServerInstanceResponse,
|
ServerInstanceResponse,
|
||||||
ServerMemberResponse,
|
ServerMemberResponse,
|
||||||
ServerMetricsResponse,
|
ServerMetricsResponse,
|
||||||
@@ -13,7 +14,7 @@ import type {
|
|||||||
ServerConfigDiffPreviewResponse,
|
ServerConfigDiffPreviewResponse,
|
||||||
RunEndpointResponse
|
RunEndpointResponse
|
||||||
} from "../api/types";
|
} from "../api/types";
|
||||||
import { ConfirmDialog, UsageMeter } from "../components/OperationControls";
|
import { ConfirmDialog, ManagementDialog, UsageMeter } from "../components/OperationControls";
|
||||||
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";
|
||||||
@@ -49,6 +50,8 @@ export function ServerDetailPage(props: PageComponentProps) {
|
|||||||
const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" });
|
const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" });
|
||||||
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 [terminalAdapters, setTerminalAdapters] = useState<LoadState<RemoteAdapterDeclarationResponse[]>>({ status: "loading" });
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
@@ -157,6 +160,15 @@ 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")} />
|
||||||
@@ -209,6 +221,16 @@ export function ServerDetailPage(props: PageComponentProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="action-strip">
|
<div className="action-strip">
|
||||||
<span className={cx("status-pill", detailFreshness === "fresh" ? statusClass(instance.data.state) : "status-disabled")}>{detailStateText}</span>
|
<span className={cx("status-pill", detailFreshness === "fresh" ? statusClass(instance.data.state) : "status-disabled")}>{detailStateText}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-command"
|
||||||
|
disabled={!canManageServers}
|
||||||
|
title={canManageServers ? "打开终端" : "当前账号没有管理权限"}
|
||||||
|
onClick={() => openTerminalEntry(instance.data)}
|
||||||
|
>
|
||||||
|
<Terminal size={15} />
|
||||||
|
<span>打开终端</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="icon-command"
|
className="icon-command"
|
||||||
@@ -291,10 +313,67 @@ 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 助手、打开终端安全入口;不暴露 raw 日志、旧命令面板、raw 配置或操作历史页).
|
||||||
- `/plugins`: 插件市场.
|
- `/plugins`: 插件市场.
|
||||||
- `/users`: 用户管理.
|
- `/users`: 用户管理.
|
||||||
- `/ai-providers`: AI 提供商管理.
|
- `/ai-providers`: AI 提供商管理.
|
||||||
|
|||||||
Reference in New Issue
Block a user