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
+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>