feat: redesign server deployment workflow
This commit is contained in:
@@ -28,6 +28,7 @@ import type {
|
||||
RemoteAdapterDeclarationResponse
|
||||
} from "../api/types";
|
||||
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
|
||||
import { ServerDeploymentWorkflow } from "../components/ServerDeploymentWorkflow";
|
||||
import { ClientManagerLifecyclePanel } from "../components/ClientManagerLifecyclePanel";
|
||||
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
|
||||
import { PluginLifecycleWorkbench } from "../components/PluginLifecycleWorkbench";
|
||||
@@ -48,7 +49,7 @@ import {
|
||||
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
|
||||
import { canStartServer, canStopServer, endpointLabel, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
|
||||
import { canStartServer, canStopServer, defaultServerCreateForm, endpointLabel, pluginCreateInputDefaults, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerCreateFormState, type ServerMetadataFormState } from "../contracts/serverManagement";
|
||||
import {
|
||||
serverDetailSections,
|
||||
serverIsOnline,
|
||||
@@ -93,6 +94,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
const [runtimeBinding, setRuntimeBinding] = useState<LoadState<RuntimeBindingResponse>>({ status: "loading" });
|
||||
const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" });
|
||||
const [endpoints, setEndpoints] = useState<RunEndpointResponse[]>([]);
|
||||
const [showDeploymentEditor, setShowDeploymentEditor] = useState(false);
|
||||
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
|
||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||
|
||||
@@ -199,6 +201,18 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
});
|
||||
}
|
||||
|
||||
async function saveDeploymentWorkflow(form: ServerCreateFormState) {
|
||||
if (instance.status !== "ready") return;
|
||||
const current = instance.data;
|
||||
const operationId = operations.begin({ intent: "更新部署定义", targetKind: "server", targetId: current.id, requester: session.displayName });
|
||||
try {
|
||||
await platformApiClient.updateServerDeployment(current.id, { runEndpointId: form.runEndpointId || undefined, mode: form.deploymentMode, profileKey: form.profileKey || undefined, createInputs: form.createInputs, serverRoot: form.serverRoot.trim() || undefined, workingDirectory: form.workingDirectory.trim() || undefined, installCommand: form.installCommand.trim() || undefined, startCommand: form.startCommand.trim() || undefined, stopCommand: form.stopCommand.trim() || undefined, statusCommand: form.statusCommand.trim() || undefined, shell: form.shell || undefined });
|
||||
operations.succeed(operationId, "部署设置已保存;路径和命令保持受保护状态。");
|
||||
setShowDeploymentEditor(false);
|
||||
await refresh();
|
||||
} catch (error) { operations.fail(operationId, error instanceof Error ? error.message : "部署设置保存失败"); }
|
||||
}
|
||||
|
||||
if (!serverId) {
|
||||
return (
|
||||
<EmptyState title="未选择服务器" description="请从服务器列表进入详情页。" actionLabel="返回服务器列表" onAction={() => onNavigate("servers")} />
|
||||
@@ -269,6 +283,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
<Square size={15} />
|
||||
<span>停止</span>
|
||||
</button>
|
||||
<button type="button" className="icon-command" disabled={instance.data.state === "running" || instance.data.state === "installing"} title={instance.data.state === "running" || instance.data.state === "installing" ? "请先停止服务器再编辑部署" : "编辑部署"} onClick={() => setShowDeploymentEditor(true)}><Pencil size={15} /><span>编辑部署</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="server-detail-stat-strip">
|
||||
@@ -307,7 +322,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
onChanged={() => void refresh()}
|
||||
/>
|
||||
)}
|
||||
{section === "overview" && <ServerDeploymentSection instance={instance.data} deployment={deployment} endpoints={endpoints} session={session} operations={operations} onChanged={() => void refresh()} />}
|
||||
{section === "overview" && <ServerDeploymentSection instance={instance.data} deployment={deployment} onEdit={() => setShowDeploymentEditor(true)} />}
|
||||
{section === "overview" && <RuntimeDLLExtensionsPanel runtimeProfiles={plugins.find((plugin) => plugin.id === instance.data.pluginId)?.runtimeProfiles} />}
|
||||
{section === "overview" && <SourceRCONCommandPanel serverId={instance.data.id} pluginId={instance.data.pluginId} />}
|
||||
{section === "overview" && (
|
||||
@@ -337,6 +352,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
{section === "plugins" && <PluginControlsSection serverId={serverId} instance={instance.data} plugins={plugins} artifacts={artifacts} session={session} operations={operations} onNavigate={onNavigate} />}
|
||||
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
|
||||
{section === "history" && <HistorySection serverId={serverId} serverOperations={serverOperations} jobs={jobs} artifacts={artifacts} metricHistory={metricHistory} backups={backups} remoteAdapters={remoteAdapters} />}
|
||||
<ServerDeploymentWorkflow open={showDeploymentEditor && deployment.status === "ready"} kind="edit" plugins={plugins} endpoints={endpoints} initialForm={deploymentWorkflowForm(instance.data, deployment.status === "ready" ? deployment.data : undefined, plugins, endpoints)} deployment={deployment.status === "ready" ? deployment.data : undefined} busy={operations.isPending(instance.data.id, "更新部署定义")} onClose={() => setShowDeploymentEditor(false)} onSubmit={saveDeploymentWorkflow} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -428,88 +444,26 @@ function ServerMetadataSection({ instance, session, operations, onChanged }: Ser
|
||||
interface ServerDeploymentSectionProps {
|
||||
instance: ServerInstanceResponse;
|
||||
deployment: LoadState<ServerDeploymentResponse>;
|
||||
endpoints: RunEndpointResponse[];
|
||||
session: PageComponentProps["session"];
|
||||
operations: PageComponentProps["operations"];
|
||||
onChanged: () => void;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
function ServerDeploymentSection({ instance, deployment, endpoints, session, operations, onChanged }: ServerDeploymentSectionProps) {
|
||||
const [runEndpointId, setRunEndpointId] = useState(instance.runEndpointId);
|
||||
const [mode, setMode] = useState<"guided-install" | "existing-server" | "custom-command">("guided-install");
|
||||
const [serverRoot, setServerRoot] = useState("");
|
||||
const [workingDirectory, setWorkingDirectory] = useState("");
|
||||
const [installCommand, setInstallCommand] = useState("");
|
||||
const [startCommand, setStartCommand] = useState("");
|
||||
const [stopCommand, setStopCommand] = useState("");
|
||||
const [statusCommand, setStatusCommand] = useState("");
|
||||
const [shell, setShell] = useState<"" | "posix-sh" | "powershell" | "cmd">("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<{ status: "succeeded" | "failed"; label: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setRunEndpointId(instance.runEndpointId);
|
||||
if (deployment.status === "ready") {
|
||||
setMode(deployment.data.mode ?? "guided-install");
|
||||
setShell(deployment.data.shell ?? "");
|
||||
}
|
||||
}, [deployment, instance.id, instance.runEndpointId]);
|
||||
|
||||
async function save(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
const operationId = operations.begin({ intent: "更新部署定义", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||||
try {
|
||||
await platformApiClient.updateServerDeployment(instance.id, { runEndpointId: runEndpointId || undefined, mode, serverRoot: serverRoot.trim() || undefined, workingDirectory: workingDirectory.trim() || undefined, installCommand: installCommand.trim() || undefined, startCommand: startCommand.trim() || undefined, stopCommand: stopCommand.trim() || undefined, statusCommand: statusCommand.trim() || undefined, shell: shell || undefined });
|
||||
operations.succeed(operationId, "部署定义已保存;受保护路径和命令不会回显。");
|
||||
setResult({ status: "succeeded", label: "部署定义已保存" });
|
||||
setServerRoot(""); setWorkingDirectory(""); setInstallCommand(""); setStartCommand(""); setStopCommand(""); setStatusCommand("");
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
const label = error instanceof Error ? error.message : "部署定义保存失败";
|
||||
operations.fail(operationId, label);
|
||||
setResult({ status: "failed", label });
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function deploy() {
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
const operationId = operations.begin({ intent: "部署服务器", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||||
try {
|
||||
const response = await platformApiClient.deployServerInstance(instance.id, { expectedConfigVersion: instance.configVersion, idempotencyKey: `web:deploy:${instance.id}:${Date.now()}` });
|
||||
operations.succeed(operationId, response.job.id ? "部署任务已进入队列,等待 Run 领取。" : "部署请求已接受。");
|
||||
setResult({ status: "succeeded", label: deploymentProgressLabel(response.job.progress) });
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
const label = error instanceof Error ? error.message : "部署失败";
|
||||
operations.fail(operationId, label);
|
||||
setResult({ status: "failed", label });
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
function ServerDeploymentSection({ instance, deployment, onEdit }: ServerDeploymentSectionProps) {
|
||||
if (deployment.status === "loading") return <LoadingState label="正在加载部署定义…" compact />;
|
||||
if (deployment.status === "error") return <ErrorState title="部署定义不可用" reason={deployment.reason} diagnosticId={`deployment:${instance.id}`} compact />;
|
||||
const view = deployment.data;
|
||||
return <article className="console-panel" aria-label="server deployment">
|
||||
<div className="panel-header"><h2><PackageOpen size={16} style={{ verticalAlign: "-2px" }} /> 部署定义</h2><span className="page-status">{view.mode || "未配置"} · 修订 {view.revision}</span></div>
|
||||
<p className="section-copy">根目录、工作目录和命令为受保护输入:保存后仅显示已配置状态,不会显示原文。</p>
|
||||
<div className="action-list"><span>根目录:{view.serverRootConfigured ? "已配置" : "未配置"}</span><span>工作目录:{view.workingDirectoryConfigured ? "已配置" : "未配置"}</span><span>安装 / 启动:{view.installCommandConfigured ? "已配置" : "未配置"} / {view.startCommandConfigured ? "已配置" : "未配置"}</span></div>
|
||||
<form className="provider-form" style={{ marginTop: 12 }} onSubmit={(event) => void save(event)}>
|
||||
<div className="form-grid">
|
||||
<label>运行节点<select value={runEndpointId} onChange={(event) => setRunEndpointId(event.target.value)}><option value="">暂不绑定</option>{endpoints.map((endpoint) => <option key={endpoint.id} value={endpoint.id}>{endpointLabel(endpoint, endpoint.id)}</option>)}</select></label>
|
||||
<label>部署方式<select value={mode} onChange={(event) => setMode(event.target.value as typeof mode)}><option value="guided-install">插件引导安装</option><option value="existing-server">接管已有服务器</option><option value="custom-command">自定义命令</option></select></label>
|
||||
<label>服务器根目录(重新填写才会更改)<input value={serverRoot} onChange={(event) => setServerRoot(event.target.value)} placeholder="完整绝对路径" autoComplete="off" /></label>
|
||||
<label>工作目录(重新填写才会更改)<input value={workingDirectory} onChange={(event) => setWorkingDirectory(event.target.value)} placeholder="完整绝对路径" autoComplete="off" /></label>
|
||||
{mode === "custom-command" && <><label>命令解释器<select value={shell} onChange={(event) => setShell(event.target.value as typeof shell)}><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 value={installCommand} onChange={(event) => setInstallCommand(event.target.value)} autoComplete="off" /></label><label>启动命令(首次自定义部署必填)<input value={startCommand} onChange={(event) => setStartCommand(event.target.value)} autoComplete="off" /></label><label>停止命令<input value={stopCommand} onChange={(event) => setStopCommand(event.target.value)} autoComplete="off" /></label><label>状态命令<input value={statusCommand} onChange={(event) => setStatusCommand(event.target.value)} autoComplete="off" /></label></>}
|
||||
</div>
|
||||
<div className="action-strip"><button type="submit" className="primary-command" disabled={busy}><Pencil size={14} /><span>{busy ? "保存中…" : "保存部署定义"}</span></button>{instance.state === "draft" || instance.state === "failed" ? <button type="button" className="icon-command" disabled={busy || !runEndpointId} onClick={() => void deploy()}><Sparkles size={14} /><span>部署到 Run</span></button> : null}</div>
|
||||
</form>
|
||||
{result && <div style={{ marginTop: 10 }}><ResultBadge status={result.status} label={result.label} /></div>}
|
||||
<p className="section-copy">服务器目录是主目录;执行目录只用于高级自定义启动,留空时继承服务器目录。路径和命令均为受保护输入,不会回显。</p>
|
||||
<div className="console-row-list"><div className="console-row"><span>服务器目录</span><strong>{view.serverRootConfigured ? "已配置" : "未配置"}</strong></div><div className="console-row"><span>高级执行目录</span><strong>{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}</strong></div><div className="console-row"><span>启动设置</span><strong>{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "未配置" : "插件引导"}</strong></div></div>
|
||||
<div className="action-strip" style={{ marginTop: 12 }}><button type="button" className="primary-command" disabled={instance.state === "running" || instance.state === "installing"} onClick={onEdit}><Pencil size={14} /><span>编辑部署</span></button>{(instance.state === "draft" || instance.state === "failed") && <span className="field-help">保存后可从详情明确发起部署。</span>}</div>
|
||||
</article>;
|
||||
}
|
||||
|
||||
function deploymentWorkflowForm(instance: ServerInstanceResponse, deployment: ServerDeploymentResponse | undefined, plugins: GamePluginResponse[], endpoints: RunEndpointResponse[]): ServerCreateFormState {
|
||||
const plugin = plugins.find((item) => item.id === instance.pluginId);
|
||||
return { ...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 ?? "" };
|
||||
}
|
||||
|
||||
function deploymentProgressLabel(progress: JobResponse["progress"]): string {
|
||||
switch (progress.phase) {
|
||||
case "queued": return "任务已排队,等待 Run 领取";
|
||||
|
||||
Reference in New Issue
Block a user