feat: move distribution builds to platform Docker builder

This commit is contained in:
npc0-hue
2026-07-30 19:25:50 +08:00
parent 1e004dc9ec
commit e614a17fe3
45 changed files with 4492 additions and 294 deletions
@@ -0,0 +1,116 @@
/** @vitest-environment jsdom */
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GamePluginResponse } from "../api/types";
import { defaultServerCreateForm } from "../contracts/serverManagement";
import { minimalServerCreateRequestFromForm } from "../schemas/serverManagement";
import { ServerDeploymentWorkflow } from "./ServerDeploymentWorkflow";
const plugin: GamePluginResponse = {
id: "game.runtime",
name: "Runtime Game",
version: "1.0.0",
serverType: "runtime",
manifestRef: "artifact://runtime-manifest",
createFormSchemaRef: "schemas/create.json",
createFields: [{ key: "serverRoot", label: "服务器目录", type: "text", required: true }],
requiredRunCapabilities: ["process.install"],
declaredPermissions: ["server.create"],
permissions: { ai: false, logs: true, files: false, jobs: true, artifacts: false },
lifecycleActions: { install: "actions/install.json", start: "actions/start.json", stop: "actions/stop.json" },
bridgeActions: [],
pages: [],
tags: [],
aiPurposes: [],
productionLifecycle: { operations: ["install"], dependencyPolicy: "optional", approvalRequired: [] },
status: "installed",
runtimeProfiles: {
transportProfiles: [{ key: "rcon", kind: "rcon", targetKey: "rcon.password", capabilities: ["remote.run.rcon.command"] }],
lifecycleProfiles: [{ key: "local", mode: "local-process", capabilities: ["process.install"], transportKeys: ["rcon"] }]
}
};
let root: Root | null = null;
let container: HTMLDivElement | null = null;
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(async () => {
if (root) {
await act(async () => root?.unmount());
}
container?.remove();
root = null;
container = null;
});
describe("ServerDeploymentWorkflow", () => {
it("submits plugin type and server name as a minimal create request", async () => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
const initialForm = defaultServerCreateForm([plugin], []);
let submitted: ReturnType<typeof minimalServerCreateRequestFromForm> | undefined;
const onSubmit = vi.fn(async (form: typeof initialForm) => {
submitted = minimalServerCreateRequestFromForm(form, 17);
});
await act(async () => {
root?.render(
<ServerDeploymentWorkflow
open
kind="create"
plugins={[plugin]}
endpoints={[]}
initialForm={initialForm}
onClose={() => undefined}
onSubmit={onSubmit}
/>
);
});
expect(container.querySelector('select[name="pluginId"]')).not.toBeNull();
expect(container.querySelector('input[name="name"]')).not.toBeNull();
for (const field of ["deploymentTargetId", "runEndpointId", "profileKey", "serverRoot", "startCommand"]) {
expect(container.querySelector(`[name="${field}"]`)).toBeNull();
}
expect(container.textContent).not.toContain("运行连接设置");
expect(container.querySelector('select[name="deploymentMode"]')).toBeNull();
const nameInput = container.querySelector<HTMLInputElement>('input[name="name"]');
if (!nameInput) throw new Error("server name input not found");
await act(async () => {
setInputValue(nameInput, "Minimal Runtime Server");
});
await submitWorkflow(container);
expect(container.textContent).toContain("本次只创建服务器记录");
expect(container.textContent).toContain("Minimal Runtime Server");
await submitWorkflow(container);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(submitted).toEqual({
id: "server-minimal-runtime-server-17",
pluginId: "game.runtime",
name: "Minimal Runtime Server",
idempotencyKey: "web:create:server-minimal-runtime-server-17:17"
});
});
});
async function submitWorkflow(target: HTMLElement) {
const form = target.querySelector<HTMLFormElement>('form[aria-label="创建服务器部署向导"]');
if (!form) throw new Error("create workflow form not found");
await act(async () => {
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
});
}
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
@@ -3,7 +3,7 @@ import { type ChangeEvent, type FormEvent, useEffect, useMemo, useState } from "
import type { GamePluginResponse, RunEndpointResponse, ServerDeploymentResponse, ServerDeploymentRevealResponse } from "../api/types";
import { ManagementDialog } from "./OperationControls";
import { endpointLabel, pluginCreateInputDefaults, pluginLabel, runtimeBindingFields, type ServerCreateFormState } from "../contracts/serverManagement";
import { endpointLabel, pluginCreateInputDefaults, pluginLabel, type ServerCreateFormState } from "../contracts/serverManagement";
import { cx } from "../utils/classes";
type WorkflowKind = "create" | "edit";
@@ -18,37 +18,32 @@ interface ServerDeploymentWorkflowProps {
busy?: boolean;
onReveal?: () => Promise<ServerDeploymentRevealResponse>;
onClose: () => void;
onSubmit: (form: ServerCreateFormState, saveAsDraft: boolean) => Promise<void>;
onSubmit: (form: ServerCreateFormState) => Promise<void>;
}
export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initialForm, deployment, busy = false, onReveal, onClose, onSubmit }: ServerDeploymentWorkflowProps) {
const [step, setStep] = useState(0);
const [form, setForm] = useState<ServerCreateFormState>(initialForm);
const [saveAsDraft, setSaveAsDraft] = useState(false);
const [revealBusy, setRevealBusy] = useState(false);
const [revealError, setRevealError] = useState("");
const selectedPlugin = useMemo(() => plugins.find((plugin) => plugin.id === form.pluginId), [form.pluginId, plugins]);
const profileOptions = selectedPlugin?.runtimeProfiles?.lifecycleProfiles ?? [];
const pluginFields = selectedPlugin?.createFields ?? [];
const bindingFields = runtimeBindingFields(selectedPlugin, form.profileKey);
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 needsTargetSelection = kind === "edit" && !initialForm.runEndpointId;
const selectedTargetID = form.runEndpointId;
const workflowSteps = kind === "create"
? [{ label: "选择目标", icon: Compass }, { label: "部署方式", icon: ServerCog }, { label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }]
? [{ label: "基本信息", icon: Compass }, { label: "确认", icon: Rocket }]
: needsTargetSelection
? [{ label: "选择运行节点", icon: Compass }, { label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }]
: [{ label: "相关配置", icon: FolderCog }, { label: "确认", icon: Rocket }];
const pluginStep = kind === "create" ? 0 : -1;
const targetStep = needsTargetSelection ? 0 : -1;
const modeStep = kind === "create" ? 1 : -1;
const configurationStep = kind === "create" ? 2 : needsTargetSelection ? 1 : 0;
const configurationStep = kind === "create" ? -1 : needsTargetSelection ? 1 : 0;
const reviewStep = workflowSteps.length - 1;
useEffect(() => {
if (!open) return;
setStep(0);
setSaveAsDraft(kind === "create" && !initialForm.runEndpointId);
setForm(initialForm);
setRevealBusy(false);
setRevealError("");
@@ -72,12 +67,11 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
}
function updateCreateInput(key: string, value: string) { setForm((current) => ({ ...current, createInputs: { ...current.createInputs, [key]: value } })); }
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.deploymentTargetId)) : Boolean(form.runEndpointId);
if (step === pluginStep) return Boolean(form.pluginId) && Boolean(form.name.trim());
if (step === targetStep) return 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;
if (form.deploymentMode === "existing-server" && !form.serverRoot.trim() && !deployment?.serverRootConfigured) return false;
if (form.deploymentMode === "custom-command" && !form.startCommand.trim() && !deployment?.startCommandConfigured) return false;
@@ -89,7 +83,7 @@ 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, deploymentTargetId: saveAsDraft ? "" : form.deploymentTargetId, runEndpointId: saveAsDraft ? "" : form.runEndpointId }, saveAsDraft);
await onSubmit(form);
}
async function revealSavedInputs() {
@@ -114,24 +108,20 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
}
const protectedState = (nextValue: string, configured: boolean) => nextValue.trim() ? "将替换" : configured ? "保持已配置" : "未配置";
const actionLabel = kind === "create" ? "保存草稿并准备专属 Run" : "保存部署设置";
const actionLabel = kind === "create" ? "创建服务器" : "保存部署设置";
return <ManagementDialog open={open} title={kind === "create" ? "创建服务器" : "编辑部署"} description={kind === "create" ? "按部署顺序完成设置;路径和命令始终受保护,不会在确认页或日志中回显。" : "仅停止中的服务器可以修改部署设置。已保存的受保护路径和命令仅在本窗口内读取,关闭后清除。"} wide onClose={closeWorkflow}>
return <ManagementDialog open={open} title={kind === "create" ? "创建服务器" : "编辑部署"} description={kind === "create" ? "只需选择插件类型并填写服务器名称;运行配置、部署方式和目录可在创建后的服务器详情中按需补充。" : "仅停止中的服务器可以修改部署设置。已保存的受保护路径和命令仅在本窗口内读取,关闭后清除。"} wide onClose={closeWorkflow}>
<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 === pluginStep && <div className="deployment-workflow-body">
<div className="workflow-hint-grid"><div className="workflow-hint-card"><strong></strong><span></span></div><div className="workflow-hint-card"><strong></strong><span></span></div><div className="workflow-hint-card"><strong> Run</strong><span></span></div></div>
<div className="form-grid"><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><input name="name" value={form.name} onChange={updateForm} placeholder="Example Survival #3" required /></label></div>
</div>}
{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 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 className="form-guidance"><strong>稿</strong><span></span></div>
<div className="form-grid"><label><select name="runEndpointId" value={selectedTargetID} onChange={updateForm} required><option value=""></option>{endpoints.map((endpoint) => <option key={endpoint.id} value={endpoint.id}>{endpointLabel(endpoint, endpoint.id)}</option>)}</select></label></div>
</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" }))} />
<ModeOption active={form.deploymentMode === "existing-server"} title="接管已有服务器" copy="预检指定目录并接入已有实例;不会把它当作一次新安装。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "existing-server" }))} />
<ModeOption active={form.deploymentMode === "custom-command"} title="自定义启动方式" copy="用于非标准启动器或脚本;需由节点策略允许。" onClick={() => setForm((current) => ({ ...current, deploymentMode: "custom-command" }))} />
</div></div>}
{step === configurationStep && <div className="deployment-workflow-body">{kind === "edit" && onReveal && <div className="form-guidance"><strong></strong><span>{revealBusy ? "正在读取已保存的目录和命令…" : "这些值只保留在当前编辑窗口,关闭后会清除。"}</span>{revealError && <><span className="field-help">{revealError}</span><button type="button" className="primary-command" disabled={busy || revealBusy} onClick={() => void revealSavedInputs()}></button></>}</div>}<div className="form-grid">
{kind === "create" && <label><input name="name" value={form.name} onChange={updateForm} placeholder="Example Survival #3" required /></label>}
{kind === "create" && <label><select name="profileKey" value={form.profileKey} onChange={updateForm}><option value="">使</option>{profileOptions.map((profile) => <option key={profile.key} value={profile.key}>{profile.key} · {profile.mode}</option>)}</select><small className="field-help"></small></label>}
{kind === "edit" && <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><small className="field-help"></small></label>}
{form.deploymentMode === "guided-install" && <label>{isScum ? "(必填)" : "(可选)"}<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置安装目录" : "完整绝对路径"} autoComplete="off" required={isScum && !deployment?.serverRootConfigured} /><small className="field-help">SCUM </small></label>}
{form.deploymentMode === "existing-server" && <label><input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已接管目录" : "完整绝对路径"} autoComplete="off" required={!deployment?.serverRootConfigured} /><small className="field-help">Run </small></label>}
@@ -147,16 +137,13 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
{form.deploymentMode === "guided-install" && <GuidedInstallPlan pluginName={pluginLabel(selectedPlugin, form.pluginId)} isScum={isScum} />}
{form.deploymentMode === "existing-server" && <ExistingServerAdoptionPlan pluginName={pluginLabel(selectedPlugin, form.pluginId)} isScum={isScum} />}
{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 && (kind === "create" ? <div className="deployment-workflow-body"><div className="deployment-review"><div><span></span><strong>{pluginLabel(selectedPlugin, form.pluginId)}</strong></div><div><span></span><strong>{form.name.trim() || "未填写"}</strong></div></div><div className="form-guidance"><strong></strong><span> Run</span></div></div> : <div className="deployment-workflow-body"><div className="deployment-review"><div><span></span><strong>{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></strong><span>{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>;
}
function ModeOption({ active, title, copy, onClick }: { active: boolean; title: string; copy: string; onClick: () => void }) { return <button type="button" className={cx("deployment-mode-option", active && "deployment-mode-option-active")} onClick={onClick}><strong>{title}</strong><span>{copy}</span></button>; }
function GuidedInstallPlan({ pluginName, isScum }: { pluginName: string; isScum: boolean }) {
const steps = isScum ? [
{ icon: ScanSearch, title: "预检目录与端口", copy: "确认安装目录可用、节点兼容且端口可绑定。" },