separate server run bindings from deployment targets

This commit is contained in:
npc0-hue
2026-07-25 19:48:26 +08:00
parent cea7472517
commit e5c94be1db
28 changed files with 465 additions and 104 deletions
+2
View File
@@ -438,6 +438,7 @@ export interface ServerInstanceResponse {
id: string;
pluginId: string;
pluginVersion: string;
deploymentTargetId?: string;
runEndpointId: string;
name: string;
ownerUserId?: string;
@@ -484,6 +485,7 @@ export interface ServerInstanceListResponse {
export interface ServerLifecycleCreateRequest {
id: string;
pluginId: string;
deploymentTargetId?: string;
runEndpointId?: string;
name: string;
idempotencyKey: string;
@@ -31,6 +31,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
const activeServer = kind === "edit" && Boolean(deployment);
const isScum = selectedPlugin?.id === "game.scum";
const needsTargetSelection = kind === "create" || !initialForm.runEndpointId;
const selectedTargetID = kind === "create" ? form.deploymentTargetId : form.runEndpointId;
const workflowSteps = kind === "create"
? [{ label: "选择目标", icon: Compass }, { label: "部署方式", icon: ServerCog }, { label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }]
: needsTargetSelection
@@ -64,7 +65,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
function updateBinding(key: string, value: string) { setForm((current) => ({ ...current, bindings: { ...current.bindings, [key]: value } })); }
function canContinue() {
if (step === targetStep) return kind === "create" ? Boolean(form.pluginId && (saveAsDraft || form.runEndpointId)) : Boolean(form.runEndpointId);
if (step === targetStep) return kind === "create" ? Boolean(form.pluginId && (saveAsDraft || form.deploymentTargetId)) : Boolean(form.runEndpointId);
if (step === configurationStep) {
if (kind === "create" && !form.name.trim()) return false;
if (isScum && form.deploymentMode === "guided-install" && !form.serverRoot.trim() && !deployment?.serverRootConfigured) return false;
@@ -78,19 +79,19 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (step < reviewStep) { if (canContinue()) setStep((current) => current + 1); return; }
await onSubmit({ ...form, runEndpointId: saveAsDraft ? "" : form.runEndpointId }, saveAsDraft);
await onSubmit({ ...form, deploymentTargetId: saveAsDraft ? "" : form.deploymentTargetId, runEndpointId: saveAsDraft ? "" : form.runEndpointId }, saveAsDraft);
}
const protectedState = (nextValue: string, configured: boolean) => nextValue.trim() ? "将替换" : configured ? "保持已配置" : "未配置";
const actionLabel = kind === "create" ? (saveAsDraft ? "保存草稿" : "创建、部署并前往构建 Run") : "保存部署设置";
const actionLabel = kind === "create" ? "保存草稿并准备专属 Run" : "保存部署设置";
return <ManagementDialog open={open} title={kind === "create" ? "创建服务器" : "编辑部署"} description={kind === "create" ? "按部署顺序完成设置;路径和命令始终受保护,不会在确认页或日志中回显。" : "仅停止中的服务器可以修改部署设置。受保护路径和命令留空会保持原值。"} wide onClose={() => { if (!busy) onClose(); }}>
<form className="provider-form dialog-form server-deployment-workflow" onSubmit={(event) => void submit(event)} aria-label={kind === "create" ? "创建服务器部署向导" : "编辑服务器部署向导"}>
<ol className="deployment-workflow-steps" style={{ gridTemplateColumns: `repeat(${workflowSteps.length}, minmax(0, 1fr))` }} aria-label="部署步骤">{workflowSteps.map((item, index) => { const Icon = item.icon; return <li key={item.label} className={cx(index === step && "deployment-workflow-step-active", index < step && "deployment-workflow-step-complete")}><span>{index < step ? <CheckCircle2 size={15} /> : <Icon size={15} />}</span><strong>{index + 1}. {item.label}</strong></li>; })}</ol>
{step === targetStep && <div className="deployment-workflow-body">
{kind === "create" ? <div className="workflow-hint-grid"><div className="workflow-hint-card"><strong></strong><span></span></div><div className="workflow-hint-card"><strong></strong><span>Run 访</span></div><div className="workflow-hint-card"><strong></strong><span></span></div></div> : <div className="form-guidance"><strong>稿</strong><span></span></div>}
<div className="form-grid">{kind === "create" && <label><select name="pluginId" value={form.pluginId} onChange={updateForm} required>{plugins.map((plugin) => <option key={plugin.id} value={plugin.id}>{pluginLabel(plugin, plugin.id)}</option>)}</select></label>}<label><select name="runEndpointId" value={form.runEndpointId} onChange={updateForm} disabled={kind === "create" && saveAsDraft} required={kind !== "create" || !saveAsDraft}><option value=""></option>{endpoints.map((endpoint) => <option key={endpoint.id} value={endpoint.id}>{endpointLabel(endpoint, endpoint.id)}</option>)}</select></label></div>
{kind === "create" && <label className="deployment-draft-choice"><input type="checkbox" checked={saveAsDraft} onChange={(event) => setSaveAsDraft(event.target.checked)} /><span><strong>稿</strong><small></small></span></label>}
{kind === "create" ? <div className="workflow-hint-grid"><div className="workflow-hint-card"><strong></strong><span></span></div><div className="workflow-hint-card"><strong></strong><span> Run Run</span></div><div className="workflow-hint-card"><strong> Run</strong><span>稿 Run</span></div></div> : <div className="form-guidance"><strong>稿</strong><span></span></div>}
<div className="form-grid">{kind === "create" && <label><select name="pluginId" value={form.pluginId} onChange={updateForm} required>{plugins.map((plugin) => <option key={plugin.id} value={plugin.id}>{pluginLabel(plugin, plugin.id)}</option>)}</select></label>}<label>{kind === "create" ? "部署目标" : "运行节点"}<select name={kind === "create" ? "deploymentTargetId" : "runEndpointId"} value={selectedTargetID} onChange={updateForm} disabled={kind === "create" && saveAsDraft} required={kind !== "create" || !saveAsDraft}><option value="">{kind === "create" ? "请选择部署目标" : "请选择运行节点"}</option>{endpoints.map((endpoint) => <option key={endpoint.id} value={endpoint.id}>{endpointLabel(endpoint, endpoint.id)}</option>)}</select></label></div>
{kind === "create" && <label className="deployment-draft-choice"><input type="checkbox" checked={saveAsDraft} onChange={(event) => setSaveAsDraft(event.target.checked)} /><span><strong></strong><small>稿 Run</small></span></label>}
</div>}
{step === modeStep && <div className="deployment-workflow-body"><p className="section-copy"></p>{isScum && <div className="form-guidance"><strong>SCUM </strong><span>Run </span></div>}<div className="deployment-mode-grid">
<ModeOption active={form.deploymentMode === "guided-install"} title="新建并安装" copy="按插件的推荐方案安装并写入游戏配置。适合绝大多数新服务器。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "guided-install" }))} />
@@ -117,7 +118,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
{form.deploymentMode === "custom-command" && <details className="provider-advanced-settings" open><summary></summary><p className="field-help"></p><div className="form-grid"><label><input name="startCommand" value={form.startCommand} onChange={updateForm} placeholder={deployment?.startCommandConfigured ? "留空保持已配置启动命令" : "必填,例如 ./start-server"} autoComplete="off" required={!deployment?.startCommandConfigured} /></label><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="workingDirectory" value={form.workingDirectory} onChange={updateForm} placeholder={deployment?.workingDirectoryConfigured ? "留空保持已配置执行目录" : "默认使用服务器目录"} autoComplete="off" /></label><label><input name="installCommand" value={form.installCommand} onChange={updateForm} autoComplete="off" placeholder="留空保持原值或不使用" /></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></div></details>}
{kind === "create" && bindingFields.length > 0 && <details className="provider-advanced-settings"><summary></summary><p className="field-help"></p><div className="form-grid">{bindingFields.map((field) => <label key={field.key}>{field.key}{field.required ? "(必填)" : ""}<input type={field.sensitive ? "password" : "text"} autoComplete="off" value={form.bindings[field.key] ?? ""} onChange={(event) => updateBinding(field.key, event.target.value)} placeholder={field.sensitive ? "托管凭据引用" : "安全逻辑值"} required={field.required} /></label>)}</div></details>}
</div>}
{step === reviewStep && <div className="deployment-workflow-body"><div className="deployment-review"><div><span></span><strong>{pluginLabel(selectedPlugin, form.pluginId)}</strong></div><div><span></span><strong>{saveAsDraft ? "保存为未绑定草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === form.runEndpointId), form.runEndpointId)}</strong></div><div><span></span><strong>{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}</strong></div><div><span>{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}</span><strong>{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}</strong></div>{form.deploymentMode === "custom-command" && <><div><span></span><strong>{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}</strong></div><div><span></span><strong>{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}</strong></div></>}{form.deploymentMode === "guided-install" && <div><span></span><strong>{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}</strong></div>}{isScum && <div><span></span><strong>/</strong></div>}</div><div className="form-guidance"><strong>{saveAsDraft ? "本次保存定义" : activeServer ? "本次只保存部署设置" : "确认后将创建、部署并打开 Run 构建"}</strong><span>{saveAsDraft ? "后续从服务器详情选择节点并部署。" : form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : activeServer ? "保存后可在详情中明确发起部署;路径和命令不会显示原文。" : isScum ? "Run 只有在 SCUM 配置映射与健康验证通过后才会报告安装成功。" : "Run 会在领取任务后执行本机预检,再进行安装、配置与启动。"}</span></div></div>}
{step === reviewStep && <div className="deployment-workflow-body"><div className="deployment-review"><div><span></span><strong>{pluginLabel(selectedPlugin, form.pluginId)}</strong></div><div><span>{kind === "create" ? "部署目标" : "目标"}</span><strong>{saveAsDraft ? "保存为未指定目标的草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === selectedTargetID), selectedTargetID)}</strong></div><div><span></span><strong>{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}</strong></div><div><span>{form.deploymentMode === "guided-install" ? "安装目录" : form.deploymentMode === "existing-server" ? "已有服务器目录" : "服务器目录"}</span><strong>{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}</strong></div>{form.deploymentMode === "custom-command" && <><div><span></span><strong>{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}</strong></div><div><span></span><strong>{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}</strong></div></>}{form.deploymentMode === "guided-install" && <div><span></span><strong>{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}</strong></div>}{isScum && <div><span></span><strong>/</strong></div>}</div><div className="form-guidance"><strong>{kind === "create" ? "本次保存草稿并保留专属 Run 身份" : activeServer ? "本次只保存部署设置" : "本次只保存部署设置"}</strong><span>{kind === "create" ? "随后生成并启动专属 Run;它完成注册后,才能明确发起部署。" : form.deploymentMode === "existing-server" ? "Run 将先预检现有目录;不会重装或覆盖已有游戏配置。" : "保存后可在详情中明确发起部署;路径和命令不会显示原文。"}</span></div></div>}
<div className="confirm-actions"><button type="button" disabled={busy} onClick={() => step === 0 ? onClose() : setStep((current) => current - 1)}>{step === 0 ? "取消" : "上一步"}</button>{step < reviewStep ? <button type="submit" className="confirm-primary" disabled={busy || !canContinue()}><CircleDashed size={16} /><span></span></button> : <button type="submit" className="confirm-primary" disabled={busy}><Rocket size={16} /><span>{busy ? "保存中…" : actionLabel}</span></button>}</div>
</form>
</ManagementDialog>;
+5 -3
View File
@@ -14,8 +14,9 @@ export type ServerRemovalAction = "delete";
export interface ServerCreateFormState {
id: string;
name: string;
pluginId: string;
runEndpointId: string;
pluginId: string;
deploymentTargetId: string;
runEndpointId: string;
profileKey: string;
bindings: Record<string, string>;
createInputs: Record<string, string>;
@@ -63,7 +64,8 @@ export interface ServerManagementSummary {
export const emptyServerCreateForm: ServerCreateFormState = {
id: "",
name: "",
pluginId: "",
pluginId: "",
deploymentTargetId: "",
runEndpointId: "",
profileKey: "",
bindings: {},
+3 -1
View File
@@ -176,7 +176,9 @@ describe("first-party console pages", () => {
expect(serverDeploymentWorkflowSource).toContain("选择目标");
expect(serverDeploymentWorkflowSource).toContain("部署方式");
expect(serverDeploymentWorkflowSource).toContain("相关配置");
expect(serverDeploymentWorkflowSource).toContain("仅保存为草稿");
expect(serverDeploymentWorkflowSource).toContain("选择部署目标");
expect(serverDeploymentWorkflowSource).toContain("专属 Run");
expect(serverDeploymentWorkflowSource).toContain("保存草稿并准备专属 Run");
expect(serverDeploymentWorkflowSource).toContain("执行目录(可选)");
expect(serverDeploymentWorkflowSource).toContain("默认使用服务器目录");
expect(serverDeploymentWorkflowSource).toContain("安装目录{isScum ? \"(必填)\" : \"(可选)\"}");
+2 -2
View File
@@ -157,11 +157,11 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const operationId = operations.begin({ intent: "创建服务器", targetKind: "server", targetId: "platform", requester: session.displayName });
try {
const result = await platformApiClient.createServerWorkflow(serverCreateRequestFromForm(nextForm));
operations.succeed(operationId, result.job.id ? `已创建实例 ${result.instance.id},安装任务 ${result.job.id} 已派发` : `已保存草稿 ${result.instance.id}可在 Run 注册后绑定并部署。`, result.job.id ? result.job : undefined);
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();
if (result.job.id) onNavigate("serverDetail", { serverId: result.instance.id, routeKey: "run-builder" });
onNavigate("serverDetail", { serverId: result.instance.id, routeKey: "run-builder" });
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "创建失败", operationId);
}
+2 -1
View File
@@ -15,7 +15,8 @@ export function serverCreateRequestFromForm(form: ServerCreateFormState, sequenc
const id = form.id.trim() || serverInstanceIdFromName(form.name, sequence);
return {
id,
pluginId: form.pluginId.trim(),
pluginId: form.pluginId.trim(),
deploymentTargetId: form.deploymentTargetId.trim() || undefined,
runEndpointId: form.runEndpointId.trim() || undefined,
name: form.name.trim(),
idempotencyKey: lifecycleIdempotencyKey("create", id, sequence),