feat: support custom server deployment drafts

This commit is contained in:
npc0-hue
2026-07-24 16:56:28 +08:00
parent 292b380f3c
commit 220ef91a8e
36 changed files with 1520 additions and 85 deletions
+14
View File
@@ -88,6 +88,8 @@ import type {
ServerLifecycleCommandRequest,
ServerLifecycleCreateRequest,
ServerLifecycleResponse,
ServerDeploymentRequest,
ServerDeploymentResponse,
ServerConfigWriteApprovalRequest,
ServerConfigWriteDispatchResponse,
SourceRCONCommandRequest,
@@ -185,6 +187,18 @@ export class PlatformApiClient {
});
}
async getServerDeployment(id: string): Promise<ServerDeploymentResponse> {
return this.request<ServerDeploymentResponse>(`/server-instances/${encodeURIComponent(id)}/deployment`);
}
async updateServerDeployment(id: string, request: ServerDeploymentRequest): Promise<ServerDeploymentResponse> {
return this.request<ServerDeploymentResponse>(`/server-instances/${encodeURIComponent(id)}/deployment`, { method: "PUT", body: request });
}
async deployServerInstance(id: string, request: ServerLifecycleCommandRequest): Promise<ServerLifecycleResponse> {
return this.request<ServerLifecycleResponse>(`/server-instances/${encodeURIComponent(id)}/deploy`, { method: "POST", body: request });
}
async getServerRuntimeBinding(id: string): Promise<RuntimeBindingResponse> {
return this.request<RuntimeBindingResponse>(`/server-instances/${encodeURIComponent(id)}/runtime-binding`);
}
+54 -3
View File
@@ -342,6 +342,7 @@ export interface GamePluginResponse {
supportedOs?: string[];
manifestRef: string;
createFormSchemaRef: string;
createFields?: PluginCreateFieldResponse[];
requiredRunCapabilities: string[];
declaredPermissions: string[];
permissions: PluginPermissionsResponse;
@@ -357,6 +358,18 @@ export interface GamePluginResponse {
status: GamePluginStatus;
}
export type PluginCreateFieldType = "text" | "number" | "boolean" | "select" | "port";
export interface PluginCreateFieldResponse {
key: string;
label: string;
type: PluginCreateFieldType;
required?: boolean;
defaultValue?: string;
options?: string[];
configKey?: string;
}
export interface GamePluginListResponse {
items: GamePluginResponse[];
count: number;
@@ -439,11 +452,48 @@ export interface ServerInstanceListResponse {
export interface ServerLifecycleCreateRequest {
id: string;
pluginId: string;
runEndpointId: string;
runEndpointId?: string;
name: string;
idempotencyKey: string;
profileKey: string;
bindings: Record<string, string>;
profileKey?: string;
bindings?: Record<string, string>;
deployment?: ServerDeploymentRequest;
}
export type ServerDeploymentMode = "guided-install" | "existing-server" | "custom-command";
export type ServerCommandShell = "" | "posix-sh" | "powershell" | "cmd";
// This request is write-only for paths and commands. The matching response
// intentionally returns configured flags rather than those values.
export interface ServerDeploymentRequest {
runEndpointId?: string;
mode: ServerDeploymentMode;
profileKey?: string;
runtimeBindings?: Record<string, string>;
createInputs?: Record<string, string>;
serverRoot?: string;
workingDirectory?: string;
installCommand?: string;
startCommand?: string;
stopCommand?: string;
statusCommand?: string;
shell?: ServerCommandShell;
}
export interface ServerDeploymentResponse {
serverInstanceId: string;
mode?: ServerDeploymentMode;
profileKey?: string;
createInputs?: Record<string, string>;
serverRootConfigured: boolean;
workingDirectoryConfigured: boolean;
installCommandConfigured: boolean;
startCommandConfigured: boolean;
stopCommandConfigured: boolean;
statusCommandConfigured: boolean;
shell?: ServerCommandShell;
revision: number;
updatedAt?: string;
}
export interface RuntimeBindingUpdateRequest {
@@ -531,6 +581,7 @@ export interface RunEndpointListResponse {
export interface JobProgressBody {
percent: number;
phase?: "queued" | "claimed" | "preflight" | "install" | "configure" | "start" | "health";
message?: string;
}
+34 -10
View File
@@ -1,8 +1,9 @@
import type {
GamePluginResponse,
JobResponse,
RunEndpointResponse,
ServerInstanceResponse,
GamePluginResponse,
JobResponse,
RunEndpointResponse,
ServerDeploymentMode,
ServerInstanceResponse,
ServerInstanceState
} from "../api/types";
@@ -15,8 +16,17 @@ export interface ServerCreateFormState {
name: string;
pluginId: string;
runEndpointId: string;
profileKey: string;
bindings: Record<string, string>;
profileKey: string;
bindings: Record<string, string>;
createInputs: Record<string, string>;
deploymentMode: ServerDeploymentMode;
serverRoot: string;
workingDirectory: string;
installCommand: string;
startCommand: string;
stopCommand: string;
statusCommand: string;
shell: "" | "posix-sh" | "powershell" | "cmd";
}
export interface RuntimeBindingField {
@@ -55,8 +65,17 @@ export const emptyServerCreateForm: ServerCreateFormState = {
name: "",
pluginId: "",
runEndpointId: "",
profileKey: "",
bindings: {}
profileKey: "",
bindings: {},
createInputs: {},
deploymentMode: "guided-install",
serverRoot: "",
workingDirectory: "",
installCommand: "",
startCommand: "",
stopCommand: "",
statusCommand: "",
shell: ""
};
export function summarizeServerManagement(instances: ServerInstanceResponse[], jobs: JobResponse[]): ServerManagementSummary {
@@ -104,8 +123,13 @@ export function defaultServerCreateForm(plugins: GamePluginResponse[], endpoints
...emptyServerCreateForm,
pluginId: plugin?.id ?? "",
profileKey: plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "",
runEndpointId: endpoints[0]?.id ?? ""
};
runEndpointId: "",
createInputs: pluginCreateInputDefaults(plugin)
};
}
export function pluginCreateInputDefaults(plugin: GamePluginResponse | undefined): Record<string, string> {
return Object.fromEntries((plugin?.createFields ?? []).map((field) => [field.key, field.defaultValue ?? ""]));
}
export function runtimeBindingFields(plugin: GamePluginResponse | undefined, profileKey: string): RuntimeBindingField[] {
+115 -3
View File
@@ -21,6 +21,8 @@ import type {
ServerMemberResponse,
ServerMetricsResponse,
RuntimeBindingResponse,
RunEndpointResponse,
ServerDeploymentResponse,
ServerRuntimeActionsResponse,
MetricSampleResponse,
RemoteAdapterDeclarationResponse
@@ -46,7 +48,7 @@ import {
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
import { canStartServer, canStopServer, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
import { canStartServer, canStopServer, endpointLabel, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
import {
serverDetailSections,
serverIsOnline,
@@ -89,6 +91,8 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
const [remoteAdapters, setRemoteAdapters] = useState<RemoteAdapterDeclarationResponse[]>([]);
const [runtimeActions, setRuntimeActions] = useState<LoadState<ServerRuntimeActionsResponse>>({ status: "loading" });
const [runtimeBinding, setRuntimeBinding] = useState<LoadState<RuntimeBindingResponse>>({ status: "loading" });
const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" });
const [endpoints, setEndpoints] = useState<RunEndpointResponse[]>([]);
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
@@ -99,9 +103,10 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
}
setInstance({ status: "loading" });
try {
const [detail, pluginResponse, jobResponse, runtimeResponse, bindingResponse, metricHistoryResponse, backupResponse, adapterResponse] = await Promise.all([
const [detail, pluginResponse, endpointResponse, jobResponse, runtimeResponse, bindingResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse] = await Promise.all([
platformApiClient.getServerInstance(serverId),
platformApiClient.listGamePlugins(),
platformApiClient.listRunEndpoints(),
platformApiClient.listJobs(serverId),
platformApiClient
.getServerRuntimeActions(serverId)
@@ -111,15 +116,21 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
.getServerRuntimeBinding(serverId)
.then((data): LoadState<RuntimeBindingResponse> => ({ status: "ready", data }))
.catch((error): LoadState<RuntimeBindingResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "运行配置加载失败" })),
platformApiClient
.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 }))
]);
setInstance({ status: "ready", data: detail });
setPlugins(pluginResponse.items);
setEndpoints(endpointResponse.items);
setJobs(jobResponse.items);
setRuntimeActions(runtimeResponse);
setRuntimeBinding(bindingResponse);
setDeployment(deploymentResponse);
setMetricHistory(metricHistoryResponse.items);
setBackups(backupResponse.items);
setRemoteAdapters(adapterResponse.items);
@@ -137,6 +148,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
setArtifacts([]);
setRuntimeActions({ status: "error", reason: "运行分发状态加载失败" });
setRuntimeBinding({ status: "error", reason: "运行配置加载失败" });
setDeployment({ status: "error", reason: "部署定义加载失败" });
setMetricHistory([]);
setBackups([]);
setRemoteAdapters([]);
@@ -295,6 +307,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
onChanged={() => void refresh()}
/>
)}
{section === "overview" && <ServerDeploymentSection instance={instance.data} deployment={deployment} endpoints={endpoints} session={session} operations={operations} onChanged={() => void refresh()} />}
{section === "overview" && <RuntimeDLLExtensionsPanel runtimeProfiles={plugins.find((plugin) => plugin.id === instance.data.pluginId)?.runtimeProfiles} />}
{section === "overview" && <SourceRCONCommandPanel serverId={instance.data.id} pluginId={instance.data.pluginId} />}
{section === "overview" && (
@@ -412,6 +425,104 @@ function ServerMetadataSection({ instance, session, operations, onChanged }: Ser
);
}
interface ServerDeploymentSectionProps {
instance: ServerInstanceResponse;
deployment: LoadState<ServerDeploymentResponse>;
endpoints: RunEndpointResponse[];
session: PageComponentProps["session"];
operations: PageComponentProps["operations"];
onChanged: () => void;
}
function ServerDeploymentSection({ instance, deployment, endpoints, session, operations, onChanged }: ServerDeploymentSectionProps) {
const [runEndpointId, setRunEndpointId] = useState(instance.runEndpointId);
const [mode, setMode] = useState<"guided-install" | "existing-server" | "custom-command">("guided-install");
const [serverRoot, setServerRoot] = useState("");
const [workingDirectory, setWorkingDirectory] = useState("");
const [installCommand, setInstallCommand] = useState("");
const [startCommand, setStartCommand] = useState("");
const [stopCommand, setStopCommand] = useState("");
const [statusCommand, setStatusCommand] = useState("");
const [shell, setShell] = useState<"" | "posix-sh" | "powershell" | "cmd">("");
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<{ status: "succeeded" | "failed"; label: string } | null>(null);
useEffect(() => {
setRunEndpointId(instance.runEndpointId);
if (deployment.status === "ready") {
setMode(deployment.data.mode ?? "guided-install");
setShell(deployment.data.shell ?? "");
}
}, [deployment, instance.id, instance.runEndpointId]);
async function save(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setBusy(true);
setResult(null);
const operationId = operations.begin({ intent: "更新部署定义", targetKind: "server", targetId: instance.id, requester: session.displayName });
try {
await platformApiClient.updateServerDeployment(instance.id, { runEndpointId: runEndpointId || undefined, mode, serverRoot: serverRoot.trim() || undefined, workingDirectory: workingDirectory.trim() || undefined, installCommand: installCommand.trim() || undefined, startCommand: startCommand.trim() || undefined, stopCommand: stopCommand.trim() || undefined, statusCommand: statusCommand.trim() || undefined, shell: shell || undefined });
operations.succeed(operationId, "部署定义已保存;受保护路径和命令不会回显。");
setResult({ status: "succeeded", label: "部署定义已保存" });
setServerRoot(""); setWorkingDirectory(""); setInstallCommand(""); setStartCommand(""); setStopCommand(""); setStatusCommand("");
onChanged();
} catch (error) {
const label = error instanceof Error ? error.message : "部署定义保存失败";
operations.fail(operationId, label);
setResult({ status: "failed", label });
} finally { setBusy(false); }
}
async function deploy() {
setBusy(true);
setResult(null);
const operationId = operations.begin({ intent: "部署服务器", targetKind: "server", targetId: instance.id, requester: session.displayName });
try {
const response = await platformApiClient.deployServerInstance(instance.id, { expectedConfigVersion: instance.configVersion, idempotencyKey: `web:deploy:${instance.id}:${Date.now()}` });
operations.succeed(operationId, response.job.id ? "部署任务已进入队列,等待 Run 领取。" : "部署请求已接受。");
setResult({ status: "succeeded", label: deploymentProgressLabel(response.job.progress) });
onChanged();
} catch (error) {
const label = error instanceof Error ? error.message : "部署失败";
operations.fail(operationId, label);
setResult({ status: "failed", label });
} finally { setBusy(false); }
}
if (deployment.status === "loading") return <LoadingState label="正在加载部署定义…" compact />;
if (deployment.status === "error") return <ErrorState title="部署定义不可用" reason={deployment.reason} diagnosticId={`deployment:${instance.id}`} compact />;
const view = deployment.data;
return <article className="console-panel" aria-label="server deployment">
<div className="panel-header"><h2><PackageOpen size={16} style={{ verticalAlign: "-2px" }} /> </h2><span className="page-status">{view.mode || "未配置"} · {view.revision}</span></div>
<p className="section-copy"></p>
<div className="action-list"><span>{view.serverRootConfigured ? "已配置" : "未配置"}</span><span>{view.workingDirectoryConfigured ? "已配置" : "未配置"}</span><span> / {view.installCommandConfigured ? "已配置" : "未配置"} / {view.startCommandConfigured ? "已配置" : "未配置"}</span></div>
<form className="provider-form" style={{ marginTop: 12 }} onSubmit={(event) => void save(event)}>
<div className="form-grid">
<label><select value={runEndpointId} onChange={(event) => setRunEndpointId(event.target.value)}><option value=""></option>{endpoints.map((endpoint) => <option key={endpoint.id} value={endpoint.id}>{endpointLabel(endpoint, endpoint.id)}</option>)}</select></label>
<label><select value={mode} onChange={(event) => setMode(event.target.value as typeof mode)}><option value="guided-install"></option><option value="existing-server"></option><option value="custom-command"></option></select></label>
<label><input value={serverRoot} onChange={(event) => setServerRoot(event.target.value)} placeholder="完整绝对路径" autoComplete="off" /></label>
<label><input value={workingDirectory} onChange={(event) => setWorkingDirectory(event.target.value)} placeholder="完整绝对路径" autoComplete="off" /></label>
{mode === "custom-command" && <><label><select value={shell} onChange={(event) => setShell(event.target.value as typeof shell)}><option value=""> argv</option><option value="posix-sh">POSIX sh</option><option value="powershell">PowerShell</option><option value="cmd">Windows cmd</option></select></label><label><input value={installCommand} onChange={(event) => setInstallCommand(event.target.value)} autoComplete="off" /></label><label><input value={startCommand} onChange={(event) => setStartCommand(event.target.value)} autoComplete="off" /></label><label><input value={stopCommand} onChange={(event) => setStopCommand(event.target.value)} autoComplete="off" /></label><label><input value={statusCommand} onChange={(event) => setStatusCommand(event.target.value)} autoComplete="off" /></label></>}
</div>
<div className="action-strip"><button type="submit" className="primary-command" disabled={busy}><Pencil size={14} /><span>{busy ? "保存中…" : "保存部署定义"}</span></button>{instance.state === "draft" || instance.state === "failed" ? <button type="button" className="icon-command" disabled={busy || !runEndpointId} onClick={() => void deploy()}><Sparkles size={14} /><span> Run</span></button> : null}</div>
</form>
{result && <div style={{ marginTop: 10 }}><ResultBadge status={result.status} label={result.label} /></div>}
</article>;
}
function deploymentProgressLabel(progress: JobResponse["progress"]): string {
switch (progress.phase) {
case "queued": return "任务已排队,等待 Run 领取";
case "claimed": return "Run 已领取任务";
case "preflight": return "正在执行本机预检";
case "install": return "正在安装服务器";
case "configure": return "正在写入游戏配置";
case "start": return "正在启动服务器";
case "health": return "正在进行健康检查";
default: return "部署任务已提交";
}
}
interface ServerAdministratorsSectionProps {
instance: ServerInstanceResponse;
session: PageComponentProps["session"];
@@ -587,7 +698,7 @@ function OverviewSection({ instance, metrics, jobs, onOpenLogs }: OverviewSectio
{failed.length > 0 && <span> {failed.length} </span>}
{pending.length > 0 ? (
<span>
{pending[0].capability}{pending[0].state}{pending[0].progress.percent}%
{pending[0].capability}{deploymentProgressLabel(pending[0].progress)}{pending[0].progress.percent}%
</span>
) : (
<span></span>
@@ -2267,6 +2378,7 @@ function HistorySection({ serverId, serverOperations, jobs, artifacts, metricHis
<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>
+60 -8
View File
@@ -23,6 +23,7 @@ import {
canDeleteServer,
defaultServerCreateForm,
endpointLabel,
pluginCreateInputDefaults,
pluginLabel,
runtimeBindingFields,
type ServerCreateFormState
@@ -101,6 +102,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
pluginId: plugin?.id ?? "",
profileKey,
bindings: plugin?.id === current.pluginId && profileKey === current.profileKey ? current.bindings : {},
createInputs: plugin?.id === current.pluginId ? current.createInputs : pluginCreateInputDefaults(plugin),
runEndpointId: endpointResponse.items.some((endpoint) => endpoint.id === current.runEndpointId)
? current.runEndpointId
: endpointResponse.items[0]?.id || ""
@@ -156,13 +158,14 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const createProfileOptions = selectedCreatePlugin?.runtimeProfiles?.lifecycleProfiles ?? [];
const createProfileUnavailable = Boolean(selectedCreatePlugin && createProfileOptions.length === 0);
const createBindingFields = runtimeBindingFields(selectedCreatePlugin, form.profileKey);
const createPluginFields = selectedCreatePlugin?.createFields ?? [];
function updateForm(event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
const { name, value } = event.target;
setForm((current) => {
if (name === "pluginId") {
const plugin = plugins.find((item) => item.id === value);
return { ...current, pluginId: value, profileKey: plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "", bindings: {} };
return { ...current, pluginId: value, profileKey: plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "", bindings: {}, createInputs: pluginCreateInputDefaults(plugin) };
}
if (name === "profileKey") {
return { ...current, profileKey: value, bindings: {} };
@@ -175,12 +178,16 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
setForm((current) => ({ ...current, bindings: { ...current.bindings, [key]: value } }));
}
function updateCreateInput(key: string, value: string) {
setForm((current) => ({ ...current, createInputs: { ...current.createInputs, [key]: value } }));
}
async function handleCreate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const operationId = operations.begin({ intent: "创建服务器", targetKind: "server", targetId: "platform", requester: session.displayName });
try {
const result = await platformApiClient.createServerWorkflow(serverCreateRequestFromForm(form));
operations.succeed(operationId, `已创建实例 ${result.instance.id},安装任务 ${result.job.id} 已派发`, result.job);
operations.succeed(operationId, result.job.id ? `已创建实例 ${result.instance.id},安装任务 ${result.job.id} 已派发` : `已保存草稿 ${result.instance.id};可在 Run 注册后绑定并部署。`, result.job.id ? result.job : undefined);
setForm(defaultServerCreateForm(plugins, endpoints));
setShowCreate(false);
await refresh();
@@ -489,7 +496,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
<ManagementDialog
open={showCreate && canManageServers}
title="创建服务器"
description="选择插件声明的运行配置和安全逻辑绑定。提交后以 Platform 返回的实例与安装任务为准。"
description="先保存服务器定义也可以;Run 注册后再绑定部署。根目录和命令只会写入受保护执行计划,之后不会在页面、任务或日志中显示。"
wide
onClose={() => { if (!createPending) setShowCreate(false); }}
>
@@ -511,7 +518,8 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
</label>
<label>
<select name="runEndpointId" value={form.runEndpointId} onChange={updateForm} required>
<select name="runEndpointId" value={form.runEndpointId} onChange={updateForm}>
<option value="">稿</option>
{endpoints.map((endpoint) => (
<option key={endpoint.id} value={endpoint.id}>
{endpointLabel(endpoint, endpoint.id)}
@@ -520,8 +528,9 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
</select>
</label>
<label>
<select name="profileKey" value={form.profileKey} onChange={updateForm} required>
<select name="profileKey" value={form.profileKey} onChange={updateForm}>
<option value="">使</option>
{createProfileOptions.map((profile) => (
<option key={profile.key} value={profile.key}>
{profile.key} · {profile.mode}
@@ -535,6 +544,49 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
<span> manifest </span>
</div>
)}
<label>
<select name="deploymentMode" value={form.deploymentMode} onChange={updateForm}>
<option value="guided-install"></option>
<option value="existing-server"></option>
<option value="custom-command"></option>
</select>
</label>
<label>
<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder="/srv/scum-alpha 或 C:\\Games\\SCUM" autoComplete="off" />
</label>
<label>
<input name="workingDirectory" value={form.workingDirectory} onChange={updateForm} placeholder="/srv/scum-alpha" autoComplete="off" />
</label>
{createPluginFields.map((field) => (
<label key={field.key}>
{field.label}{field.required ? "(必填)" : ""}
{field.type === "select" ? (
<select value={form.createInputs[field.key] ?? ""} onChange={(event) => updateCreateInput(field.key, event.target.value)} required={field.required}>
{!field.required && <option value=""></option>}
{field.options?.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
) : (
<input type={field.type === "boolean" ? "checkbox" : field.type === "number" || field.type === "port" ? "number" : "text"} min={field.type === "port" ? 1 : undefined} max={field.type === "port" ? 65535 : undefined} checked={field.type === "boolean" ? form.createInputs[field.key] === "true" : undefined} value={field.type === "boolean" ? undefined : form.createInputs[field.key] ?? ""} onChange={(event) => updateCreateInput(field.key, field.type === "boolean" ? String(event.target.checked) : event.target.value)} required={field.required} />
)}
</label>
))}
{form.deploymentMode === "custom-command" && (
<>
<label>
<select name="shell" value={form.shell} onChange={updateForm}>
<option value=""> argv</option><option value="posix-sh">POSIX sh</option><option value="powershell">PowerShell</option><option value="cmd">Windows cmd</option>
</select>
</label>
<label><input name="installCommand" value={form.installCommand} onChange={updateForm} autoComplete="off" placeholder="例如 SteamCMD 安装命令" /></label>
<label><input name="startCommand" value={form.startCommand} onChange={updateForm} autoComplete="off" required placeholder="例如 /srv/app/.venv/bin/python server.py" /></label>
<label><input name="stopCommand" value={form.stopCommand} onChange={updateForm} autoComplete="off" /></label>
<label><input name="statusCommand" value={form.statusCommand} onChange={updateForm} autoComplete="off" /></label>
</>
)}
{createBindingFields.map((field) => (
<label key={field.key}>
{field.key}{field.required ? "(必填)" : ""}
@@ -551,9 +603,9 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
</div>
<div className="confirm-actions">
<button type="button" disabled={createPending} onClick={() => setShowCreate(false)}></button>
<button type="submit" className="confirm-primary" disabled={createPending || !form.profileKey || createProfileUnavailable} title="创建服务器">
<button type="submit" className="confirm-primary" disabled={createPending || createProfileUnavailable} title="创建服务器">
<Sparkles size={16} />
<span>{createPending ? "创建中…" : "创建并安装"}</span>
<span>{createPending ? "保存中…" : form.runEndpointId ? "保存并部署" : "保存草稿"}</span>
</button>
</div>
</form>
+19 -2
View File
@@ -11,6 +11,10 @@ const plugin: GamePluginResponse = {
serverType: "runtime",
manifestRef: "artifact://runtime-manifest",
createFormSchemaRef: "schemas/create.json",
createFields: [
{ key: "gamePort", label: "游戏端口", type: "port", required: true, defaultValue: "7777" },
{ key: "maxPlayers", label: "最大玩家数", type: "number", required: true, defaultValue: "64" }
],
requiredRunCapabilities: ["process.install"],
declaredPermissions: ["server.create"],
permissions: { ai: false, logs: true, files: false, jobs: true, artifacts: false },
@@ -65,11 +69,17 @@ describe("runtime profile server creation contracts", () => {
).toEqual({
id: "server-1",
pluginId: "game.runtime",
runEndpointId: "",
runEndpointId: undefined,
name: "Runtime Server",
idempotencyKey: "web:create:server-1:17",
profileKey: "local",
bindings: { "server-root": "runtime.server-root", "rcon.password": "secret://runtime/server-1/rcon" }
bindings: { "server-root": "runtime.server-root", "rcon.password": "secret://runtime/server-1/rcon" },
deployment: {
mode: "guided-install",
profileKey: "local",
runtimeBindings: { "server-root": "runtime.server-root", "rcon.password": "secret://runtime/server-1/rcon" },
createInputs: { gamePort: "7777", maxPlayers: "64" }
}
});
});
@@ -84,4 +94,11 @@ describe("runtime profile server creation contracts", () => {
});
expect(serverInstanceIdFromName("测试服", 18)).toBe("server-18");
});
it("keeps complete paths and commands in a write-only deployment payload", () => {
const form = defaultServerCreateForm([plugin], []);
const request = serverCreateRequestFromForm({ ...form, name: "Venv Server", deploymentMode: "custom-command", serverRoot: "/srv/venv-server", workingDirectory: "/srv/venv-server", startCommand: "/srv/venv-server/.venv/bin/python server.py", shell: "" }, 19);
expect(request.runEndpointId).toBeUndefined();
expect(request.deployment).toMatchObject({ mode: "custom-command", serverRoot: "/srv/venv-server", workingDirectory: "/srv/venv-server", startCommand: "/srv/venv-server/.venv/bin/python server.py" });
});
});
+16 -3
View File
@@ -16,11 +16,24 @@ export function serverCreateRequestFromForm(form: ServerCreateFormState, sequenc
return {
id,
pluginId: form.pluginId.trim(),
runEndpointId: form.runEndpointId.trim(),
runEndpointId: form.runEndpointId.trim() || undefined,
name: form.name.trim(),
idempotencyKey: lifecycleIdempotencyKey("create", id, sequence),
profileKey: form.profileKey.trim(),
bindings: Object.fromEntries(Object.entries(form.bindings).map(([key, value]) => [key, value.trim()]).filter(([, value]) => value !== ""))
profileKey: form.profileKey.trim() || undefined,
bindings: Object.fromEntries(Object.entries(form.bindings).map(([key, value]) => [key, value.trim()]).filter(([, value]) => value !== "")),
deployment: {
mode: form.deploymentMode,
profileKey: form.profileKey.trim() || undefined,
runtimeBindings: Object.fromEntries(Object.entries(form.bindings).map(([key, value]) => [key, value.trim()]).filter(([, value]) => value !== "")),
createInputs: Object.fromEntries(Object.entries(form.createInputs).map(([key, value]) => [key, value.trim()])),
serverRoot: form.serverRoot.trim() || undefined,
workingDirectory: form.workingDirectory.trim() || undefined,
installCommand: form.installCommand.trim() || undefined,
startCommand: form.startCommand.trim() || undefined,
stopCommand: form.stopCommand.trim() || undefined,
statusCommand: form.statusCommand.trim() || undefined,
shell: form.shell || undefined
}
};
}