Integrate SCUM real ops workflows
This commit is contained in:
@@ -10,7 +10,6 @@ import { ServersPage } from "./ServersPage";
|
||||
import { UsersPage } from "./UsersPage";
|
||||
import runtimeTaskProgressSource from "../components/RuntimeTaskProgress.tsx?raw";
|
||||
import serverDeploymentWorkflowSource from "../components/ServerDeploymentWorkflow.tsx?raw";
|
||||
import serverLiveOperationsSource from "../components/ServerLiveOperations.tsx?raw";
|
||||
import serverCreateSchemaSource from "../schemas/serverManagement.ts?raw";
|
||||
import serversPageSource from "./ServersPage.tsx?raw";
|
||||
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
|
||||
@@ -214,17 +213,25 @@ describe("first-party console pages", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps live logs and management terminal on server detail instead of the server list", () => {
|
||||
it("removes raw log and management terminal entry points from server pages", () => {
|
||||
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).toContain("ServerManagementTerminalDrawer");
|
||||
expect(serverDetailPageSource).not.toContain("ServerManagementTerminalDrawer");
|
||||
expect(serverDetailPageSource).not.toContain("SourceRCONCommandPanel");
|
||||
expect(serverDetailPageSource).not.toContain("ServerLiveLogDrawer");
|
||||
expect(serverDetailPageSource).not.toContain('<span>实时日志</span>');
|
||||
expect(serverDetailPageSource).not.toContain("openServerLogEvents");
|
||||
expect(serverDetailPageSource).not.toContain("previewServerConfigDiff");
|
||||
expect(serverDetailPageSource).not.toContain("approveServerConfigWrite");
|
||||
expect(serverDetailPageSource).not.toContain("操作历史");
|
||||
expect(serverDetailPageSource).toContain("runtimeObservationFreshness");
|
||||
expect(serverDetailPageSource).toContain("Run 未验证");
|
||||
expect(serverDetailPageSource).toContain("管理终端");
|
||||
expect(serverDetailPageSource).toContain("PluginPageSection");
|
||||
expect(serverDetailPageSource).toContain("AI 配置助手");
|
||||
});
|
||||
|
||||
it("keeps create target and profile controls out while preserving deployment steps", () => {
|
||||
@@ -311,52 +318,30 @@ describe("first-party console pages", () => {
|
||||
|
||||
expect(html).toContain("返回列表");
|
||||
expect(html).not.toContain("概览");
|
||||
expect(html).toContain("日志");
|
||||
expect(html).toContain("管理终端");
|
||||
expect(html).toContain("配置");
|
||||
expect(html).toContain("管理");
|
||||
expect(html).not.toContain("日志");
|
||||
expect(html).not.toContain("管理终端");
|
||||
expect(html).not.toContain("配置");
|
||||
expect(html).not.toContain("运行操作");
|
||||
expect(html).not.toContain("插件控制");
|
||||
expect(html).toContain("AI 助手");
|
||||
expect(html).toContain("操作历史");
|
||||
expect(html).not.toContain("操作历史");
|
||||
});
|
||||
|
||||
it("renders management terminal as a large command console with quick commands", () => {
|
||||
expect(serverLiveOperationsSource).toContain("management-terminal-drawer");
|
||||
expect(serverLiveOperationsSource).toContain("terminal-output-topbar");
|
||||
expect(serverLiveOperationsSource).toContain("terminal-command-dock");
|
||||
expect(serverLiveOperationsSource).toContain("terminalQuickCommandCatalog");
|
||||
expect(serverLiveOperationsSource).toContain("terminalQuickCommandsForPlugin");
|
||||
expect(serverLiveOperationsSource).toContain("queueGameClientBridgeCommand");
|
||||
expect(serverLiveOperationsSource).toContain("scumManagementRCONCommandRequest");
|
||||
expect(serverLiveOperationsSource).toContain("#ListSquads");
|
||||
expect(serverLiveOperationsSource).toContain("#ListSpawnedVehicles");
|
||||
expect(serverLiveOperationsSource).toContain("selectQuickCommand(item)");
|
||||
expect(serverLiveOperationsSource).toContain("hideHeader");
|
||||
expect(serverLiveOperationsSource).toContain("handleCommandKeyDown");
|
||||
expect(serverLiveOperationsSource).toContain("commandHistory");
|
||||
expect(serverLiveOperationsSource).toContain("ArrowUp");
|
||||
expect(serverLiveOperationsSource).not.toContain("listServerLiveLogs");
|
||||
expect(serverLiveOperationsSource).toContain("openServerLogEvents");
|
||||
expect(serverLiveOperationsSource).toContain("getGameClientBridgeCommand");
|
||||
expect(serverLiveOperationsSource).toContain("terminalLineFromBridgeCommand");
|
||||
expect(serverLiveOperationsSource).not.toContain("terminalRelevantStreams");
|
||||
expect(serverLiveOperationsSource).toContain("SSE 实时推送");
|
||||
expect(serverLiveOperationsSource).toContain("mergeTerminalLines");
|
||||
expect(serverLiveOperationsSource).toContain("terminalInitialHistoryWindow = 500");
|
||||
expect(serverLiveOperationsSource).toContain("maxTerminalLines = 10000");
|
||||
expect(serverLiveOperationsSource).toContain("followLatestRef");
|
||||
expect(serverLiveOperationsSource).toContain("initialHistoryPendingRef");
|
||||
expect(serverLiveOperationsSource).toContain("lockTerminalFollow");
|
||||
expect(serverLiveOperationsSource).toContain("handleTerminalScroll");
|
||||
expect(serverLiveOperationsSource).toContain("scrollHeight - output.clientHeight - output.scrollTop <= 24");
|
||||
expect(serverLiveOperationsSource).not.toContain("terminalLogPollMs = 1000");
|
||||
expect(serverLiveOperationsSource).not.toContain("logStreamPollMs = 5000");
|
||||
expect(serverLiveOperationsSource).not.toContain("queryLogStream(");
|
||||
expect(serverLiveOperationsSource).not.toContain("SaveWorld");
|
||||
it("routes SCUM operations through typed plugin workflow surfaces", () => {
|
||||
expect(serverDetailPageSource).toContain("PluginPageHostPage");
|
||||
expect(serverDetailPageSource).toContain("plugin:${page.key}");
|
||||
expect(serverDetailPageSource).toContain("section === \"llm\"");
|
||||
expect(serverDetailPageSource).not.toContain("terminalQuickCommandCatalog");
|
||||
expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest");
|
||||
expect(serverDetailPageSource).not.toContain("queueGameClientBridgeCommand");
|
||||
expect(serverDetailPageSource).not.toContain("commandHistory");
|
||||
});
|
||||
|
||||
it("uses declared SCUM log source keys for log backfill defaults", () => {
|
||||
expect(serversPageSource).toContain("scum-server-events");
|
||||
it("does not expose SCUM log backfill defaults as product actions", () => {
|
||||
expect(serversPageSource).not.toContain("scum-server-events");
|
||||
expect(serversPageSource).not.toContain("requestLogBackfill");
|
||||
expect(serversPageSource).not.toContain("historical-logs");
|
||||
expect(serversPageSource).not.toContain("server-log");
|
||||
expect(serverDetailPageSource).not.toContain("server-log");
|
||||
});
|
||||
|
||||
@@ -26,19 +26,19 @@ const plugin: GamePluginResponse = {
|
||||
manifestRef: "artifact://manifests/game.scum/1.0.0",
|
||||
createFormSchemaRef: "schemas/create-form.schema.json",
|
||||
requiredRunCapabilities: [],
|
||||
declaredPermissions: ["server.read", "server.logs.read", "server.game-client.read", "server.game-client.command"],
|
||||
permissions: { ai: false, logs: true, files: false, jobs: true, artifacts: false, remoteAccess: false },
|
||||
declaredPermissions: ["server.read", "server.game-client.read", "server.game-client.command"],
|
||||
permissions: { ai: true, logs: false, files: false, jobs: true, artifacts: false, remoteAccess: false },
|
||||
lifecycleActions: {},
|
||||
bridgeActions: ["server.instances.read", "logs.query"],
|
||||
bridgeActions: ["server.instances.read"],
|
||||
pages: [{
|
||||
key: "files-config",
|
||||
title: "文件与配置",
|
||||
path: "/files-config",
|
||||
key: "players",
|
||||
title: "用户管理",
|
||||
path: "/players",
|
||||
bundleKey: "scum-server-plugin",
|
||||
bundleVersion: "1.0.1",
|
||||
bundleIntegritySha256: "sha256:8de5ec67248be72a6fa47df5e6f8c98e10092ade99e6fc066ceeba678b119c64",
|
||||
permissions: ["server.game-client.read", "server.game-client.command", "server.logs.read"],
|
||||
bridgeActions: ["server.instances.read", "logs.query"]
|
||||
bundleIntegritySha256: "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
|
||||
permissions: ["server.game-client.read", "server.game-client.command"],
|
||||
bridgeActions: ["server.instances.read"]
|
||||
}],
|
||||
tags: ["scum"],
|
||||
aiPurposes: [],
|
||||
@@ -48,7 +48,7 @@ const plugin: GamePluginResponse = {
|
||||
snapshots: [{ type: "companion.health", schemaVersion: "1", schemaRef: "schemas/bridge/health.json", keepForSeconds: 3600, maxRecords: 24 }],
|
||||
commandRetentionSeconds: 86400,
|
||||
maxCommands: 1000,
|
||||
pages: [{ pageKey: "files-config", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"] }]
|
||||
pages: [{ pageKey: "players", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"] }]
|
||||
},
|
||||
status: "installed"
|
||||
};
|
||||
@@ -65,7 +65,7 @@ function props(serverId = "server-1"): PageComponentProps {
|
||||
};
|
||||
return {
|
||||
session,
|
||||
params: { pluginId: "game.scum", routeKey: "files-config", serverId },
|
||||
params: { pluginId: "game.scum", routeKey: "players", serverId },
|
||||
operations,
|
||||
onNavigate: () => undefined,
|
||||
onLogout: async () => undefined,
|
||||
@@ -77,7 +77,7 @@ function props(serverId = "server-1"): PageComponentProps {
|
||||
describe("PluginPageHostPage", () => {
|
||||
it("renders a generic manifest-owned bundle declaration", () => {
|
||||
const html = renderToStaticMarkup(<PluginPageHostPage {...props()} initialPlugin={plugin} />);
|
||||
expect(html).toContain("文件与配置");
|
||||
expect(html).toContain("用户管理");
|
||||
expect(html).toContain("平台托管上下文");
|
||||
expect(html).toContain("scum-server-plugin@1.0.1");
|
||||
expect(html).toContain("完整性");
|
||||
@@ -96,11 +96,17 @@ describe("PluginPageHostPage", () => {
|
||||
expect(hostSource).not.toMatch(/ScumFileConfigWorkbench|GamePlayerIntelligencePanel|GameGiftCatalogPanel|ScumMapTrajectoryPanel|game\.scum/);
|
||||
});
|
||||
|
||||
it("keeps file workspace callbacks stable across parent operational refreshes", () => {
|
||||
it("keeps typed SCUM workspace callbacks stable across parent operational refreshes", () => {
|
||||
expect(hostSource).toContain("readyPluginRef.current = readyPlugin");
|
||||
expect(hostSource).toContain("hostContextRef.current = hostContext");
|
||||
expect(hostSource).toContain("refreshWorkspace: async ()");
|
||||
expect(hostSource).toContain("platformApiClient.listGamePlugins()");
|
||||
expect(hostSource).toContain("listSCUMPlayers: () => platformApiClient.listSCUMPlayers(serverId)");
|
||||
expect(hostSource).toContain("createSCUMWorkflow: (request) => platformApiClient.createSCUMWorkflow(serverId, request as never)");
|
||||
expect(hostSource).toContain("createSCUMOperation: (request) => platformApiClient.createSCUMOperation(serverId, request as never)");
|
||||
expect(hostSource).toContain("listSCUMWorkflowSteps: (workflowId) => platformApiClient.listSCUMWorkflowSteps(serverId, workflowId)");
|
||||
expect(hostSource).not.toContain("refreshWorkspace");
|
||||
expect(hostSource).not.toContain("requestFile");
|
||||
expect(hostSource).not.toContain("writeFile");
|
||||
expect(hostSource).not.toContain("getDeclaredFileReadSnapshot");
|
||||
expect(hostSource).toContain("}, [pluginId, serverId]);");
|
||||
expect(hostSource).not.toContain("}, [hostContext, readyPlugin, serverId]);");
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
|
||||
import type { PluginPageWorkspaceActions } from "../contracts/pluginPageHost";
|
||||
import { createPluginBridgeDispatcher, createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
|
||||
import { createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
|
||||
import { loadPluginPageBundle, type PluginPageAvailability } from "../utils/pluginPageBundles";
|
||||
|
||||
type PluginPageState =
|
||||
@@ -27,7 +27,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
|
||||
const routeKey = params.routeKey ?? "";
|
||||
const serverId = params.serverId ?? "";
|
||||
const [state, setState] = useState<PluginPageState>(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" });
|
||||
const [bundle, setBundle] = useState<ComponentType<{ context: ReturnType<typeof createPluginBridgeHostContext>; workspace?: unknown; workspaceActions?: PluginPageWorkspaceActions; availability: PluginPageAvailability }> | null>(null);
|
||||
const [bundle, setBundle] = useState<ComponentType<{ context: ReturnType<typeof createPluginBridgeHostContext>; workspaceActions?: PluginPageWorkspaceActions; availability: PluginPageAvailability }> | null>(null);
|
||||
const [bundleError, setBundleError] = useState("");
|
||||
const [availability, setAvailability] = useState<PluginPageAvailability>({ available: false, reason: "正在验证 Companion 可用性。" });
|
||||
const readyPluginRef = useRef<GamePluginResponse | undefined>(undefined);
|
||||
@@ -69,97 +69,18 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
|
||||
const workspaceActions = useMemo<PluginPageWorkspaceActions | undefined>(() => {
|
||||
if (!pluginId) return undefined;
|
||||
return {
|
||||
refreshWorkspace: async () => {
|
||||
const response = await platformApiClient.listGamePlugins();
|
||||
const plugin = response.items.find((candidate) => candidate.id === pluginId);
|
||||
if (!plugin) {
|
||||
throw new Error("未找到已注册的插件声明。");
|
||||
}
|
||||
readyPluginRef.current = plugin;
|
||||
setState({ status: "ready", plugin });
|
||||
return plugin.fileWorkspace;
|
||||
},
|
||||
requestFile: async (fileKey) => {
|
||||
const plugin = readyPluginRef.current;
|
||||
const context = hostContextRef.current;
|
||||
if (!serverId) {
|
||||
return { status: "denied", message: "插件页面没有绑定服务器,无法读取文件。" };
|
||||
}
|
||||
if (!plugin || !context) {
|
||||
return { status: "denied", message: "插件页面上下文尚未就绪。" };
|
||||
}
|
||||
if (!plugin.fileWorkspace?.files.some((file) => file.key === fileKey)) {
|
||||
return { status: "denied", message: "该文件不在当前插件声明的工作区内。" };
|
||||
}
|
||||
const dispatch = createPluginBridgeDispatcher(context, platformApiClient);
|
||||
const response = await dispatch({
|
||||
requestId: `web:plugin-file-read:${serverId}:${fileKey}:${Date.now()}`,
|
||||
action: "files.request",
|
||||
payload: { operation: "read", key: fileKey }
|
||||
});
|
||||
const jobId = response.result?.jobId;
|
||||
if (response.status === "queued" || response.status === "ok") {
|
||||
return { status: response.status, jobId, message: jobId ? `读取任务 ${jobId} 已提交。` : "已提交文件读取请求。" };
|
||||
}
|
||||
return { status: response.status, message: response.error?.message ?? "文件读取请求未能提交。" };
|
||||
},
|
||||
getFileSnapshot: async (fileKey) => {
|
||||
const plugin = readyPluginRef.current;
|
||||
if (!serverId) {
|
||||
return { serverInstanceId: "", pluginId, key: fileKey, state: "unavailable", reason: "插件页面没有绑定服务器,无法读取文件快照。" };
|
||||
}
|
||||
if (!plugin) {
|
||||
return { serverInstanceId: serverId, pluginId, key: fileKey, state: "unavailable", reason: "插件页面上下文尚未就绪。" };
|
||||
}
|
||||
if (!plugin.fileWorkspace?.files.some((file) => file.key === fileKey)) {
|
||||
return { serverInstanceId: serverId, pluginId: plugin.id, key: fileKey, state: "unavailable", reason: "该文件不在当前插件声明的工作区内。" };
|
||||
}
|
||||
try {
|
||||
return await platformApiClient.getDeclaredFileReadSnapshot(serverId, fileKey);
|
||||
} catch (error) {
|
||||
return {
|
||||
serverInstanceId: serverId,
|
||||
pluginId: plugin.id,
|
||||
key: fileKey,
|
||||
state: "unavailable",
|
||||
reason: error instanceof Error ? error.message : "无法读取文件快照。"
|
||||
};
|
||||
}
|
||||
},
|
||||
writeFile: async (fileKey, content, options) => {
|
||||
const plugin = readyPluginRef.current;
|
||||
const context = hostContextRef.current;
|
||||
if (!serverId) {
|
||||
return { status: "denied", message: "插件页面没有绑定服务器,无法写入文件。" };
|
||||
}
|
||||
if (!plugin || !context) {
|
||||
return { status: "denied", message: "插件页面上下文尚未就绪。" };
|
||||
}
|
||||
const file = plugin.fileWorkspace?.files.find((candidate) => candidate.key === fileKey);
|
||||
if (!file) {
|
||||
return { status: "denied", message: "该文件不在当前插件声明的工作区内。" };
|
||||
}
|
||||
if (file.kind !== "config" || !file.editable) {
|
||||
return { status: "denied", message: "该声明文件不允许通过配置工作台写入。" };
|
||||
}
|
||||
if (!context.permissions.includes("server.files.write")) {
|
||||
return { status: "denied", message: "当前页面没有声明文件写入权限。" };
|
||||
}
|
||||
try {
|
||||
const response = await platformApiClient.dispatchFileOperation({
|
||||
serverInstanceId: serverId,
|
||||
pluginId: plugin.id,
|
||||
operation: "write",
|
||||
key: fileKey,
|
||||
content,
|
||||
expectedChecksum: options?.expectedChecksum,
|
||||
idempotencyKey: `web:plugin-file-write:${serverId}:${fileKey}:${Date.now()}`
|
||||
});
|
||||
return { status: response.status, jobId: response.job.id, message: `写入任务 ${response.job.id} 已提交。` };
|
||||
} catch (error) {
|
||||
return { status: "error", message: error instanceof Error ? error.message : "文件写入请求未能提交。" };
|
||||
}
|
||||
}
|
||||
listSCUMPlayers: () => platformApiClient.listSCUMPlayers(serverId),
|
||||
listSCUMSquads: () => platformApiClient.listSCUMSquads(serverId),
|
||||
listSCUMSquadMembers: () => platformApiClient.listSCUMSquadMembers(serverId),
|
||||
listSCUMVehicles: () => platformApiClient.listSCUMVehicles(serverId),
|
||||
listSCUMFlags: () => platformApiClient.listSCUMFlags(serverId),
|
||||
listSCUMPositions: () => platformApiClient.listSCUMPositions(serverId),
|
||||
listSCUMOperations: () => platformApiClient.listSCUMOperations(serverId),
|
||||
createSCUMOperation: (request) => platformApiClient.createSCUMOperation(serverId, request as never),
|
||||
approveSCUMOperation: (operationId) => platformApiClient.approveSCUMOperation(serverId, operationId),
|
||||
listSCUMWorkflows: () => platformApiClient.listSCUMWorkflows(serverId),
|
||||
createSCUMWorkflow: (request) => platformApiClient.createSCUMWorkflow(serverId, request as never),
|
||||
listSCUMWorkflowSteps: (workflowId) => platformApiClient.listSCUMWorkflowSteps(serverId, workflowId)
|
||||
};
|
||||
}, [pluginId, serverId]);
|
||||
const bundleLoadKey = declaredBundlePage ? [declaredBundlePage.bundleKey, declaredBundlePage.bundleVersion, declaredBundlePage.bundleIntegritySha256, declaredBundlePage.path].join(":") : "";
|
||||
@@ -196,7 +117,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
|
||||
<>
|
||||
{bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
|
||||
{!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" compact />}
|
||||
{bundle && React.createElement(bundle, { context: hostContext, workspace: state.plugin.fileWorkspace, workspaceActions, availability })}
|
||||
{bundle && React.createElement(bundle, { context: hostContext, workspaceActions, availability })}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -226,7 +147,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
|
||||
</section>
|
||||
{bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
|
||||
{!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" />}
|
||||
{bundle && React.createElement(bundle, { context: hostContext, workspace: state.plugin.fileWorkspace, workspaceActions, availability })}
|
||||
{bundle && React.createElement(bundle, { context: hostContext, workspaceActions, availability })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { configDiffViewFromPreview } from "./ServerDetailPage";
|
||||
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
|
||||
import sourceRCONCommandPanelSource from "../components/SourceRCONCommandPanel.tsx?raw";
|
||||
import artifactTransferSource from "../utils/artifactTransfer.ts?raw";
|
||||
import type { ServerConfigDiffPreviewResponse } from "../api/types";
|
||||
|
||||
const preview: ServerConfigDiffPreviewResponse = {
|
||||
@@ -41,9 +39,11 @@ describe("ServerDetailPage config write approval", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses config preview and approval APIs instead of generic config.write job creation", () => {
|
||||
expect(serverDetailPageSource).toContain("previewServerConfigDiff");
|
||||
expect(serverDetailPageSource).toContain("approveServerConfigWrite");
|
||||
it("uses AI config diff approval without exposing raw config workbench APIs", () => {
|
||||
expect(serverDetailPageSource).toContain("approveAIConfigDiff");
|
||||
expect(serverDetailPageSource).toContain("AI 配置助手");
|
||||
expect(serverDetailPageSource).not.toContain("previewServerConfigDiff");
|
||||
expect(serverDetailPageSource).not.toContain("approveServerConfigWrite");
|
||||
expect(serverDetailPageSource).not.toContain('capability: "config.write"');
|
||||
});
|
||||
|
||||
@@ -62,10 +62,10 @@ describe("ServerDetailPage config write approval", () => {
|
||||
expect(serverDetailPageSource).not.toContain("content: diff.nextContent");
|
||||
});
|
||||
|
||||
it("uses platform-mediated artifact download and bridge references without backend internals", () => {
|
||||
expect(serverDetailPageSource).toContain("openArtifactDownload");
|
||||
expect(serverDetailPageSource).toContain("readArtifactContent");
|
||||
expect(serverDetailPageSource).toContain("浏览器制品传输");
|
||||
it("keeps artifact transfer and backend internals out of server detail", () => {
|
||||
expect(serverDetailPageSource).not.toContain("openArtifactDownload");
|
||||
expect(serverDetailPageSource).not.toContain("readArtifactContent");
|
||||
expect(serverDetailPageSource).not.toContain("浏览器制品传输");
|
||||
expect(serverDetailPageSource).not.toContain("storage://");
|
||||
expect(serverDetailPageSource).not.toContain("unix://");
|
||||
expect(serverDetailPageSource).not.toContain("Bearer ");
|
||||
@@ -79,7 +79,7 @@ describe("ServerDetailPage config write approval", () => {
|
||||
expect(serverDetailPageSource).not.toContain("pushRunUpdate");
|
||||
expect(serverDetailPageSource).not.toContain("generateClientManager");
|
||||
expect(serverDetailPageSource).not.toContain("ClientManagerLifecyclePanel");
|
||||
expect(serverDetailPageSource).toContain("openServerLogEvents");
|
||||
expect(serverDetailPageSource).not.toContain("openServerLogEvents");
|
||||
expect(serverDetailPageSource).not.toContain("authKey");
|
||||
expect(serverDetailPageSource).not.toContain("password=");
|
||||
expect(serverDetailPageSource).not.toContain("unix://");
|
||||
@@ -95,17 +95,12 @@ describe("ServerDetailPage config write approval", () => {
|
||||
expect(serverDetailPageSource).not.toContain("RuntimeDLLExtensionsPanel");
|
||||
});
|
||||
|
||||
it("adds SCUM announcements and raw commands through protected RCON bridge jobs", () => {
|
||||
expect(serverDetailPageSource).toContain("SourceRCONCommandPanel");
|
||||
expect(sourceRCONCommandPanelSource).toContain("queueGameClientBridgeCommand");
|
||||
expect(sourceRCONCommandPanelSource).toContain("scumManagementRCONCommandRequest");
|
||||
expect(sourceRCONCommandPanelSource).toContain("不保留指令原文");
|
||||
expect(sourceRCONCommandPanelSource).toContain("执行结果以 Run 日志为准");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("ConfirmDialog");
|
||||
expect(sourceRCONCommandPanelSource).not.toContain("operations.");
|
||||
for (const forbidden of ["password", "host", "transcript", "history"]) {
|
||||
expect(sourceRCONCommandPanelSource).not.toContain(forbidden);
|
||||
}
|
||||
it("does not expose raw SCUM RCON command panels", () => {
|
||||
expect(serverDetailPageSource).not.toContain("SourceRCONCommandPanel");
|
||||
expect(serverDetailPageSource).not.toContain("queueGameClientBridgeCommand");
|
||||
expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest");
|
||||
expect(serverDetailPageSource).not.toContain("管理终端");
|
||||
expect(serverDetailPageSource).toContain("PluginPageHostPage");
|
||||
});
|
||||
|
||||
it("removes generic plugin controls from server detail", () => {
|
||||
@@ -136,12 +131,9 @@ describe("ServerDetailPage config write approval", () => {
|
||||
expect(serverDetailPageSource).not.toContain("ScumFileManagementSection");
|
||||
});
|
||||
|
||||
it("keeps plugin lifecycle and bridge-visible output on platform-owned logical references", () => {
|
||||
expect(serverDetailPageSource).toContain("platformApiClient.openArtifactDownload(artifact.id)");
|
||||
expect(serverDetailPageSource).toContain("downloadArtifactReference(reference");
|
||||
expect(artifactTransferSource).toContain("readContent(reference.artifactId");
|
||||
expect(artifactTransferSource).toContain("replace(/Bearer\\s+[^\\s]+/gi, \"[token]\")");
|
||||
expect(artifactTransferSource).toContain("replace(/sk-[A-Za-z0-9_-]+/g, \"[secret]\")");
|
||||
it("keeps plugin lifecycle output out of raw bridge-visible detail surfaces", () => {
|
||||
expect(serverDetailPageSource).not.toContain("platformApiClient.openArtifactDownload(artifact.id)");
|
||||
expect(serverDetailPageSource).not.toContain("downloadArtifactReference(reference");
|
||||
expect(serverDetailPageSource).not.toContain("storage://bucket");
|
||||
expect(serverDetailPageSource).not.toContain("runSocket");
|
||||
expect(serverDetailPageSource).not.toContain("rawApiKey");
|
||||
|
||||
@@ -1,36 +1,25 @@
|
||||
import { Download, MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, Terminal, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
|
||||
import { MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
|
||||
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type {
|
||||
ConfigDiffLineResponse,
|
||||
ArtifactResponse,
|
||||
BackupResponse,
|
||||
GamePluginResponse,
|
||||
JobResponse,
|
||||
LogStreamResponse,
|
||||
ServerConfigDiffPreviewResponse,
|
||||
ServerConfigResponse,
|
||||
ServerInstanceResponse,
|
||||
ServerMemberResponse,
|
||||
ServerMetricsResponse,
|
||||
ServerDeploymentResponse,
|
||||
MetricSampleResponse,
|
||||
RemoteAdapterDeclarationResponse,
|
||||
ServerConfigDiffPreviewResponse,
|
||||
RunEndpointResponse
|
||||
} from "../api/types";
|
||||
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
|
||||
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
|
||||
import { SourceRCONCommandPanel } from "../components/SourceRCONCommandPanel";
|
||||
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||
import { ConfirmDialog, UsageMeter } from "../components/OperationControls";
|
||||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import { jobCapabilityLabel } from "../contracts/jobPresentation";
|
||||
import { canStartServer, canStopServer, runtimeObservationFreshness, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
|
||||
import { ServerManagementTerminalDrawer } from "../components/ServerLiveOperations";
|
||||
import {
|
||||
serverDetailSections,
|
||||
serverIsOnline,
|
||||
isPlatformAdmin,
|
||||
type ConfigDiffView,
|
||||
type LlmSuggestionView,
|
||||
type ServerDetailSection
|
||||
@@ -39,34 +28,25 @@ import {
|
||||
serverLifecycleCommandRequest,
|
||||
serverMetadataUpdateRequestFromForm
|
||||
} from "../schemas/serverManagement";
|
||||
import { diffHasChanges } from "../utils/diff";
|
||||
import { downloadArtifactReference, safeArtifactError, safeArtifactFilename } from "../utils/artifactTransfer";
|
||||
import { cx } from "../utils/classes";
|
||||
import { stateLabel, statusClass } from "./ServersPage";
|
||||
import { PluginPageHostPage } from "./PluginPageHostPage";
|
||||
import { appendLiveLogEntries, entryFromServerLogEvent, mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent, type LiveLogEntry } from "../utils/logEvents";
|
||||
|
||||
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
||||
|
||||
const defaultConfigKey = "server.properties";
|
||||
const serverDetailRefreshMs = 5000;
|
||||
const serverMetricFreshMs = 30000;
|
||||
|
||||
export function ServerDetailPage(props: PageComponentProps) {
|
||||
const { session, params, operations, onNavigate } = props;
|
||||
const serverId = params.serverId ?? "";
|
||||
const [section, setSection] = useState<ServerDetailSection>("logs");
|
||||
const [section, setSection] = useState<ServerDetailSection>("manage");
|
||||
const [instance, setInstance] = useState<LoadState<ServerInstanceResponse>>({ status: "loading" });
|
||||
const [metrics, setMetrics] = useState<ServerMetricsResponse | null>(null);
|
||||
const [plugins, setPlugins] = useState<GamePluginResponse[]>([]);
|
||||
const [jobs, setJobs] = useState<JobResponse[]>([]);
|
||||
const [artifacts, setArtifacts] = useState<ArtifactResponse[]>([]);
|
||||
const [metricHistory, setMetricHistory] = useState<MetricSampleResponse[]>([]);
|
||||
const [backups, setBackups] = useState<BackupResponse[]>([]);
|
||||
const [remoteAdapters, setRemoteAdapters] = useState<RemoteAdapterDeclarationResponse[]>([]);
|
||||
const [runEndpoint, setRunEndpoint] = useState<RunEndpointResponse | undefined>();
|
||||
const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" });
|
||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
|
||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||
|
||||
@@ -77,7 +57,7 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
}
|
||||
setInstance({ status: "loading" });
|
||||
try {
|
||||
const [detail, pluginResponse, jobResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse, endpointResponse] = await Promise.all([
|
||||
const [detail, pluginResponse, jobResponse, deploymentResponse, endpointResponse] = await Promise.all([
|
||||
platformApiClient.getServerInstance(serverId),
|
||||
platformApiClient.listGamePlugins(),
|
||||
platformApiClient.listJobs(serverId),
|
||||
@@ -85,36 +65,17 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
.getServerDeployment(serverId)
|
||||
.then((data): LoadState<ServerDeploymentResponse> => ({ status: "ready", data }))
|
||||
.catch((error): LoadState<ServerDeploymentResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "部署定义加载失败" })),
|
||||
platformApiClient.listMetricHistory(serverId).catch(() => ({ items: [], count: 0 })),
|
||||
platformApiClient.listBackups(serverId).catch(() => ({ items: [], count: 0 })),
|
||||
platformApiClient.listRemoteAdapters(serverId).catch(() => ({ items: [], count: 0 })),
|
||||
platformApiClient.listRunEndpoints().catch(() => ({ items: [], count: 0 }))
|
||||
]);
|
||||
setInstance({ status: "ready", data: detail });
|
||||
setPlugins(pluginResponse.items);
|
||||
setJobs(jobResponse.items);
|
||||
setDeployment(deploymentResponse);
|
||||
setMetricHistory(metricHistoryResponse.items);
|
||||
setBackups(backupResponse.items);
|
||||
setRemoteAdapters(adapterResponse.items);
|
||||
setRunEndpoint(endpointResponse.items.find((endpoint) => endpoint.id === detail.runEndpointId));
|
||||
const artifactLists = await Promise.all(
|
||||
jobResponse.items.slice(0, 20).map((job) =>
|
||||
platformApiClient
|
||||
.listArtifacts({ ownerKind: "job", ownerId: job.id, state: "available" })
|
||||
.then((response) => response.items)
|
||||
.catch(() => [] as ArtifactResponse[])
|
||||
)
|
||||
);
|
||||
setArtifacts(uniqueArtifacts(artifactLists.flat()));
|
||||
} catch (error) {
|
||||
setInstance({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
|
||||
setArtifacts([]);
|
||||
setRunEndpoint(undefined);
|
||||
setDeployment({ status: "error", reason: "部署定义加载失败" });
|
||||
setMetricHistory([]);
|
||||
setBackups([]);
|
||||
setRemoteAdapters([]);
|
||||
}
|
||||
try {
|
||||
const metricsResponse = await platformApiClient.listServerMetrics();
|
||||
@@ -151,10 +112,6 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refreshOperationalState]);
|
||||
|
||||
const serverOperations = useMemo(
|
||||
() => operations.operations.filter((operation) => operation.targetId === serverId || operation.targetId.startsWith(`${serverId}:`)),
|
||||
[operations.operations, serverId]
|
||||
);
|
||||
const canManageServers = session.capabilities.includes("servers.manage");
|
||||
const readyPlugin = instance.status === "ready" ? plugins.find((plugin) => plugin.id === instance.data.pluginId) : undefined;
|
||||
const detailFreshness = instance.status === "ready" ? runtimeObservationFreshness(instance.data, runEndpoint) : "unverified";
|
||||
@@ -168,7 +125,7 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (detailSections.some((entry) => entry.id === section)) return;
|
||||
setSection(detailSections[0]?.id ?? "logs");
|
||||
setSection(detailSections[0]?.id ?? "manage");
|
||||
}, [detailSections, section]);
|
||||
|
||||
function requestLifecycle(current: ServerInstanceResponse, action: "start" | "stop") {
|
||||
@@ -270,7 +227,6 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
<Square size={15} />
|
||||
<span>停止</span>
|
||||
</button>
|
||||
<button type="button" className="icon-command" disabled={!canManageServers} title={canManageServers ? "管理终端" : "当前账号没有管理权限"} onClick={() => setTerminalOpen(true)}><Terminal size={15} /><span>管理终端</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="server-detail-stat-strip">
|
||||
@@ -301,11 +257,9 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{section === "logs" && <LogsSection serverId={serverId} />}
|
||||
{section === "terminal" && <SourceRCONCommandPanel serverId={instance.data.id} pluginId={instance.data.pluginId} />}
|
||||
{pluginPageKeyFromSection(section) && readyPlugin && <PluginPageSection pageProps={props} serverId={serverId} plugin={readyPlugin} routeKey={pluginPageKeyFromSection(section) ?? ""} />}
|
||||
{section === "config" && <ServerDeploymentSection instance={instance.data} deployment={deployment} />}
|
||||
{section === "config" && (
|
||||
{section === "manage" && <ServerDeploymentSection instance={instance.data} deployment={deployment} />}
|
||||
{section === "manage" && (
|
||||
<ServerMetadataSection
|
||||
instance={instance.data}
|
||||
session={session}
|
||||
@@ -313,11 +267,8 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
onChanged={(next) => setInstance({ status: "ready", data: next })}
|
||||
/>
|
||||
)}
|
||||
{section === "config" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
|
||||
{section === "config" && <ConfigSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
|
||||
{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 === "history" && <HistorySection serverId={serverId} serverOperations={serverOperations} jobs={jobs} artifacts={artifacts} metricHistory={metricHistory} backups={backups} remoteAdapters={remoteAdapters} />}
|
||||
<ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} pluginId={instance.data.pluginId} canManage={canManageServers} onClose={() => setTerminalOpen(false)} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -344,14 +295,6 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueArtifacts(artifacts: ArtifactResponse[]): ArtifactResponse[] {
|
||||
const byID = new Map<string, ArtifactResponse>();
|
||||
for (const artifact of artifacts) {
|
||||
byID.set(artifact.id, artifact);
|
||||
}
|
||||
return [...byID.values()];
|
||||
}
|
||||
|
||||
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];
|
||||
@@ -619,343 +562,6 @@ function metricFreshnessLabel(metrics: ServerMetricsResponse | null): string {
|
||||
return new Date(metrics.collectedAt).toLocaleTimeString();
|
||||
}
|
||||
|
||||
interface LogsSectionProps {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
interface LogFilterState {
|
||||
level: string;
|
||||
keyword: string;
|
||||
source: string;
|
||||
sinceMinutes: string;
|
||||
}
|
||||
|
||||
function LogsSection({ serverId }: LogsSectionProps) {
|
||||
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
|
||||
const [entries, setEntries] = useState<LiveLogEntry[]>([]);
|
||||
const [filter, setFilter] = useState<LogFilterState>({ level: "all", keyword: "", source: "all", sinceMinutes: "all" });
|
||||
const [selected, setSelected] = useState<LiveLogEntry | null>(null);
|
||||
const [eventSourceKey, setEventSourceKey] = useState(0);
|
||||
|
||||
const refresh = useCallback(() => setEventSourceKey((current) => current + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
setStreams({ status: "loading" });
|
||||
setEntries([]);
|
||||
setSelected(null);
|
||||
let ready = false;
|
||||
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: 200 });
|
||||
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: [] });
|
||||
});
|
||||
events.addEventListener("log", (event) => {
|
||||
const payload = parseServerLogEvent(event);
|
||||
if (!payload) return;
|
||||
ready = true;
|
||||
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], streamFromServerLogEvent(payload)) }));
|
||||
setEntries((current) => appendLiveLogEntries(current, [entryFromServerLogEvent(payload)], 1000));
|
||||
});
|
||||
events.onerror = () => {
|
||||
if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" });
|
||||
};
|
||||
return () => events.close();
|
||||
}, [eventSourceKey, serverId]);
|
||||
|
||||
const sources = useMemo(() => [...new Set(entries.map((entry) => entry.source))], [entries]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const keyword = filter.keyword.trim().toLowerCase();
|
||||
const sinceMs = filter.sinceMinutes === "all" ? null : Date.now() - Number(filter.sinceMinutes) * 60_000;
|
||||
return entries.filter((entry) => {
|
||||
if (filter.level !== "all" && (entry.level ?? "info").toLowerCase() !== filter.level) {
|
||||
return false;
|
||||
}
|
||||
if (filter.source !== "all" && entry.source !== filter.source) {
|
||||
return false;
|
||||
}
|
||||
if (keyword && !entry.line.toLowerCase().includes(keyword)) {
|
||||
return false;
|
||||
}
|
||||
if (sinceMs !== null && new Date(entry.timestamp).getTime() < sinceMs) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).sort(compareLogEntriesDesc);
|
||||
}, [entries, filter]);
|
||||
|
||||
return (
|
||||
<article className="console-panel" aria-label="server logs">
|
||||
<div className="panel-header">
|
||||
<h2>日志</h2>
|
||||
<button type="button" className="icon-command" onClick={refresh}>
|
||||
<Sparkles size={14} />
|
||||
<span>重连</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="log-filter-bar">
|
||||
<select value={filter.level} aria-label="按级别过滤" onChange={(event) => setFilter((current) => ({ ...current, level: event.target.value }))}>
|
||||
<option value="all">全部级别</option>
|
||||
<option value="error">error</option>
|
||||
<option value="warn">warn</option>
|
||||
<option value="info">info</option>
|
||||
<option value="debug">debug</option>
|
||||
</select>
|
||||
<select value={filter.source} aria-label="按来源过滤" onChange={(event) => setFilter((current) => ({ ...current, source: event.target.value }))}>
|
||||
<option value="all">全部来源</option>
|
||||
{sources.map((source) => (
|
||||
<option key={source} value={source}>
|
||||
{source}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filter.sinceMinutes}
|
||||
aria-label="按时间过滤"
|
||||
onChange={(event) => setFilter((current) => ({ ...current, sinceMinutes: event.target.value }))}
|
||||
>
|
||||
<option value="all">全部时间</option>
|
||||
<option value="15">最近 15 分钟</option>
|
||||
<option value="60">最近 1 小时</option>
|
||||
<option value="1440">最近 24 小时</option>
|
||||
</select>
|
||||
<input
|
||||
type="search"
|
||||
value={filter.keyword}
|
||||
placeholder="关键字"
|
||||
aria-label="按关键字过滤"
|
||||
onChange={(event) => setFilter((current) => ({ ...current, keyword: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
{streams.status === "loading" && <LoadingState label="正在加载日志…" compact />}
|
||||
{streams.status === "error" && <ErrorState title="日志加载失败" reason={streams.reason} diagnosticId={`logs:${serverId}`} onRetry={refresh} compact />}
|
||||
{streams.status === "ready" && entries.length === 0 && (
|
||||
<EmptyState title="暂无日志" description="该服务器还没有已入库的日志流,或运行端尚未上报日志。" actionLabel="重连" onAction={refresh} />
|
||||
)}
|
||||
{streams.status === "ready" && entries.length > 0 && visible.length === 0 && (
|
||||
<EmptyState title="没有匹配的日志" description="调整级别、来源、时间范围或关键字后再试。" />
|
||||
)}
|
||||
{visible.length > 0 && (
|
||||
<div className="log-list" role="list">
|
||||
{visible.map((entry) => (
|
||||
<button key={`${entry.streamId}-${entry.seq}`} type="button" className="log-line" role="listitem" onClick={() => setSelected(entry)}>
|
||||
<time>{new Date(entry.timestamp).toLocaleTimeString()}</time>
|
||||
<span className={cx("log-level", levelClass(entry.level))}>{(entry.level ?? "info").toUpperCase()}</span>
|
||||
<span>{entry.line}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<div className="drawer-backdrop" role="presentation" onClick={() => setSelected(null)}>
|
||||
<div className="drawer-panel" role="dialog" aria-modal="true" aria-label="日志详情" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="panel-header">
|
||||
<h2>日志详情</h2>
|
||||
<button type="button" className="drawer-close" onClick={() => setSelected(null)}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<dl className="detail-list">
|
||||
<div>
|
||||
<dt>时间</dt>
|
||||
<dd>{new Date(selected.timestamp).toLocaleString()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>级别</dt>
|
||||
<dd>{(selected.level ?? "info").toUpperCase()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>来源</dt>
|
||||
<dd>{selected.source}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>序号</dt>
|
||||
<dd>{selected.seq}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>内容</dt>
|
||||
<dd>{selected.line}</dd>
|
||||
</div>
|
||||
{selected.fields && Object.keys(selected.fields).length > 0 && (
|
||||
<div>
|
||||
<dt>字段</dt>
|
||||
<dd>
|
||||
{Object.entries(selected.fields)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(" ")}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
<DiagnosticSummary diagnosticId={`log:${serverId}:${selected.source}:${selected.seq}`} detail={selected.line} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function levelClass(level?: string): string {
|
||||
const normalized = (level ?? "info").toLowerCase();
|
||||
if (normalized === "error" || normalized === "fatal") {
|
||||
return "log-level-error";
|
||||
}
|
||||
if (normalized === "warn" || normalized === "warning") {
|
||||
return "log-level-warn";
|
||||
}
|
||||
return "log-level-info";
|
||||
}
|
||||
|
||||
function compareLogEntriesDesc(a: LiveLogEntry, b: LiveLogEntry): number {
|
||||
const time = (Date.parse(b.timestamp) || 0) - (Date.parse(a.timestamp) || 0);
|
||||
if (time !== 0) return time;
|
||||
return b.seq - a.seq;
|
||||
}
|
||||
|
||||
interface ConfigSectionProps {
|
||||
serverId: string;
|
||||
instance: ServerInstanceResponse;
|
||||
session: PageComponentProps["session"];
|
||||
operations: PageComponentProps["operations"];
|
||||
}
|
||||
|
||||
function ConfigSection({ serverId, instance, session, operations }: ConfigSectionProps) {
|
||||
const [config, setConfig] = useState<LoadState<{ content: string; source: "api" | "local" }>>({ status: "loading" });
|
||||
const [draft, setDraft] = useState("");
|
||||
const [diff, setDiff] = useState<ConfigDiffView | null>(null);
|
||||
const [previewBusy, setPreviewBusy] = useState(false);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setConfig({ status: "loading" });
|
||||
try {
|
||||
const response: ServerConfigResponse = await platformApiClient.getServerConfig(serverId);
|
||||
setConfig({ status: "ready", data: { content: response.content, source: "api" } });
|
||||
setDraft(response.content);
|
||||
} catch (error) {
|
||||
setConfig({ status: "error", reason: error instanceof Error ? error.message : "配置读取接口不可用" });
|
||||
setDraft("");
|
||||
}
|
||||
}, [serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
async function prepareDiff(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (config.status !== "ready") {
|
||||
return;
|
||||
}
|
||||
setPreviewBusy(true);
|
||||
setPreviewError(null);
|
||||
try {
|
||||
const preview = await platformApiClient.previewServerConfigDiff(serverId, {
|
||||
expectedConfigVersion: instance.configVersion,
|
||||
expectedChecksum: instance.configChecksum,
|
||||
key: defaultConfigKey,
|
||||
proposedContent: draft
|
||||
});
|
||||
setDiff(configDiffViewFromPreview(preview));
|
||||
} catch (error) {
|
||||
setPreviewError(error instanceof Error ? error.message : "配置差异预览失败");
|
||||
} finally {
|
||||
setPreviewBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDiff() {
|
||||
if (!diff || config.status !== "ready") {
|
||||
return;
|
||||
}
|
||||
const operationId = operations.begin({ intent: "写入配置", targetKind: "config", targetId: serverId, requester: session.displayName });
|
||||
try {
|
||||
const dispatch = await platformApiClient.approveServerConfigWrite(serverId, {
|
||||
expectedConfigVersion: diff.configVersion ?? instance.configVersion,
|
||||
expectedChecksum: diff.checksum ?? instance.configChecksum,
|
||||
key: diff.key ?? defaultConfigKey,
|
||||
proposedContent: diff.nextContent,
|
||||
proposedContentInputRef: diff.proposedContentInputRef,
|
||||
idempotencyKey: `web:config.write:${serverId}:${Date.now()}`
|
||||
});
|
||||
const job = dispatch.job;
|
||||
operations.succeed(operationId, `配置写入任务 ${job.id} 已派发`, job);
|
||||
setDiff(null);
|
||||
} catch (error) {
|
||||
operations.fail(operationId, error instanceof Error ? error.message : "配置写入任务派发失败", operationId);
|
||||
}
|
||||
}
|
||||
|
||||
const writeOperation = operations.operations.find((operation) => operation.intent === "写入配置" && operation.targetId === serverId);
|
||||
|
||||
return (
|
||||
<article className="console-panel" aria-label="server configuration">
|
||||
<div className="panel-header">
|
||||
<h2>配置</h2>
|
||||
{config.status === "ready" && <span className="page-status">配置版本 v{instance.configVersion}{instance.configChecksum ? ` · ${instance.configChecksum.slice(0, 18)}` : ""}</span>}
|
||||
</div>
|
||||
{writeOperation && (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<ResultBadge
|
||||
status={writeOperation.status}
|
||||
label={
|
||||
writeOperation.status === "pending"
|
||||
? "配置写入中…"
|
||||
: writeOperation.status === "succeeded"
|
||||
? (writeOperation.message ?? "写入任务已派发")
|
||||
: `写入失败:${writeOperation.errorReason}(诊断 ${writeOperation.diagnosticId})`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{config.status === "loading" && <LoadingState label="正在加载配置…" compact />}
|
||||
{config.status === "error" && <ErrorState title="配置读取不可用" reason={config.reason} diagnosticId={`server-config:${serverId}`} onRetry={() => void refresh()} compact />}
|
||||
{previewError && <ErrorState title="配置差异预览失败" reason={previewError} compact />}
|
||||
{config.status === "ready" && (
|
||||
<form className="provider-form" style={{ border: 0, padding: 0 }} onSubmit={(event) => void prepareDiff(event)}>
|
||||
<label>
|
||||
配置内容
|
||||
<textarea value={draft} onChange={(event) => setDraft(event.target.value)} rows={10} aria-label="配置编辑器" />
|
||||
</label>
|
||||
<button type="submit" className="primary-command" disabled={previewBusy || draft === config.data.content}>
|
||||
{previewBusy ? "预览中…" : "预览变更"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{diff && (
|
||||
<div className="drawer-backdrop" role="presentation" onClick={() => setDiff(null)}>
|
||||
<div className="drawer-panel" role="dialog" aria-modal="true" aria-label="配置变更确认" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="panel-header">
|
||||
<h2>确认配置变更</h2>
|
||||
<span className="page-status">{diff.summary}</span>
|
||||
</div>
|
||||
<p style={{ margin: 0, color: "var(--ink-soft)", fontSize: 14 }}>
|
||||
目标服务器:<strong>{instance.name}</strong>({serverId})。请检查平台返回的差异,确认后才会派发写入任务。
|
||||
</p>
|
||||
<DiffView lines={diff.lines} />
|
||||
<div className="confirm-actions">
|
||||
<button type="button" onClick={() => setDiff(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="confirm-primary" disabled={!diffHasChanges(diff)} onClick={() => void submitDiff()}>
|
||||
确认并派发写入任务
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
interface LlmSectionProps {
|
||||
serverId: string;
|
||||
instance: ServerInstanceResponse;
|
||||
@@ -985,21 +591,13 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
|
||||
throw new Error(response.error?.message ?? "AI 提供商未返回可用建议");
|
||||
}
|
||||
const recommendation = response.configRecommendation;
|
||||
const preview = recommendation?.suggestedConfig
|
||||
? await platformApiClient.previewServerConfigDiff(serverId, {
|
||||
expectedConfigVersion: instance.configVersion,
|
||||
expectedChecksum: instance.configChecksum,
|
||||
key: recommendation.key,
|
||||
proposedContent: recommendation.suggestedConfig
|
||||
})
|
||||
: undefined;
|
||||
setSuggestion({
|
||||
serverInstanceId: serverId,
|
||||
source: "api",
|
||||
recommendation: response.recommendation ?? "Platform 已返回配置建议。",
|
||||
diffId: recommendation?.diffId,
|
||||
expiresAt: recommendation?.expiresAt,
|
||||
diff: preview ? configDiffViewFromPreview(preview) : undefined
|
||||
diffSummary: recommendation?.diffSummary
|
||||
});
|
||||
} catch (caught) {
|
||||
setSuggestionError(caught instanceof Error ? caught.message : "AI 建议请求失败");
|
||||
@@ -1009,7 +607,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
|
||||
}
|
||||
|
||||
async function applySuggestion() {
|
||||
if (!suggestion?.diff || !suggestion.diffId || approvalBusy) {
|
||||
if (!suggestion?.diffId || approvalBusy) {
|
||||
return;
|
||||
}
|
||||
const operationId = operations.begin({ intent: "应用 AI 配置建议", targetKind: "llm", targetId: serverId, requester: session.displayName });
|
||||
@@ -1078,15 +676,19 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
|
||||
<span className="page-status">平台 AI Provider</span>
|
||||
</div>
|
||||
<p style={{ margin: 0, color: "var(--ink-soft)", fontSize: 14 }}>{suggestion.recommendation}</p>
|
||||
{suggestion.diff ? (
|
||||
{suggestion.diffId ? (
|
||||
<>
|
||||
<DiffView lines={suggestion.diff.lines} />
|
||||
<div className="console-record">
|
||||
<div className="console-record-head"><strong>Reviewable AI diff</strong><span className="status-pill status-active">pending</span></div>
|
||||
<div className="console-record-meta"><span>Diff {suggestion.diffId}</span>{suggestion.expiresAt && <span>到期 {new Date(suggestion.expiresAt).toLocaleString()}</span>}</div>
|
||||
<span className="provider-id">{suggestion.diffSummary ?? "平台已保存可审查配置差异;批准后才会派发写入任务。"}</span>
|
||||
</div>
|
||||
<div className="confirm-actions">
|
||||
<button type="button" onClick={() => setSuggestion(null)}>
|
||||
放弃建议
|
||||
</button>
|
||||
<button type="button" className="confirm-primary" onClick={() => setConfirming(true)}>
|
||||
确认差异并写入
|
||||
审批 AI 差异
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
@@ -1132,207 +734,3 @@ function configDiffLineFromPreviewLine(line: ConfigDiffLineResponse): ConfigDiff
|
||||
text: line.content
|
||||
};
|
||||
}
|
||||
|
||||
interface HistorySectionProps {
|
||||
serverId: string;
|
||||
serverOperations: PageComponentProps["operations"]["operations"];
|
||||
jobs: JobResponse[];
|
||||
artifacts: ArtifactResponse[];
|
||||
metricHistory: MetricSampleResponse[];
|
||||
backups: BackupResponse[];
|
||||
remoteAdapters: RemoteAdapterDeclarationResponse[];
|
||||
}
|
||||
|
||||
function HistorySection({ serverId, serverOperations, jobs, artifacts, metricHistory, backups, remoteAdapters }: HistorySectionProps) {
|
||||
return (
|
||||
<>
|
||||
<ProductionGovernancePanel compact title={`服务器 ${serverId} 的容量与告警`} />
|
||||
<div className="overview-two-col" aria-label="operation history">
|
||||
<article className="console-panel">
|
||||
<div className="panel-header">
|
||||
<h2>本次会话操作</h2>
|
||||
</div>
|
||||
{serverOperations.length === 0 ? (
|
||||
<EmptyState title="暂无操作记录" description="在本页发起启动、停止、插件页面操作或配置写入后,这里会显示完整的操作生命周期。" />
|
||||
) : (
|
||||
<div className="console-record-list">
|
||||
{serverOperations.map((operation) => (
|
||||
<div key={operation.id} className="console-record">
|
||||
<div className="console-record-head">
|
||||
<strong>{operation.intent}</strong>
|
||||
<ResultBadge
|
||||
status={operation.status}
|
||||
label={operation.status === "pending" ? "进行中" : operation.status === "succeeded" ? "成功" : "失败"}
|
||||
/>
|
||||
</div>
|
||||
<div className="console-record-meta">
|
||||
<span>
|
||||
操作 <code>{operation.id}</code>
|
||||
</span>
|
||||
{operation.jobId && (
|
||||
<span>
|
||||
任务 <code>{operation.jobId}</code>
|
||||
{operation.jobState ? `(${operation.jobState})` : ""}
|
||||
</span>
|
||||
)}
|
||||
<span>目标 {operation.targetId}</span>
|
||||
<span>发起人 {operation.requester}</span>
|
||||
<span>{new Date(operation.updatedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
{operation.message && <span className="provider-id">{operation.message}</span>}
|
||||
{operation.status === "failed" && operation.errorReason && (
|
||||
<span className="provider-id">
|
||||
失败原因:{operation.errorReason}
|
||||
{operation.diagnosticId ? `(诊断 ${operation.diagnosticId})` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
<article className="console-panel">
|
||||
<div className="panel-header">
|
||||
<h2>平台任务记录</h2>
|
||||
</div>
|
||||
{jobs.length === 0 ? (
|
||||
<EmptyState title="暂无任务" description="该服务器还没有平台侧任务记录。" />
|
||||
) : (
|
||||
<div className="console-record-list">
|
||||
{jobs.slice(0, 20).map((job) => (
|
||||
<div key={job.id} className="console-record">
|
||||
<div className="console-record-head">
|
||||
<strong>{jobCapabilityLabel(job.capability)}</strong>
|
||||
<span className={cx("status-pill", job.state === "succeeded" ? "status-active" : job.state === "failed" ? "status-error" : "status-disabled")}>
|
||||
{job.state}
|
||||
</span>
|
||||
</div>
|
||||
<div className="console-record-meta">
|
||||
<span>
|
||||
任务 <code>{job.id}</code>
|
||||
</span>
|
||||
<span>进度 {job.progress.percent}%</span>
|
||||
{job.progress.phase && <span>阶段:{deploymentProgressLabel(job.progress)}</span>}
|
||||
<span>
|
||||
尝试 {job.attempt}/{job.retryPolicy.maxAttempts}
|
||||
</span>
|
||||
{job.nextAttemptAt && <span>下次尝试 {new Date(job.nextAttemptAt).toLocaleString()}</span>}
|
||||
{job.lastReconciledAt && <span>最近协调 {new Date(job.lastReconciledAt).toLocaleString()}</span>}
|
||||
<span>{new Date(job.updatedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
{job.progress.message && <span className="provider-id">{job.progress.message}</span>}
|
||||
{job.cancelReason && <span className="provider-id">取消原因:{job.cancelReason}</span>}
|
||||
{job.reconcileOutcome && <span className="provider-id">协调结果:{job.reconcileOutcome}</span>}
|
||||
{job.executionResult && (job.executionResult.processState || job.executionResult.checksum || job.executionResult.version !== undefined) && (
|
||||
<span className="provider-id">
|
||||
执行结果:{job.executionResult.processState ?? job.executionResult.kind ?? "已记录"}
|
||||
{job.executionResult.version !== undefined ? ` · v${job.executionResult.version}` : ""}
|
||||
{job.executionResult.checksum ? ` · ${job.executionResult.checksum.slice(0, 18)}` : ""}
|
||||
{job.executionResult.sizeBytes !== undefined ? ` · ${job.executionResult.sizeBytes} B` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
<ArtifactDownloadPanel serverId={serverId} artifacts={artifacts} />
|
||||
<article className="console-panel" aria-label="durable observability">
|
||||
<div className="panel-header">
|
||||
<h2>持久化观测</h2>
|
||||
</div>
|
||||
<div className="console-record-list">
|
||||
<div className="console-record">
|
||||
<div className="console-record-head"><strong>指标样本</strong><span className="status-pill status-active">{metricHistory.length} 条</span></div>
|
||||
<div className="console-record-meta"><span>最新采集 {metricHistory.length > 0 ? new Date(metricHistory[metricHistory.length - 1].collectedAt).toLocaleString() : "暂无"}</span></div>
|
||||
</div>
|
||||
<div className="console-record">
|
||||
<div className="console-record-head"><strong>备份记录</strong><span className="status-pill status-active">{backups.length} 条</span></div>
|
||||
<div className="console-record-meta">{backups.slice(0, 4).map((backup) => <span key={backup.id}>{backup.id} · {backup.state} · {backup.checksum.slice(0, 18)}</span>)}</div>
|
||||
</div>
|
||||
<div className="console-record">
|
||||
<div className="console-record-head"><strong>远端适配器声明</strong><span className="status-pill status-active">{remoteAdapters.length} 个</span></div>
|
||||
<div className="console-record-meta">{remoteAdapters.slice(0, 4).map((adapter) => <span key={adapter.key}>{adapter.key} · {adapter.kind} · {adapter.targetKeys.join(", ")}</span>)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ArtifactDownloadPanelProps {
|
||||
serverId: string;
|
||||
artifacts: ArtifactResponse[];
|
||||
}
|
||||
|
||||
function ArtifactDownloadPanel({ serverId, artifacts }: ArtifactDownloadPanelProps) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<Record<string, { status: "pending" | "succeeded" | "failed"; label: string; progress?: number }>>({});
|
||||
|
||||
async function downloadArtifact(artifact: ArtifactResponse) {
|
||||
setActiveId(artifact.id);
|
||||
setResult((current) => ({ ...current, [artifact.id]: { status: "pending", label: "正在打开制品", progress: 0 } }));
|
||||
try {
|
||||
const reference = await platformApiClient.openArtifactDownload(artifact.id);
|
||||
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit), (progress) => {
|
||||
setResult((current) => ({ ...current, [artifact.id]: { status: "pending", label: `传输 ${progress}%`, progress } }));
|
||||
});
|
||||
setResult((current) => ({ ...current, [artifact.id]: { status: "succeeded", label: `已打开 ${safeArtifactFilename(reference.filename)}`, progress: 100 } }));
|
||||
} catch (error) {
|
||||
setResult((current) => ({ ...current, [artifact.id]: { status: "failed", label: safeArtifactError(error) } }));
|
||||
} finally {
|
||||
setActiveId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="console-panel">
|
||||
<div className="panel-header">
|
||||
<h2>
|
||||
<PackageOpen size={16} />
|
||||
浏览器制品传输
|
||||
</h2>
|
||||
</div>
|
||||
{artifacts.length === 0 ? (
|
||||
<EmptyState title="暂无可下载制品" description="该服务器当前没有已完成的可用制品。" />
|
||||
) : (
|
||||
<div className="console-record-list">
|
||||
{artifacts.slice(0, 12).map((artifact) => {
|
||||
const itemResult = result[artifact.id];
|
||||
return (
|
||||
<div key={artifact.id} className="console-record">
|
||||
<div className="console-record-head">
|
||||
<strong>{artifact.id}</strong>
|
||||
<ResultBadge status={itemResult?.status ?? "pending"} label={itemResult?.label ?? artifact.state} />
|
||||
</div>
|
||||
<div className="console-record-meta">
|
||||
<span>服务器 {serverId}</span>
|
||||
<span>{formatBytes(artifact.sizeBytes)}</span>
|
||||
<span>{artifact.checksum}</span>
|
||||
</div>
|
||||
{itemResult?.progress !== undefined && <UsageMeter label="传输" percent={itemResult.progress} />}
|
||||
<div className="action-strip">
|
||||
<button type="button" className="icon-command" disabled={activeId !== null} onClick={() => void downloadArtifact(artifact)}>
|
||||
<Download size={14} />
|
||||
<span>{activeId === artifact.id ? "传输中" : "打开"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) {
|
||||
return `${value} B`;
|
||||
}
|
||||
if (value < 1024 * 1024) {
|
||||
return `${(value / 1024).toFixed(1)} KiB`;
|
||||
}
|
||||
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
runtimeDependencyStages,
|
||||
runtimeDownloadStages,
|
||||
runtimeKeyResetStages,
|
||||
runtimeLogStages,
|
||||
runtimeRunBuildStages,
|
||||
runtimeUpdateStages,
|
||||
useRuntimeTaskController
|
||||
@@ -33,7 +32,6 @@ import { filterServerCards, serverIsOnline, type ServerCardView, type ServerStat
|
||||
import {
|
||||
clientManagerBuildRequest,
|
||||
dependencyJobRequest,
|
||||
logBackfillRequest,
|
||||
runDistributionGenerateRequest,
|
||||
runUpdateRequest,
|
||||
serverCreateRequestFromForm,
|
||||
@@ -367,8 +365,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probe.key, plan.key, plan.digest));
|
||||
return `依赖安装任务已排队,job ${job.id}`;
|
||||
}
|
||||
const job = await platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, defaults.logSourceKey));
|
||||
return `历史日志回填任务已排队,job ${job.id}`;
|
||||
throw new Error("该运行操作已下线");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -677,8 +674,7 @@ type ServerQuickRuntimeAction =
|
||||
| "reset-run-key"
|
||||
| "generate-client-manager"
|
||||
| "dependencies-check"
|
||||
| "dependencies-install"
|
||||
| "historical-logs";
|
||||
| "dependencies-install";
|
||||
|
||||
async function requireQuickRuntimeActionAvailable(serverInstanceId: string, action: ServerQuickRuntimeAction): Promise<void> {
|
||||
const runtimeActions = await platformApiClient.getServerRuntimeActions(serverInstanceId);
|
||||
@@ -917,7 +913,7 @@ const serverQuickActionGroups: Array<{ label: string; actions: ServerQuickRuntim
|
||||
},
|
||||
{
|
||||
label: "诊断维护",
|
||||
actions: ["dependencies-check", "dependencies-install", "historical-logs"]
|
||||
actions: ["dependencies-check", "dependencies-install"]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -937,8 +933,6 @@ function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string {
|
||||
return "依赖检查";
|
||||
case "dependencies-install":
|
||||
return "依赖安装";
|
||||
case "historical-logs":
|
||||
return "历史日志";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -961,7 +955,7 @@ function quickRuntimeStages(action: ServerQuickRuntimeAction) {
|
||||
if (action === "dependencies-check" || action === "dependencies-install") {
|
||||
return runtimeDependencyStages;
|
||||
}
|
||||
return runtimeLogStages;
|
||||
return runtimeDependencyStages;
|
||||
}
|
||||
|
||||
function quickRuntimeExecuteStageIndex(action: ServerQuickRuntimeAction): number {
|
||||
@@ -990,8 +984,7 @@ function quickRuntimeDefaultsForPlugin(pluginId: string) {
|
||||
clientProfileKey: isScum ? "scum-client-manager" : "client-manager",
|
||||
repositoryUrl: isScum ? "https://github.com/F88888/scum_client.git" : "https://github.com/example/client-manager.git",
|
||||
probeKey: isScum ? "steamcmd" : "java-21",
|
||||
installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux",
|
||||
logSourceKey: isScum ? "scum-server-events" : "latest-log"
|
||||
installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user