Integrate SCUM real ops workflows

This commit is contained in:
npc0-hue
2026-08-10 21:12:53 +08:00
parent 1063330710
commit a770bc6250
88 changed files with 6375 additions and 2719 deletions
+28 -43
View File
@@ -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");
});
+21 -15
View File
@@ -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]);");
});
+16 -95
View File
@@ -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>
);
}
+19 -27
View File
@@ -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");
+19 -621
View File
@@ -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`;
}
+5 -12
View File
@@ -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"
};
}