diff --git a/openspec/changes/redesign-server-deployment-workflow/.openspec.yaml b/openspec/changes/redesign-server-deployment-workflow/.openspec.yaml new file mode 100644 index 0000000..5e6d53a --- /dev/null +++ b/openspec/changes/redesign-server-deployment-workflow/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-24 diff --git a/openspec/changes/redesign-server-deployment-workflow/design.md b/openspec/changes/redesign-server-deployment-workflow/design.md new file mode 100644 index 0000000..6ad8b85 --- /dev/null +++ b/openspec/changes/redesign-server-deployment-workflow/design.md @@ -0,0 +1,68 @@ +## Context + +The existing server-create dialog serializes target selection, deployment choice, paths, plugin game fields, runtime binding fields, and custom commands into one form. A server's deployment editor exists only in the detail overview beside many unrelated operational panels. The API already distinguishes drafts, protected deployment inputs, deployment modes, and explicit dispatch; the design problem is the management-console workflow, not a missing host-side capability. + +The console must preserve its black-mecha / magical-girl shared visual system and must not return protected paths, commands, credentials, or direct Run access to the browser. + +## Goals / Non-Goals + +**Goals:** + +- Make the normal operator flow target-first, mode-second, configuration-third, then review. +- Provide a single reusable workflow for new definitions and safe edits to drafts or stopped servers. +- Make draft creation explicit and keep deployment impact, capability issues, and write-only values understandable. +- Keep changes compatible with existing create, update-deployment, and deploy endpoints. + +**Non-Goals:** + +- Changing Run's protected deployment protocol, adding SSH/host browsing, returning protected values, or allowing deployment-definition mutation for an active server. +- Adding new game plugins, changing plugin manifest semantics, or replacing the console visual language. +- Implementing a generic shell editor or a path browser in Platform. + +## Decisions + +### 1. One controlled workflow owns both creation and deployment edits + +Introduce a `ServerDeploymentWorkflow` component with `create` and `edit` modes. The page that opens it owns only the lifecycle request: create calls the existing workflow-create endpoint, while edit saves the existing deployment definition and lets the operator explicitly deploy afterward. This removes duplicate form policy without introducing an API abstraction that hides lifecycle actions. + +The alternative—incrementally rearranging the two current forms—would keep divergent defaults, labels, and field visibility. + +### 2. Workflow steps follow operator decisions, not storage fields + +The component has four steps: target (plugin plus Run or explicit draft), deployment method, configuration, and review. Forward navigation validates only fields required by the selected mode. Back navigation preserves typed values. The final review is the only submit point. + +`guided-install` shows plugin game inputs and a primary “server directory”; `existing-server` shows the existing server directory; `custom-command` adds launch data. `workingDirectory` is an advanced custom-command input labelled as an execution directory and defaults logically to `serverRoot` when omitted. This preserves the current protocol distinction without demanding it from normal users. + +### 3. Draft is a deliberate outcome + +The target step presents compatible nodes and a separate “save as draft” choice with explanatory copy. It never represents draft creation as an empty selection accidentally left in a select input. A draft review changes the final action to save only; it does not offer a deceptive deployment promise. + +### 4. Editing is a discoverable operation with safe prefill + +Server cards and the detail header expose `编辑部署`. Detail opening uses the same workflow. The edit form receives safe deployment view values (mode, configured flags, create inputs, selected endpoint) and starts protected input fields blank with replacement copy. It is disabled for active servers, matching the existing server-side rule. + +### 5. Review uses only safe, operator-facing summaries + +The review names the plugin, chosen node/draft outcome, method, game configuration, and configured/replaced protected inputs. It never renders protected values. Plugin profile keys remain internal implementation values; where a choice is necessary, the UI calls it a runtime preset and formats a readable label. + +### 6. Styling is shared and compact + +Use `ManagementDialog`, `console-*`, command, form, and theme token primitives, adding only narrowly scoped workflow layout classes in `theme/base.css`. The step indicator is functional state, not page-local decoration; no fixed particles, opaque SaaS cards, or independent color system are introduced. + +## Risks / Trade-offs + +- [An edit lacks the original protected values] → Show configured state and make blank fields preserve the stored values; copy states that re-entry replaces the value. +- [A user mistakes a stopped-server edit for immediate deployment] → Separate “save changes” from the explicit later “deploy” operation and show the consequence in review. +- [Node compatibility information is incomplete before Run preflight] → Show only known endpoint availability/capability; retain Run preflight as authoritative. +- [A shared component expands scope] → Limit it to deployment-definition inputs and leave server metadata, game config editor, and runtime operations in their existing sections. + +## Migration Plan + +1. Ship the shared frontend workflow behind the existing API contracts. +2. Preserve all existing definition fields and treat missing working directories as inherited from the server directory for review copy only. +3. Route card/detail edit actions into the workflow and remove the old duplicate create/deployment forms. +4. Roll back by restoring the previous page components; persisted deployment definitions and endpoints remain unchanged. + +## Open Questions + +- None for the initial workflow: endpoint capability is already authoritatively checked at dispatch and protected input values intentionally cannot be prefilled. diff --git a/openspec/changes/redesign-server-deployment-workflow/proposal.md b/openspec/changes/redesign-server-deployment-workflow/proposal.md new file mode 100644 index 0000000..a1fc71f --- /dev/null +++ b/openspec/changes/redesign-server-deployment-workflow/proposal.md @@ -0,0 +1,30 @@ +## Why + +The server console technically supports deployment editing, but it is buried in a crowded overview and separates naming, node binding, deployment mode, and protected execution inputs into unrelated surfaces. Creation presents all fields at once, making ordinary game-server setup feel like an internal control-plane form and leaving operators uncertain about whether an existing server can be changed safely. + +Operators need one understandable deployment workflow: choose a game and target node, choose the intended deployment path, configure only the fields relevant to that path, review the impact, and later reopen the same workflow to change a stopped server. + +## What Changes + +- Replace the one-page server create form with a staged create workflow: target, deployment mode, relevant configuration, and review/submit. +- Add prominent “edit deployment” entry points from a server card and server-detail header; reuse the staged workflow for stopped servers and drafts. +- Make “save as draft” an explicit secondary path instead of an unexplained empty-node option. +- Simplify directory input: use a single primary server directory and expose the execution working directory only in advanced custom-command settings, defaulting it to the server directory when omitted. +- Replace plugin-internal labels such as “运行配置” and raw profile keys with operator-oriented copy, while preserving the existing protected-input and Run capability checks. +- Show a safe deployment summary and impact confirmation before create, save, or deploy. + +## Capabilities + +### New Capabilities + +- `server-deployment-console-workflow`: A shared staged console workflow for creating and editing server deployment definitions, including explicit draft handling, mode-specific fields, safe review, and discoverable entry points. + +### Modified Capabilities + +- None. + +## Impact + +- `platform_web/`: server list actions, server detail header/overview, shared deployment-workflow component and contracts, API adapters, theme-consistent styles, and focused frontend tests. +- `platform/`: no relaxation of protected deployment inputs, Run-only execution, capability validation, or active-server mutation protection; a small safe response projection may be added only if necessary for workflow prefill. +- Existing draft, deployment-update, and deploy endpoints remain the backing lifecycle operations; this change does not add platform-side host access, SSH, or a second executor. diff --git a/openspec/changes/redesign-server-deployment-workflow/specs/server-deployment-console-workflow/spec.md b/openspec/changes/redesign-server-deployment-workflow/specs/server-deployment-console-workflow/spec.md new file mode 100644 index 0000000..7d8dad0 --- /dev/null +++ b/openspec/changes/redesign-server-deployment-workflow/specs/server-deployment-console-workflow/spec.md @@ -0,0 +1,44 @@ +## ADDED Requirements + +### Requirement: Server creation follows a staged deployment workflow +The management console SHALL collect a server definition in the order target, deployment method, mode-relevant configuration, and review. The console MUST preserve entered values while the operator moves between workflow steps and MUST prevent submission until the selected mode's required values are present. + +#### Scenario: Create a guided server +- **WHEN** an operator selects a game plugin and compatible Run node, chooses guided installation, completes the plugin configuration, and confirms review +- **THEN** the console creates the server through the existing lifecycle workflow using the selected target and deployment definition + +### Requirement: Draft creation is explicit +The management console SHALL present saving without a Run node as an explicit draft choice and MUST explain that no deployment job will be dispatched. + +#### Scenario: Save an unbound definition +- **WHEN** an operator selects the draft choice during target selection and confirms the review +- **THEN** the console saves a draft and labels the final action as saving rather than deploying + +### Requirement: Deployment configuration is mode-specific +The management console SHALL show only the deployment inputs needed by the selected deployment method. It MUST use server directory as the primary directory input and MUST expose execution working directory only in advanced custom-command configuration, with copy that it inherits the server directory when omitted. + +#### Scenario: Configure an existing server +- **WHEN** an operator chooses existing-server deployment +- **THEN** the console requests the existing server directory and does not render installation or custom command inputs + +#### Scenario: Configure a custom launcher +- **WHEN** an operator chooses custom-command deployment +- **THEN** the console renders the required startup command and optional advanced execution directory, shell, install, stop, and status inputs + +### Requirement: Existing server deployment editing is discoverable and safe +The management console SHALL expose an edit-deployment action from a server card and server-detail header. It MUST reuse the staged deployment workflow, use only safe deployment projections for prefill, and MUST prevent opening an editable deployment workflow for active servers. + +#### Scenario: Edit a stopped server +- **WHEN** an operator invokes edit deployment for a stopped server +- **THEN** the console opens the staged workflow with its selected node, mode, create inputs, and protected-input configured state without revealing protected values + +#### Scenario: Attempt to edit a running server +- **WHEN** an operator views a running server +- **THEN** the console identifies that deployment settings require stopping the server before editing and does not submit a deployment mutation + +### Requirement: Review preserves deployment-input protection +The workflow review SHALL identify the chosen plugin, endpoint or draft status, deployment method, game configuration, and configured protected input state. It MUST NOT render stored or newly entered full host paths, command text, credentials, or runtime-binding secrets. + +#### Scenario: Review custom deployment +- **WHEN** an operator reaches review with a custom-command deployment +- **THEN** the review reports that server directory, startup command, and optional execution fields are configured or replaced without exposing their values diff --git a/openspec/changes/redesign-server-deployment-workflow/tasks.md b/openspec/changes/redesign-server-deployment-workflow/tasks.md new file mode 100644 index 0000000..88ddfd5 --- /dev/null +++ b/openspec/changes/redesign-server-deployment-workflow/tasks.md @@ -0,0 +1,16 @@ +## 1. Shared deployment workflow + +- [x] 1.1 Add a reusable staged deployment workflow contract and component that owns target, mode, configuration, and safe review state. +- [x] 1.2 Move the server-create request mapping into the workflow and implement explicit draft copy and mode-specific configuration visibility. +- [x] 1.3 Implement safe edit prefill and deployment-update submission, including protected-field configured/replacement summaries. + +## 2. Server management integration + +- [x] 2.1 Replace the Servers page create dialog with the staged workflow and expose an edit-deployment action from each eligible server card. +- [x] 2.2 Add a detail-header edit-deployment action and replace the overview's inline deployment mutation form with a concise safe summary and workflow launch. + +## 3. Theme-consistent interaction and verification + +- [x] 3.1 Add compact shared-theme workflow layout styles and remove obsolete one-page deployment form presentation. +- [x] 3.2 Add focused contracts/page tests for step order, explicit drafts, field visibility, safe review, and edit availability. +- [x] 3.3 Run focused frontend checks, `scripts/check-structure.sh`, and `openspec validate redesign-server-deployment-workflow --strict`; mark tasks complete only after evidence exists. diff --git a/platform_web/components/ServerDeploymentWorkflow.tsx b/platform_web/components/ServerDeploymentWorkflow.tsx new file mode 100644 index 0000000..4461fcc --- /dev/null +++ b/platform_web/components/ServerDeploymentWorkflow.tsx @@ -0,0 +1,115 @@ +import { CheckCircle2, CircleDashed, Compass, FolderCog, Rocket, ServerCog } from "lucide-react"; +import { type ChangeEvent, type FormEvent, useEffect, useMemo, useState } from "react"; + +import type { GamePluginResponse, RunEndpointResponse, ServerDeploymentResponse } from "../api/types"; +import { ManagementDialog } from "./OperationControls"; +import { endpointLabel, pluginCreateInputDefaults, pluginLabel, runtimeBindingFields, type ServerCreateFormState } from "../contracts/serverManagement"; +import { cx } from "../utils/classes"; + +type WorkflowKind = "create" | "edit"; + +interface ServerDeploymentWorkflowProps { + open: boolean; + kind: WorkflowKind; + plugins: GamePluginResponse[]; + endpoints: RunEndpointResponse[]; + initialForm: ServerCreateFormState; + deployment?: ServerDeploymentResponse; + busy?: boolean; + onClose: () => void; + onSubmit: (form: ServerCreateFormState, saveAsDraft: boolean) => Promise; +} + +const workflowSteps = [ + { label: "选择目标", icon: Compass }, + { label: "部署方式", icon: ServerCog }, + { label: "相关配置", icon: FolderCog }, + { label: "确认", icon: Rocket } +]; + +export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initialForm, deployment, busy = false, onClose, onSubmit }: ServerDeploymentWorkflowProps) { + const [step, setStep] = useState(0); + const [form, setForm] = useState(initialForm); + const [saveAsDraft, setSaveAsDraft] = useState(false); + 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); + + useEffect(() => { + if (!open) return; + setStep(0); + setSaveAsDraft(!initialForm.runEndpointId); + setForm(initialForm); + }, [initialForm, kind, open]); + + function updateForm(event: ChangeEvent) { + 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 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 === 0) return Boolean(form.pluginId && (saveAsDraft || form.runEndpointId)); + if (step === 2) { + if (kind === "create" && !form.name.trim()) return false; + if (form.deploymentMode === "custom-command" && !form.startCommand.trim() && !deployment?.startCommandConfigured) return false; + return pluginFields.filter((field) => field.required).every((field) => Boolean(form.createInputs[field.key]?.trim())); + } + return true; + } + + async function submit(event: FormEvent) { + event.preventDefault(); + if (step < workflowSteps.length - 1) { if (canContinue()) setStep((current) => current + 1); return; } + await onSubmit({ ...form, runEndpointId: saveAsDraft ? "" : form.runEndpointId }, saveAsDraft); + } + + const protectedState = (nextValue: string, configured: boolean) => nextValue.trim() ? "将替换" : configured ? "保持已配置" : "未配置"; + const actionLabel = kind === "create" ? (saveAsDraft ? "保存草稿" : "创建并部署") : (saveAsDraft ? "保存草稿设置" : "保存部署设置"); + + return { if (!busy) onClose(); }}> +
void submit(event)} aria-label={kind === "create" ? "创建服务器部署向导" : "编辑服务器部署向导"}> +
    {workflowSteps.map((item, index) => { const Icon = item.icon; return
  1. {index < step ? : }{index + 1}. {item.label}
  2. ; })}
+ {step === 0 &&
+
先确定游戏插件决定可填写的游戏配置与启动预设。
再选择节点节点负责预检和执行,平台不会直接访问主机。
也可存为草稿草稿不会发起部署,稍后可从服务器详情继续。
+
+ +
} + {step === 1 &&

选择你想让平台承担的方式。后续只显示这一方式需要的字段。

+ setForm((current) => ({ ...current, deploymentMode: "guided-install" }))} /> + setForm((current) => ({ ...current, deploymentMode: "existing-server" }))} /> + setForm((current) => ({ ...current, deploymentMode: "custom-command" }))} /> +
} + {step === 2 &&
+ {kind === "create" && } + {kind === "create" && } + + {pluginFields.map((field) => ( + + ))} +
+ {form.deploymentMode === "custom-command" &&
高级启动设置

只有自定义启动器需要这些设置。执行目录留空时,节点以服务器目录执行。

} + {kind === "create" && bindingFields.length > 0 &&
运行连接设置

用于插件声明的逻辑连接,不是服务器目录或游戏配置。

{bindingFields.map((field) => )}
} +
} + {step === 3 &&
插件类型{pluginLabel(selectedPlugin, form.pluginId)}
目标{saveAsDraft ? "保存为未绑定草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === form.runEndpointId), form.runEndpointId)}
部署方式{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}
服务器目录{protectedState(form.serverRoot, Boolean(deployment?.serverRootConfigured))}
{form.deploymentMode === "custom-command" && <>
启动命令{protectedState(form.startCommand, Boolean(deployment?.startCommandConfigured))}
执行目录{protectedState(form.workingDirectory, Boolean(deployment?.workingDirectoryConfigured))}
}
游戏配置{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}
{saveAsDraft ? "本次只保存定义" : activeServer ? "本次只保存部署设置" : "确认后将创建并派发部署"}{saveAsDraft ? "后续从服务器详情选择节点并部署。" : activeServer ? "保存后可在详情中明确发起部署;路径和命令不会显示原文。" : "Run 会在领取任务后执行本机预检,再进行安装、配置与启动。"}
} +
{step < workflowSteps.length - 1 ? : }
+
+
; +} + +function ModeOption({ active, title, copy, onClick }: { active: boolean; title: string; copy: string; onClick: () => void }) { return ; } diff --git a/platform_web/pages/ConsolePages.test.tsx b/platform_web/pages/ConsolePages.test.tsx index ff98fb5..9a5c2b4 100644 --- a/platform_web/pages/ConsolePages.test.tsx +++ b/platform_web/pages/ConsolePages.test.tsx @@ -9,6 +9,7 @@ import { ServerDetailPage } from "./ServerDetailPage"; import { ServersPage } from "./ServersPage"; import { UsersPage } from "./UsersPage"; import runtimeTaskProgressSource from "../components/RuntimeTaskProgress.tsx?raw"; +import serverDeploymentWorkflowSource from "../components/ServerDeploymentWorkflow.tsx?raw"; import serversPageSource from "./ServersPage.tsx?raw"; import serverDetailPageSource from "./ServerDetailPage.tsx?raw"; import type { PageComponentProps } from "../contracts/page"; @@ -167,21 +168,24 @@ describe("first-party console pages", () => { expect(serversPageSource).toContain("旧 run 会话已失效"); }); - it("submits declared runtime profiles and logical bindings from the create workflow", () => { - expect(serversPageSource).toContain(" { + expect(serversPageSource).toContain(">({ status: "loading" }); const [deployment, setDeployment] = useState>({ status: "loading" }); const [endpoints, setEndpoints] = useState([]); + const [showDeploymentEditor, setShowDeploymentEditor] = useState(false); const [confirm, setConfirm] = useState Promise }>(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 ( onNavigate("servers")} /> @@ -269,6 +283,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa 停止 +
@@ -307,7 +322,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa onChanged={() => void refresh()} /> )} - {section === "overview" && void refresh()} />} + {section === "overview" && setShowDeploymentEditor(true)} />} {section === "overview" && plugin.id === instance.data.pluginId)?.runtimeProfiles} />} {section === "overview" && } {section === "overview" && ( @@ -337,6 +352,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa {section === "plugins" && } {section === "llm" && } {section === "history" && } + setShowDeploymentEditor(false)} onSubmit={saveDeploymentWorkflow} /> )} @@ -428,88 +444,26 @@ function ServerMetadataSection({ instance, session, operations, onChanged }: Ser interface ServerDeploymentSectionProps { instance: ServerInstanceResponse; deployment: LoadState; - 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) { - 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 ; if (deployment.status === "error") return ; const view = deployment.data; return

部署定义

{view.mode || "未配置"} · 修订 {view.revision}
-

根目录、工作目录和命令为受保护输入:保存后仅显示已配置状态,不会显示原文。

-
根目录:{view.serverRootConfigured ? "已配置" : "未配置"}工作目录:{view.workingDirectoryConfigured ? "已配置" : "未配置"}安装 / 启动:{view.installCommandConfigured ? "已配置" : "未配置"} / {view.startCommandConfigured ? "已配置" : "未配置"}
-
void save(event)}> -
- - - - - {mode === "custom-command" && <>} -
-
{instance.state === "draft" || instance.state === "failed" ? : null}
-
- {result &&
} +

服务器目录是主目录;执行目录只用于高级自定义启动,留空时继承服务器目录。路径和命令均为受保护输入,不会回显。

+
服务器目录{view.serverRootConfigured ? "已配置" : "未配置"}
高级执行目录{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}
启动设置{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "未配置" : "插件引导"}
+
{(instance.state === "draft" || instance.state === "failed") && 保存后可从详情明确发起部署。}
; } +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 领取"; diff --git a/platform_web/pages/ServersPage.tsx b/platform_web/pages/ServersPage.tsx index 730657d..89ac899 100644 --- a/platform_web/pages/ServersPage.tsx +++ b/platform_web/pages/ServersPage.tsx @@ -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("all"); const [form, setForm] = useState(() => 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([]); const [deleteConfirmation, setDeleteConfirmation] = useState | 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) { - 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) { - 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
)} - { if (!createPending) setShowCreate(false); }} - > -
void handleCreate(event)} aria-label="创建服务器"> -
- - - - - {createProfileUnavailable && ( -
- - 当前插件没有声明运行配置,请重新注册完整 manifest 后刷新。 -
- )} - - - - {createPluginFields.map((field) => ( - - ))} - {form.deploymentMode === "custom-command" && ( - <> - - - - - - - )} - {createBindingFields.map((field) => ( - - ))} -
-
- - -
-
-
+ setShowCreate(false)} onSubmit={handleCreate} /> + setEditDeployment(null)} onSubmit={handleUpdateDeployment} /> 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 详情 + diff --git a/platform_web/theme/base.css b/platform_web/theme/base.css index 84ee8c5..863a42e 100644 --- a/platform_web/theme/base.css +++ b/platform_web/theme/base.css @@ -495,6 +495,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))} .management-dialog-panel .management-form,.management-dialog-panel .provider-form{border:0;padding:0;background:0 0;box-shadow:none;backdrop-filter:none} .management-dialog-panel .management-form{grid-template-columns:repeat(2,minmax(0,1fr))} .management-dialog-panel .dialog-form .confirm-actions,.management-dialog-panel .dialog-form .role-selector{grid-column:1/-1} +.server-deployment-workflow{display:grid;gap:14px}.deployment-workflow-steps{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:0;padding:0;list-style:none}.deployment-workflow-steps li{display:flex;align-items:center;gap:6px;min-height:38px;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--control-surface);color:var(--ink-faint);font-size:12px}.deployment-workflow-steps li>span{display:inline-flex;align-items:center;justify-content:center;color:var(--ink-soft)}.deployment-workflow-steps strong{font-size:12px}.deployment-workflow-step-active{border-color:var(--accent)!important;background:var(--primary-command-surface)!important;color:#fff!important;box-shadow:0 0 16px var(--accent-soft)}.deployment-workflow-step-complete{border-color:color-mix(in srgb,var(--teal) 68%,var(--line));color:var(--ink)!important}.deployment-workflow-body{display:grid;gap:12px;min-height:286px}.deployment-draft-choice{grid-template-columns:auto minmax(0,1fr);align-items:start;padding:10px 12px;border:1px dashed var(--line-strong);border-radius:8px;background:color-mix(in srgb,var(--surface-solid) 62%,transparent)}.deployment-draft-choice input{width:auto!important;min-height:auto!important;margin-top:3px}.deployment-draft-choice span{display:grid;gap:3px}.deployment-draft-choice small{color:var(--ink-faint);font-weight:600;line-height:1.4}.deployment-mode-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.deployment-mode-option{display:grid;align-content:start;gap:5px;min-height:130px;padding:14px;border:1px solid var(--line);border-radius:8px;background:var(--control-surface);color:var(--ink-soft);cursor:pointer;text-align:left}.deployment-mode-option strong{color:var(--ink);font-size:14px}.deployment-mode-option span{font-size:12px;line-height:1.45}.deployment-mode-option:hover,.deployment-mode-option:focus-visible,.deployment-mode-option-active{border-color:var(--accent);outline:0;box-shadow:inset 0 1px 0 var(--crystal-rim),0 0 0 2px var(--accent-soft);background:var(--menu-item-active-bg)}.deployment-review{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.deployment-review>div{display:grid;gap:3px;padding:10px;border:1px solid var(--line);border-radius:8px;background:color-mix(in srgb,var(--surface-solid) 64%,transparent)}.deployment-review span{color:var(--ink-faint);font-size:11.5px;font-weight:700}.deployment-review strong{color:var(--ink);font-size:13px;overflow-wrap:anywhere} .management-dialog-panel .plugin-detail-panel,.management-dialog-panel .plugin-group{border:0;padding:0;background:0 0;box-shadow:none;clip-path:none} .management-dialog-panel .plugin-group::after,.management-dialog-panel .plugin-group::before{content:none} .plugin-detail-actions{align-items:stretch} @@ -623,7 +624,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))} } @media (max-width:760px){.auth-shell{padding:14px;place-items:stretch} .auth-card{align-self:center} -.access-list-row,.maintenance-node-item,.maintenance-triage-grid,.management-form,.profile-background-grid,.profile-palette-grid,.profile-settings-form-row,.profile-settings-grid,.profile-settings-hero,.provider-preset-grid,.runtime-task-stages,.user-management-item,.workflow-hint-grid{grid-template-columns:1fr} +.access-list-row,.deployment-mode-grid,.deployment-review,.deployment-workflow-steps,.maintenance-node-item,.maintenance-triage-grid,.management-form,.profile-background-grid,.profile-palette-grid,.profile-settings-form-row,.profile-settings-grid,.profile-settings-hero,.provider-preset-grid,.runtime-task-stages,.user-management-item,.workflow-hint-grid{grid-template-columns:1fr} .console-row-actions,.maintenance-actions,.profile-settings-actions,.user-actions{justify-content:flex-start} .app-shell{display:block;grid-template-columns:1fr} .app-sidebar{position:fixed;inset:0 auto 0 0;z-index:60;width:min(82vw,300px);min-width:0;height:100dvh;display:flex;flex-direction:column;gap:12px;align-items:stretch;padding:max(14px,env(safe-area-inset-top)) 12px max(14px,env(safe-area-inset-bottom));border-right:1px solid color-mix(in srgb,var(--line-strong) 74%,transparent);border-bottom:0;overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;opacity:0;visibility:hidden;pointer-events:none;transform:translateX(calc(-100% - 18px));transition:transform .2s ease,opacity 160ms ease,visibility .2s step-end}