feat: redesign server deployment workflow
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { AlertTriangle, CakeSlice, Candy, Search, Sparkles, Trash2 } from "lucide-react";
|
||||
import { type CSSProperties, type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type CSSProperties, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
@@ -17,15 +17,14 @@ import {
|
||||
useRuntimeTaskController
|
||||
} from "../components/RuntimeTaskProgress";
|
||||
import { ConfirmDialog, ManagementDialog, UsageMeter } from "../components/OperationControls";
|
||||
import { ServerDeploymentWorkflow } from "../components/ServerDeploymentWorkflow";
|
||||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import {
|
||||
canDeleteServer,
|
||||
defaultServerCreateForm,
|
||||
endpointLabel,
|
||||
pluginCreateInputDefaults,
|
||||
pluginLabel,
|
||||
runtimeBindingFields,
|
||||
pluginCreateInputDefaults,
|
||||
type ServerCreateFormState
|
||||
} from "../contracts/serverManagement";
|
||||
import { summarizeServerOperations } from "../contracts/operationsConsole";
|
||||
@@ -72,6 +71,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
const [statusFilter, setStatusFilter] = useState<ServerStatusFilter>("all");
|
||||
const [form, setForm] = useState<ServerCreateFormState>(() => defaultServerCreateForm([], []));
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [editDeployment, setEditDeployment] = useState<{ instance: ServerInstanceResponse; deployment: import("../api/types").ServerDeploymentResponse } | null>(null);
|
||||
const runtimeTask = useRuntimeTaskController();
|
||||
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState<ReturnType<typeof serverDeleteConfirmation> | null>(null);
|
||||
@@ -102,7 +102,6 @@ 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 || ""
|
||||
@@ -154,39 +153,10 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
const visibleCards = useMemo(() => filterServerCards(cards, keyword, statusFilter), [cards, keyword, statusFilter]);
|
||||
const createPending = operations.isPending("platform", "创建服务器");
|
||||
const canManageServers = session.capabilities.includes("servers.manage");
|
||||
const selectedCreatePlugin = plugins.find((plugin) => plugin.id === form.pluginId);
|
||||
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: {}, createInputs: pluginCreateInputDefaults(plugin) };
|
||||
}
|
||||
if (name === "profileKey") {
|
||||
return { ...current, profileKey: value, bindings: {} };
|
||||
}
|
||||
return { ...current, [name]: value };
|
||||
});
|
||||
}
|
||||
|
||||
function updateBinding(key: string, value: string) {
|
||||
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();
|
||||
async function handleCreate(nextForm: ServerCreateFormState) {
|
||||
const operationId = operations.begin({ intent: "创建服务器", targetKind: "server", targetId: "platform", requester: session.displayName });
|
||||
try {
|
||||
const result = await platformApiClient.createServerWorkflow(serverCreateRequestFromForm(form));
|
||||
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);
|
||||
setForm(defaultServerCreateForm(plugins, endpoints));
|
||||
setShowCreate(false);
|
||||
@@ -196,6 +166,33 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
}
|
||||
}
|
||||
|
||||
async function openEditDeployment(instance: ServerInstanceResponse) {
|
||||
if (instance.state === "running" || instance.state === "installing") return;
|
||||
try {
|
||||
const deployment = await platformApiClient.getServerDeployment(instance.id);
|
||||
const plugin = plugins.find((item) => item.id === instance.pluginId);
|
||||
setForm({ ...defaultServerCreateForm(plugins, endpoints), name: instance.name, pluginId: instance.pluginId, runEndpointId: instance.runEndpointId, profileKey: deployment.profileKey ?? plugin?.runtimeProfiles?.lifecycleProfiles?.[0]?.key ?? "", createInputs: deployment.createInputs ?? pluginCreateInputDefaults(plugin), deploymentMode: deployment.mode ?? "guided-install", shell: deployment.shell ?? "" });
|
||||
setEditDeployment({ instance, deployment });
|
||||
} catch (error) {
|
||||
const operationId = operations.begin({ intent: "读取部署设置", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||||
operations.fail(operationId, error instanceof Error ? error.message : "部署设置加载失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateDeployment(nextForm: ServerCreateFormState) {
|
||||
if (!editDeployment) return;
|
||||
const { instance } = editDeployment;
|
||||
const operationId = operations.begin({ intent: "更新部署定义", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||||
try {
|
||||
await platformApiClient.updateServerDeployment(instance.id, { runEndpointId: nextForm.runEndpointId || undefined, mode: nextForm.deploymentMode, profileKey: nextForm.profileKey || undefined, createInputs: nextForm.createInputs, serverRoot: nextForm.serverRoot.trim() || undefined, workingDirectory: nextForm.workingDirectory.trim() || undefined, installCommand: nextForm.installCommand.trim() || undefined, startCommand: nextForm.startCommand.trim() || undefined, stopCommand: nextForm.stopCommand.trim() || undefined, statusCommand: nextForm.statusCommand.trim() || undefined, shell: nextForm.shell || undefined });
|
||||
operations.succeed(operationId, "部署设置已保存;路径和命令保持受保护状态。");
|
||||
setEditDeployment(null);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
operations.fail(operationId, error instanceof Error ? error.message : "部署设置保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteServer() {
|
||||
if (!deleteConfirmation) {
|
||||
return;
|
||||
@@ -493,123 +490,8 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ManagementDialog
|
||||
open={showCreate && canManageServers}
|
||||
title="创建服务器"
|
||||
description="先保存服务器定义也可以;Run 注册后再绑定部署。根目录和命令只会写入受保护执行计划,之后不会在页面、任务或日志中显示。"
|
||||
wide
|
||||
onClose={() => { if (!createPending) setShowCreate(false); }}
|
||||
>
|
||||
<form className="provider-form dialog-form" onSubmit={(event) => void handleCreate(event)} aria-label="创建服务器">
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
名称
|
||||
<input name="name" value={form.name} onChange={updateForm} placeholder="Example Survival #3" required />
|
||||
</label>
|
||||
<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}>
|
||||
<option value="">暂不选择(保存草稿)</option>
|
||||
{endpoints.map((endpoint) => (
|
||||
<option key={endpoint.id} value={endpoint.id}>
|
||||
{endpointLabel(endpoint, endpoint.id)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
运行配置(可选)
|
||||
<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}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{createProfileUnavailable && (
|
||||
<div className="operations-inline-warning" role="status">
|
||||
<AlertTriangle size={14} />
|
||||
<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 ? "(必填)" : ""}
|
||||
<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>
|
||||
<div className="confirm-actions">
|
||||
<button type="button" disabled={createPending} onClick={() => setShowCreate(false)}>取消</button>
|
||||
<button type="submit" className="confirm-primary" disabled={createPending || createProfileUnavailable} title="创建服务器">
|
||||
<Sparkles size={16} />
|
||||
<span>{createPending ? "保存中…" : form.runEndpointId ? "保存并部署" : "保存草稿"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</ManagementDialog>
|
||||
<ServerDeploymentWorkflow open={showCreate && canManageServers} kind="create" plugins={plugins} endpoints={endpoints} initialForm={form} busy={createPending} onClose={() => setShowCreate(false)} onSubmit={handleCreate} />
|
||||
<ServerDeploymentWorkflow open={editDeployment !== null} kind="edit" plugins={plugins} endpoints={endpoints} initialForm={form} deployment={editDeployment?.deployment} busy={editDeployment ? operations.isPending(editDeployment.instance.id, "更新部署定义") : false} onClose={() => setEditDeployment(null)} onSubmit={handleUpdateDeployment} />
|
||||
|
||||
<ManagementDialog
|
||||
open={runTargetSelection !== null}
|
||||
@@ -712,6 +594,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
||||
canManage={canManageServers}
|
||||
deleteDisabledReason={serverDeleteDisabledReason(session, card.instance)}
|
||||
onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })}
|
||||
onEdit={() => void openEditDeployment(card.instance)}
|
||||
onQuickAction={(action) => void handleQuickRuntimeAction(card.instance, action)}
|
||||
onDelete={() => {
|
||||
setDeletePassword("");
|
||||
@@ -789,11 +672,12 @@ interface ServerCardProps {
|
||||
canManage: boolean;
|
||||
deleteDisabledReason: string;
|
||||
onOpen: () => void;
|
||||
onEdit: () => void;
|
||||
onQuickAction: (action: ServerQuickRuntimeAction) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function ServerCard({ card, metricsPending, metricsUnavailable, canManage, deleteDisabledReason, onOpen, onQuickAction, onDelete }: ServerCardProps) {
|
||||
function ServerCard({ card, metricsPending, metricsUnavailable, canManage, deleteDisabledReason, onOpen, onEdit, onQuickAction, onDelete }: ServerCardProps) {
|
||||
const { instance, metrics, pendingJobs, failedJobs = 0 } = card;
|
||||
const online = serverIsOnline(instance.state);
|
||||
const canDelete = deleteDisabledReason === "";
|
||||
@@ -914,6 +798,7 @@ function ServerCard({ card, metricsPending, metricsUnavailable, canManage, delet
|
||||
<Sparkles size={14} />
|
||||
<span>详情</span>
|
||||
</button>
|
||||
<button type="button" className="icon-command" disabled={!canManage || instance.state === "running" || instance.state === "installing"} title={instance.state === "running" || instance.state === "installing" ? "请先停止服务器再编辑部署" : "编辑部署"} onClick={onEdit}><span>编辑部署</span></button>
|
||||
<button ref={menuButtonRef} type="button" className="icon-command" disabled={!canOpenActions} title={canOpenActions ? "运行操作" : "当前账号没有运行操作权限"} aria-haspopup="menu" aria-expanded={menuOpen} onClick={toggleMenu}>
|
||||
<span>运行操作</span>
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user