feat: auto-deploy guided servers on run registration
This commit is contained in:
@@ -149,7 +149,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>{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>}
|
||||
{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 ? closeWorkflow() : 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>;
|
||||
|
||||
@@ -115,10 +115,6 @@ export function canStopServer(state: ServerInstanceState): boolean {
|
||||
return state === "running";
|
||||
}
|
||||
|
||||
export function canDeployServer(state: ServerInstanceState): boolean {
|
||||
return state === "draft" || state === "failed";
|
||||
}
|
||||
|
||||
export function isPendingJobState(state: JobResponse["state"]): boolean {
|
||||
return state === "queued" || state === "accepted" || state === "running" || state === "retrying";
|
||||
}
|
||||
|
||||
@@ -162,13 +162,10 @@ describe("ServerDetailPage config write approval", () => {
|
||||
expect(serverDetailPageSource).not.toContain('capability: "process.stop"');
|
||||
});
|
||||
|
||||
it("submits explicit deploy and retry-deploy actions only through the fenced deployment API", () => {
|
||||
expect(serverDetailPageSource).toContain("canDeployServer(instance.data.state)");
|
||||
expect(serverDetailPageSource).toContain("requestDeployment(instance.data)");
|
||||
expect(serverDetailPageSource).toContain("platformApiClient.deployServerInstance(current.id");
|
||||
expect(serverDetailPageSource).toContain('serverLifecycleCommandRequest(current, "deploy")');
|
||||
expect(serverDetailPageSource).toContain('instance.data.state === "failed" ? "重试部署" : "部署"');
|
||||
expect(serverDetailPageSource).toContain('operations.isPending(instance.data.id, instance.data.state === "failed" ? "重试部署服务器" : "部署服务器")');
|
||||
it("leaves guided deployment to the dedicated Run registration workflow", () => {
|
||||
expect(serverDetailPageSource).not.toContain("canDeployServer(instance.data.state)");
|
||||
expect(serverDetailPageSource).not.toContain("requestDeployment(instance.data)");
|
||||
expect(serverDetailPageSource).not.toContain("platformApiClient.deployServerInstance(current.id");
|
||||
expect(serverDetailPageSource).not.toContain('capability: "process.install"');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, Pencil, Rocket, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
|
||||
import { type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
@@ -50,7 +50,7 @@ import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge }
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import { jobCapabilityLabel } from "../contracts/jobPresentation";
|
||||
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
|
||||
import { canDeployServer, canStartServer, canStopServer, defaultServerCreateForm, endpointLabel, pluginCreateInputDefaults, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerCreateFormState, type ServerMetadataFormState } from "../contracts/serverManagement";
|
||||
import { canStartServer, canStopServer, defaultServerCreateForm, endpointLabel, pluginCreateInputDefaults, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerCreateFormState, type ServerMetadataFormState } from "../contracts/serverManagement";
|
||||
import {
|
||||
serverDetailSections,
|
||||
serverIsOnline,
|
||||
@@ -212,26 +212,6 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
});
|
||||
}
|
||||
|
||||
function requestDeployment(current: ServerInstanceResponse) {
|
||||
const retry = current.state === "failed";
|
||||
const intent = retry ? "重试部署服务器" : "部署服务器";
|
||||
setConfirm({
|
||||
title: intent,
|
||||
description: `${retry ? "重新" : ""}部署 ${current.name}(${current.id})将向已注册的专属 Run 提交安装任务,确认继续?`,
|
||||
run: async () => {
|
||||
const operationId = operations.begin({ intent, targetKind: "server", targetId: current.id, requester: session.displayName });
|
||||
try {
|
||||
const result = await platformApiClient.deployServerInstance(current.id, serverLifecycleCommandRequest(current, "deploy"));
|
||||
operations.succeed(operationId, `部署任务 ${result.job.id}(${result.job.capability})已派发`, result.job);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
operations.fail(operationId, error instanceof Error ? error.message : "部署任务提交失败", operationId);
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function saveDeploymentWorkflow(form: ServerCreateFormState) {
|
||||
if (instance.status !== "ready") return;
|
||||
const current = instance.data;
|
||||
@@ -296,16 +276,6 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
</div>
|
||||
<div className="action-strip">
|
||||
<span className={cx("status-pill", statusClass(instance.data.state))}>{stateLabel(instance.data.state)}</span>
|
||||
{canDeployServer(instance.data.state) && <button
|
||||
type="button"
|
||||
className="primary-command"
|
||||
disabled={deployment.status !== "ready" || !deployment.data.mode || operations.isPending(instance.data.id, instance.data.state === "failed" ? "重试部署服务器" : "部署服务器")}
|
||||
title={deployment.status !== "ready" || !deployment.data.mode ? "请先配置部署定义" : undefined}
|
||||
onClick={() => requestDeployment(instance.data)}
|
||||
>
|
||||
<Rocket size={15} />
|
||||
<span>{instance.data.state === "failed" ? "重试部署" : "部署"}</span>
|
||||
</button>}
|
||||
<button
|
||||
type="button"
|
||||
className="icon-command"
|
||||
|
||||
@@ -157,7 +157,7 @@ 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();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { GamePluginResponse } from "../api/types";
|
||||
import { canDeployServer, defaultServerCreateForm, runtimeBindingFields } from "../contracts/serverManagement";
|
||||
import { defaultServerCreateForm, runtimeBindingFields } from "../contracts/serverManagement";
|
||||
import { serverCreateRequestFromForm, serverInstanceIdFromName } from "./serverManagement";
|
||||
|
||||
const plugin: GamePluginResponse = {
|
||||
@@ -42,13 +42,6 @@ const plugin: GamePluginResponse = {
|
||||
};
|
||||
|
||||
describe("runtime profile server creation contracts", () => {
|
||||
it("allows deployment only from draft or failed server states", () => {
|
||||
expect(canDeployServer("draft")).toBe(true);
|
||||
expect(canDeployServer("failed")).toBe(true);
|
||||
expect(canDeployServer("installing")).toBe(false);
|
||||
expect(canDeployServer("ready")).toBe(false);
|
||||
});
|
||||
|
||||
it("derives logical binding fields from the selected profile", () => {
|
||||
expect(runtimeBindingFields(plugin, "local")).toEqual([
|
||||
{ key: "java-runtime", required: false, sensitive: false },
|
||||
|
||||
Reference in New Issue
Block a user