feat: redesign server deployment workflow
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-24
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
+44
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ServerCreateFormState>(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<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 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<HTMLFormElement>) {
|
||||||
|
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 <ManagementDialog open={open} title={kind === "create" ? "创建服务器" : "编辑部署"} description={kind === "create" ? "按部署顺序完成设置;路径和命令始终受保护,不会在确认页或日志中回显。" : "仅停止中的服务器可以修改部署设置。受保护路径和命令留空会保持原值。"} wide onClose={() => { if (!busy) onClose(); }}>
|
||||||
|
<form className="provider-form dialog-form server-deployment-workflow" onSubmit={(event) => void submit(event)} aria-label={kind === "create" ? "创建服务器部署向导" : "编辑服务器部署向导"}>
|
||||||
|
<ol className="deployment-workflow-steps" 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 === 0 && <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>也可存为草稿</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>运行节点<select name="runEndpointId" value={form.runEndpointId} onChange={updateForm} disabled={saveAsDraft} required={!saveAsDraft}><option value="">请选择运行节点</option>{endpoints.map((endpoint) => <option key={endpoint.id} value={endpoint.id}>{endpointLabel(endpoint, endpoint.id)}</option>)}</select></label></div>
|
||||||
|
<label className="deployment-draft-choice"><input type="checkbox" checked={saveAsDraft} onChange={(event) => setSaveAsDraft(event.target.checked)} /><span><strong>{kind === "create" ? "仅保存为草稿" : "保持为未绑定草稿"}</strong><small>暂不指定运行节点;不会安装、预检或派发任务。</small></span></label>
|
||||||
|
</div>}
|
||||||
|
{step === 1 && <div className="deployment-workflow-body"><p className="section-copy">选择你想让平台承担的方式。后续只显示这一方式需要的字段。</p><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 === 2 && <div className="deployment-workflow-body"><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>}
|
||||||
|
<label>服务器目录<input name="serverRoot" value={form.serverRoot} onChange={updateForm} placeholder={deployment?.serverRootConfigured ? "留空保持已配置目录" : "完整绝对路径"} autoComplete="off" /><small className="field-help">{form.deploymentMode === "existing-server" ? "已有服务器所在目录。" : "服务器文件、数据与配置的主目录。"}</small></label>
|
||||||
|
{pluginFields.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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{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 === 3 && <div className="deployment-workflow-body"><div className="deployment-review"><div><span>插件类型</span><strong>{pluginLabel(selectedPlugin, form.pluginId)}</strong></div><div><span>目标</span><strong>{saveAsDraft ? "保存为未绑定草稿" : endpointLabel(endpoints.find((endpoint) => endpoint.id === form.runEndpointId), form.runEndpointId)}</strong></div><div><span>部署方式</span><strong>{form.deploymentMode === "guided-install" ? "新建并安装" : form.deploymentMode === "existing-server" ? "接管已有服务器" : "自定义启动方式"}</strong></div><div><span>服务器目录</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></>}<div><span>游戏配置</span><strong>{Object.keys(form.createInputs).length ? `${Object.keys(form.createInputs).length} 项已准备` : "使用插件默认值"}</strong></div></div><div className="form-guidance"><strong>{saveAsDraft ? "本次只保存定义" : activeServer ? "本次只保存部署设置" : "确认后将创建并派发部署"}</strong><span>{saveAsDraft ? "后续从服务器详情选择节点并部署。" : activeServer ? "保存后可在详情中明确发起部署;路径和命令不会显示原文。" : "Run 会在领取任务后执行本机预检,再进行安装、配置与启动。"}</span></div></div>}
|
||||||
|
<div className="confirm-actions"><button type="button" disabled={busy} onClick={() => step === 0 ? onClose() : setStep((current) => current - 1)}>{step === 0 ? "取消" : "上一步"}</button>{step < workflowSteps.length - 1 ? <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>; }
|
||||||
@@ -9,6 +9,7 @@ import { ServerDetailPage } from "./ServerDetailPage";
|
|||||||
import { ServersPage } from "./ServersPage";
|
import { ServersPage } from "./ServersPage";
|
||||||
import { UsersPage } from "./UsersPage";
|
import { UsersPage } from "./UsersPage";
|
||||||
import runtimeTaskProgressSource from "../components/RuntimeTaskProgress.tsx?raw";
|
import runtimeTaskProgressSource from "../components/RuntimeTaskProgress.tsx?raw";
|
||||||
|
import serverDeploymentWorkflowSource from "../components/ServerDeploymentWorkflow.tsx?raw";
|
||||||
import serversPageSource from "./ServersPage.tsx?raw";
|
import serversPageSource from "./ServersPage.tsx?raw";
|
||||||
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
|
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
|
||||||
import type { PageComponentProps } from "../contracts/page";
|
import type { PageComponentProps } from "../contracts/page";
|
||||||
@@ -167,21 +168,24 @@ describe("first-party console pages", () => {
|
|||||||
expect(serversPageSource).toContain("旧 run 会话已失效");
|
expect(serversPageSource).toContain("旧 run 会话已失效");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("submits declared runtime profiles and logical bindings from the create workflow", () => {
|
it("uses a staged deployment workflow for create and edit without exposing protected inputs", () => {
|
||||||
expect(serversPageSource).toContain("<ManagementDialog");
|
expect(serversPageSource).toContain("<ServerDeploymentWorkflow");
|
||||||
expect(serversPageSource).toContain('className="provider-form dialog-form"');
|
expect(serversPageSource).toContain("openEditDeployment");
|
||||||
expect(serversPageSource).toContain('name="name"');
|
expect(serversPageSource).toContain("编辑部署");
|
||||||
expect(serversPageSource).toContain('name="profileKey"');
|
expect(serverDetailPageSource).toContain("<ServerDeploymentWorkflow");
|
||||||
expect(serversPageSource).toContain("runtimeBindingFields");
|
expect(serverDeploymentWorkflowSource).toContain("选择目标");
|
||||||
expect(serversPageSource).toContain("createProfileUnavailable");
|
expect(serverDeploymentWorkflowSource).toContain("部署方式");
|
||||||
expect(serversPageSource).toContain("当前插件没有声明运行配置");
|
expect(serverDeploymentWorkflowSource).toContain("相关配置");
|
||||||
expect(serversPageSource).toContain("updateBinding(field.key");
|
expect(serverDeploymentWorkflowSource).toContain("仅保存为草稿");
|
||||||
expect(serversPageSource).toContain('type={field.sensitive ? "password" : "text"}');
|
expect(serverDeploymentWorkflowSource).toContain("执行目录(可选)");
|
||||||
expect(serversPageSource).toContain("serverCreateRequestFromForm(form)");
|
expect(serverDeploymentWorkflowSource).toContain("默认使用服务器目录");
|
||||||
expect(serversPageSource).not.toContain('name="id"');
|
expect(serverDeploymentWorkflowSource).toContain("运行连接设置");
|
||||||
expect(serversPageSource).not.toContain("实例 ID");
|
expect(serverDeploymentWorkflowSource).toContain('type={field.sensitive ? "password" : "text"}');
|
||||||
|
expect(serversPageSource).toContain("serverCreateRequestFromForm(nextForm)");
|
||||||
|
expect(serverDeploymentWorkflowSource).not.toContain('name="id"');
|
||||||
|
expect(serverDeploymentWorkflowSource).not.toContain("实例 ID");
|
||||||
for (const forbidden of ["secret://", "/Users/", "/var/run/", "unix://", "tcp://"]) {
|
for (const forbidden of ["secret://", "/Users/", "/var/run/", "unix://", "tcp://"]) {
|
||||||
expect(serversPageSource).not.toContain(forbidden);
|
expect(serverDeploymentWorkflowSource).not.toContain(forbidden);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import type {
|
|||||||
RemoteAdapterDeclarationResponse
|
RemoteAdapterDeclarationResponse
|
||||||
} from "../api/types";
|
} from "../api/types";
|
||||||
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
|
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
|
||||||
|
import { ServerDeploymentWorkflow } from "../components/ServerDeploymentWorkflow";
|
||||||
import { ClientManagerLifecyclePanel } from "../components/ClientManagerLifecyclePanel";
|
import { ClientManagerLifecyclePanel } from "../components/ClientManagerLifecyclePanel";
|
||||||
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
|
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
|
||||||
import { PluginLifecycleWorkbench } from "../components/PluginLifecycleWorkbench";
|
import { PluginLifecycleWorkbench } from "../components/PluginLifecycleWorkbench";
|
||||||
@@ -48,7 +49,7 @@ import {
|
|||||||
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||||
import type { PageComponentProps } from "../contracts/page";
|
import type { PageComponentProps } from "../contracts/page";
|
||||||
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
|
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 {
|
import {
|
||||||
serverDetailSections,
|
serverDetailSections,
|
||||||
serverIsOnline,
|
serverIsOnline,
|
||||||
@@ -93,6 +94,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
|||||||
const [runtimeBinding, setRuntimeBinding] = useState<LoadState<RuntimeBindingResponse>>({ status: "loading" });
|
const [runtimeBinding, setRuntimeBinding] = useState<LoadState<RuntimeBindingResponse>>({ status: "loading" });
|
||||||
const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" });
|
const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" });
|
||||||
const [endpoints, setEndpoints] = useState<RunEndpointResponse[]>([]);
|
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 [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
|
||||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
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) {
|
if (!serverId) {
|
||||||
return (
|
return (
|
||||||
<EmptyState title="未选择服务器" description="请从服务器列表进入详情页。" actionLabel="返回服务器列表" onAction={() => onNavigate("servers")} />
|
<EmptyState title="未选择服务器" description="请从服务器列表进入详情页。" actionLabel="返回服务器列表" onAction={() => onNavigate("servers")} />
|
||||||
@@ -269,6 +283,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
|||||||
<Square size={15} />
|
<Square size={15} />
|
||||||
<span>停止</span>
|
<span>停止</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<div className="server-detail-stat-strip">
|
<div className="server-detail-stat-strip">
|
||||||
@@ -307,7 +322,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
|||||||
onChanged={() => void refresh()}
|
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" && <RuntimeDLLExtensionsPanel runtimeProfiles={plugins.find((plugin) => plugin.id === instance.data.pluginId)?.runtimeProfiles} />}
|
||||||
{section === "overview" && <SourceRCONCommandPanel serverId={instance.data.id} pluginId={instance.data.pluginId} />}
|
{section === "overview" && <SourceRCONCommandPanel serverId={instance.data.id} pluginId={instance.data.pluginId} />}
|
||||||
{section === "overview" && (
|
{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 === "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 === "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} />}
|
{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 {
|
interface ServerDeploymentSectionProps {
|
||||||
instance: ServerInstanceResponse;
|
instance: ServerInstanceResponse;
|
||||||
deployment: LoadState<ServerDeploymentResponse>;
|
deployment: LoadState<ServerDeploymentResponse>;
|
||||||
endpoints: RunEndpointResponse[];
|
onEdit: () => void;
|
||||||
session: PageComponentProps["session"];
|
|
||||||
operations: PageComponentProps["operations"];
|
|
||||||
onChanged: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ServerDeploymentSection({ instance, deployment, endpoints, session, operations, onChanged }: ServerDeploymentSectionProps) {
|
function ServerDeploymentSection({ instance, deployment, onEdit }: 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); }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deployment.status === "loading") return <LoadingState label="正在加载部署定义…" compact />;
|
if (deployment.status === "loading") return <LoadingState label="正在加载部署定义…" compact />;
|
||||||
if (deployment.status === "error") return <ErrorState title="部署定义不可用" reason={deployment.reason} diagnosticId={`deployment:${instance.id}`} compact />;
|
if (deployment.status === "error") return <ErrorState title="部署定义不可用" reason={deployment.reason} diagnosticId={`deployment:${instance.id}`} compact />;
|
||||||
const view = deployment.data;
|
const view = deployment.data;
|
||||||
return <article className="console-panel" aria-label="server deployment">
|
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>
|
<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>
|
<p className="section-copy">服务器目录是主目录;执行目录只用于高级自定义启动,留空时继承服务器目录。路径和命令均为受保护输入,不会回显。</p>
|
||||||
<div className="action-list"><span>根目录:{view.serverRootConfigured ? "已配置" : "未配置"}</span><span>工作目录:{view.workingDirectoryConfigured ? "已配置" : "未配置"}</span><span>安装 / 启动:{view.installCommandConfigured ? "已配置" : "未配置"} / {view.startCommandConfigured ? "已配置" : "未配置"}</span></div>
|
<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>
|
||||||
<form className="provider-form" style={{ marginTop: 12 }} onSubmit={(event) => void save(event)}>
|
<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>
|
||||||
<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>}
|
|
||||||
</article>;
|
</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 {
|
function deploymentProgressLabel(progress: JobResponse["progress"]): string {
|
||||||
switch (progress.phase) {
|
switch (progress.phase) {
|
||||||
case "queued": return "任务已排队,等待 Run 领取";
|
case "queued": return "任务已排队,等待 Run 领取";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AlertTriangle, CakeSlice, Candy, Search, Sparkles, Trash2 } from "lucide-react";
|
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 { createPortal } from "react-dom";
|
||||||
|
|
||||||
import { platformApiClient } from "../api/client";
|
import { platformApiClient } from "../api/client";
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
useRuntimeTaskController
|
useRuntimeTaskController
|
||||||
} from "../components/RuntimeTaskProgress";
|
} from "../components/RuntimeTaskProgress";
|
||||||
import { ConfirmDialog, ManagementDialog, UsageMeter } from "../components/OperationControls";
|
import { ConfirmDialog, ManagementDialog, UsageMeter } from "../components/OperationControls";
|
||||||
|
import { ServerDeploymentWorkflow } from "../components/ServerDeploymentWorkflow";
|
||||||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||||
import type { PageComponentProps } from "../contracts/page";
|
import type { PageComponentProps } from "../contracts/page";
|
||||||
import {
|
import {
|
||||||
@@ -24,8 +25,6 @@ import {
|
|||||||
defaultServerCreateForm,
|
defaultServerCreateForm,
|
||||||
endpointLabel,
|
endpointLabel,
|
||||||
pluginCreateInputDefaults,
|
pluginCreateInputDefaults,
|
||||||
pluginLabel,
|
|
||||||
runtimeBindingFields,
|
|
||||||
type ServerCreateFormState
|
type ServerCreateFormState
|
||||||
} from "../contracts/serverManagement";
|
} from "../contracts/serverManagement";
|
||||||
import { summarizeServerOperations } from "../contracts/operationsConsole";
|
import { summarizeServerOperations } from "../contracts/operationsConsole";
|
||||||
@@ -72,6 +71,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
|||||||
const [statusFilter, setStatusFilter] = useState<ServerStatusFilter>("all");
|
const [statusFilter, setStatusFilter] = useState<ServerStatusFilter>("all");
|
||||||
const [form, setForm] = useState<ServerCreateFormState>(() => defaultServerCreateForm([], []));
|
const [form, setForm] = useState<ServerCreateFormState>(() => defaultServerCreateForm([], []));
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [editDeployment, setEditDeployment] = useState<{ instance: ServerInstanceResponse; deployment: import("../api/types").ServerDeploymentResponse } | null>(null);
|
||||||
const runtimeTask = useRuntimeTaskController();
|
const runtimeTask = useRuntimeTaskController();
|
||||||
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
|
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
|
||||||
const [deleteConfirmation, setDeleteConfirmation] = useState<ReturnType<typeof serverDeleteConfirmation> | null>(null);
|
const [deleteConfirmation, setDeleteConfirmation] = useState<ReturnType<typeof serverDeleteConfirmation> | null>(null);
|
||||||
@@ -102,7 +102,6 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
|||||||
pluginId: plugin?.id ?? "",
|
pluginId: plugin?.id ?? "",
|
||||||
profileKey,
|
profileKey,
|
||||||
bindings: plugin?.id === current.pluginId && profileKey === current.profileKey ? current.bindings : {},
|
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)
|
runEndpointId: endpointResponse.items.some((endpoint) => endpoint.id === current.runEndpointId)
|
||||||
? current.runEndpointId
|
? current.runEndpointId
|
||||||
: endpointResponse.items[0]?.id || ""
|
: 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 visibleCards = useMemo(() => filterServerCards(cards, keyword, statusFilter), [cards, keyword, statusFilter]);
|
||||||
const createPending = operations.isPending("platform", "创建服务器");
|
const createPending = operations.isPending("platform", "创建服务器");
|
||||||
const canManageServers = session.capabilities.includes("servers.manage");
|
const canManageServers = session.capabilities.includes("servers.manage");
|
||||||
const selectedCreatePlugin = plugins.find((plugin) => plugin.id === form.pluginId);
|
async function handleCreate(nextForm: ServerCreateFormState) {
|
||||||
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();
|
|
||||||
const operationId = operations.begin({ intent: "创建服务器", targetKind: "server", targetId: "platform", requester: session.displayName });
|
const operationId = operations.begin({ intent: "创建服务器", targetKind: "server", targetId: "platform", requester: session.displayName });
|
||||||
try {
|
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);
|
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));
|
setForm(defaultServerCreateForm(plugins, endpoints));
|
||||||
setShowCreate(false);
|
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() {
|
async function handleDeleteServer() {
|
||||||
if (!deleteConfirmation) {
|
if (!deleteConfirmation) {
|
||||||
return;
|
return;
|
||||||
@@ -493,123 +490,8 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ManagementDialog
|
<ServerDeploymentWorkflow open={showCreate && canManageServers} kind="create" plugins={plugins} endpoints={endpoints} initialForm={form} busy={createPending} onClose={() => setShowCreate(false)} onSubmit={handleCreate} />
|
||||||
open={showCreate && canManageServers}
|
<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} />
|
||||||
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>
|
|
||||||
|
|
||||||
<ManagementDialog
|
<ManagementDialog
|
||||||
open={runTargetSelection !== null}
|
open={runTargetSelection !== null}
|
||||||
@@ -712,6 +594,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
|
|||||||
canManage={canManageServers}
|
canManage={canManageServers}
|
||||||
deleteDisabledReason={serverDeleteDisabledReason(session, card.instance)}
|
deleteDisabledReason={serverDeleteDisabledReason(session, card.instance)}
|
||||||
onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })}
|
onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })}
|
||||||
|
onEdit={() => void openEditDeployment(card.instance)}
|
||||||
onQuickAction={(action) => void handleQuickRuntimeAction(card.instance, action)}
|
onQuickAction={(action) => void handleQuickRuntimeAction(card.instance, action)}
|
||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
setDeletePassword("");
|
setDeletePassword("");
|
||||||
@@ -789,11 +672,12 @@ interface ServerCardProps {
|
|||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
deleteDisabledReason: string;
|
deleteDisabledReason: string;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
onQuickAction: (action: ServerQuickRuntimeAction) => void;
|
onQuickAction: (action: ServerQuickRuntimeAction) => void;
|
||||||
onDelete: () => 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 { instance, metrics, pendingJobs, failedJobs = 0 } = card;
|
||||||
const online = serverIsOnline(instance.state);
|
const online = serverIsOnline(instance.state);
|
||||||
const canDelete = deleteDisabledReason === "";
|
const canDelete = deleteDisabledReason === "";
|
||||||
@@ -914,6 +798,7 @@ function ServerCard({ card, metricsPending, metricsUnavailable, canManage, delet
|
|||||||
<Sparkles size={14} />
|
<Sparkles size={14} />
|
||||||
<span>详情</span>
|
<span>详情</span>
|
||||||
</button>
|
</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}>
|
<button ref={menuButtonRef} type="button" className="icon-command" disabled={!canOpenActions} title={canOpenActions ? "运行操作" : "当前账号没有运行操作权限"} aria-haspopup="menu" aria-expanded={menuOpen} onClick={toggleMenu}>
|
||||||
<span>运行操作</span>
|
<span>运行操作</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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,.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 .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}
|
.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-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}
|
.management-dialog-panel .plugin-group::after,.management-dialog-panel .plugin-group::before{content:none}
|
||||||
.plugin-detail-actions{align-items:stretch}
|
.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}
|
@media (max-width:760px){.auth-shell{padding:14px;place-items:stretch}
|
||||||
.auth-card{align-self:center}
|
.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}
|
.console-row-actions,.maintenance-actions,.profile-settings-actions,.user-actions{justify-content:flex-start}
|
||||||
.app-shell{display:block;grid-template-columns:1fr}
|
.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}
|
.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}
|
||||||
|
|||||||
Reference in New Issue
Block a user