fix: expose server deployment trigger
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-28
|
||||
@@ -0,0 +1,39 @@
|
||||
## Context
|
||||
|
||||
The Platform already validates and dispatches `POST /api/v1/server-instances/{id}/deploy`; it accepts a draft or failed server only after its dedicated Run endpoint is registered. The server detail page exposes start, stop, and edit actions, but never calls that deployment API. As a result, a newly registered Run remains correctly idle and a failed server cannot be retried through the console.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Make the existing deployment transition available to an authorized operator from the server detail header.
|
||||
- Use the current server config version and a unique idempotency key, then display ordinary operation feedback and refresh state.
|
||||
- Make the action unavailable while installation is already active or the state is not deployable.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Do not change the backend API, deployment plan, Run protocol, SCUM install sequence, or automatic-start behavior.
|
||||
- Do not expose protected directories, commands, tokens, or remote connectivity details.
|
||||
- Do not change the separate build target / dedicated Run identity boundary.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Add the control beside existing lifecycle controls rather than auto-dispatching when a Run heartbeats. Registration proves connectivity only; automatic installation would make starting a downloaded executable perform a material remote write without a final operator action.
|
||||
- Reuse `platformApiClient.deployServerInstance` rather than duplicating request logic. This preserves backend authorization, version fencing, idempotency, and job projection behavior.
|
||||
- Reuse the existing operation store and `refresh()` callback so the detail page follows the same feedback pattern as start/stop and immediately shows the job state.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [A stale page submits an outdated version] → The backend's `expectedConfigVersion` check rejects it; the UI refreshes after the error.
|
||||
- [Users mistake registration for completed installation] → The action label explicitly distinguishes Deploy from Start, and the queued job result is shown through the standard task feedback.
|
||||
- [A duplicate click creates duplicate work] → A fresh idempotency key is submitted and the backend enforces its lifecycle idempotency rules.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Deploy the frontend change with the existing Platform API.
|
||||
2. Open a failed or draft server whose dedicated Run is online and select Deploy / Retry deploy.
|
||||
3. Confirm the resulting install job is assigned to the dedicated endpoint. Rollback only removes the UI control; no data migration is required.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,24 @@
|
||||
## Why
|
||||
|
||||
The dedicated SCUM Run can register successfully, but a failed or draft server has no visible Platform action that submits its existing deployment definition. Operators therefore see an idle Run startup log and cannot advance the installation workflow.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add an explicit Deploy / Retry deploy control to the server detail actions for draft and failed servers.
|
||||
- Submit the existing protected deployment definition through the existing authenticated deployment API, using the current config version and a fresh idempotency key.
|
||||
- Present the queued deployment result in the normal operation feedback and refresh the server/job view.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `server-deployment-trigger`: Operator-facing dispatch of an already-configured draft or failed server deployment after its dedicated Run registers.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- None.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affects `platform_web/pages/ServerDetailPage.tsx` and its focused UI tests.
|
||||
- Reuses the existing `POST /api/v1/server-instances/{id}/deploy` contract; no new server API, remote access, credentials, or host data are introduced.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Deployable server details expose an explicit deployment trigger
|
||||
The management console SHALL expose an authorized Deploy action for a server in `draft` or `failed` state when a deployment definition is present. The control MUST remain unavailable for non-deployable states and while an installation action is pending.
|
||||
|
||||
#### Scenario: Retry a failed server after its dedicated Run registers
|
||||
- **WHEN** an operator opens a failed SCUM server whose dedicated Run endpoint is online
|
||||
- **THEN** the detail actions expose a Retry deploy control
|
||||
- **AND** the control is distinct from Start and does not expose protected deployment inputs
|
||||
|
||||
#### Scenario: Active installation does not offer a duplicate trigger
|
||||
- **WHEN** a server is in `installing` state or its deployment operation is pending
|
||||
- **THEN** the console does not allow another deployment submission
|
||||
|
||||
### Requirement: Deployment trigger uses the existing fenced lifecycle dispatch
|
||||
The management console SHALL submit deployment through the existing server deployment API with the instance's current config version and an idempotency key, then refresh the server detail after acceptance or failure.
|
||||
|
||||
#### Scenario: Deployment is accepted
|
||||
- **WHEN** the operator activates Deploy for an eligible server
|
||||
- **THEN** the console submits the current expected config version and a unique idempotency key to the existing deployment API
|
||||
- **AND** it reports the queued job through the standard operation feedback and refreshes the detail state
|
||||
@@ -0,0 +1,9 @@
|
||||
## 1. Server detail deployment action
|
||||
|
||||
- [x] 1.1 Add an explicit Deploy / Retry deploy handler that invokes the existing fenced API with current config version and operation feedback.
|
||||
- [x] 1.2 Render the action only for deployable draft or failed instances and prevent duplicate submission while pending.
|
||||
|
||||
## 2. Verification
|
||||
|
||||
- [x] 2.1 Extend focused server-detail tests to cover the deploy trigger and its state guards.
|
||||
- [x] 2.2 Run frontend tests, the structure check, and strict OpenSpec validation.
|
||||
@@ -115,6 +115,10 @@ export function canStopServer(state: ServerInstanceState): boolean {
|
||||
return state === "running";
|
||||
}
|
||||
|
||||
export function canDeployServer(state: ServerInstanceState): boolean {
|
||||
return state === "draft" || state === "failed";
|
||||
}
|
||||
|
||||
export function isPendingJobState(state: JobResponse["state"]): boolean {
|
||||
return state === "queued" || state === "accepted" || state === "running" || state === "retrying";
|
||||
}
|
||||
|
||||
@@ -162,6 +162,16 @@ describe("ServerDetailPage config write approval", () => {
|
||||
expect(serverDetailPageSource).not.toContain('capability: "process.stop"');
|
||||
});
|
||||
|
||||
it("submits explicit deploy and retry-deploy actions only through the fenced deployment API", () => {
|
||||
expect(serverDetailPageSource).toContain("canDeployServer(instance.data.state)");
|
||||
expect(serverDetailPageSource).toContain("requestDeployment(instance.data)");
|
||||
expect(serverDetailPageSource).toContain("platformApiClient.deployServerInstance(current.id");
|
||||
expect(serverDetailPageSource).toContain('serverLifecycleCommandRequest(current, "deploy")');
|
||||
expect(serverDetailPageSource).toContain('instance.data.state === "failed" ? "重试部署" : "部署"');
|
||||
expect(serverDetailPageSource).toContain('operations.isPending(instance.data.id, instance.data.state === "failed" ? "重试部署服务器" : "部署服务器")');
|
||||
expect(serverDetailPageSource).not.toContain('capability: "process.install"');
|
||||
});
|
||||
|
||||
it("keeps plugin lifecycle and bridge-visible output on platform-owned logical references", () => {
|
||||
expect(serverDetailPageSource).toContain("parsePluginArtifactReference(result)");
|
||||
expect(serverDetailPageSource).toContain("platformApiClient.openArtifactDownload(artifact.id)");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, Pencil, Rocket, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
|
||||
import { type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
@@ -50,7 +50,7 @@ import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge }
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import { jobCapabilityLabel } from "../contracts/jobPresentation";
|
||||
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
|
||||
import { canStartServer, canStopServer, defaultServerCreateForm, endpointLabel, pluginCreateInputDefaults, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerCreateFormState, type ServerMetadataFormState } from "../contracts/serverManagement";
|
||||
import { canDeployServer, canStartServer, canStopServer, defaultServerCreateForm, endpointLabel, pluginCreateInputDefaults, pluginLabel, runtimeBindingFields, serverMetadataFormFromInstance, type ServerCreateFormState, type ServerMetadataFormState } from "../contracts/serverManagement";
|
||||
import {
|
||||
serverDetailSections,
|
||||
serverIsOnline,
|
||||
@@ -212,6 +212,26 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
});
|
||||
}
|
||||
|
||||
function requestDeployment(current: ServerInstanceResponse) {
|
||||
const retry = current.state === "failed";
|
||||
const intent = retry ? "重试部署服务器" : "部署服务器";
|
||||
setConfirm({
|
||||
title: intent,
|
||||
description: `${retry ? "重新" : ""}部署 ${current.name}(${current.id})将向已注册的专属 Run 提交安装任务,确认继续?`,
|
||||
run: async () => {
|
||||
const operationId = operations.begin({ intent, targetKind: "server", targetId: current.id, requester: session.displayName });
|
||||
try {
|
||||
const result = await platformApiClient.deployServerInstance(current.id, serverLifecycleCommandRequest(current, "deploy"));
|
||||
operations.succeed(operationId, `部署任务 ${result.job.id}(${result.job.capability})已派发`, result.job);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
operations.fail(operationId, error instanceof Error ? error.message : "部署任务提交失败", operationId);
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function saveDeploymentWorkflow(form: ServerCreateFormState) {
|
||||
if (instance.status !== "ready") return;
|
||||
const current = instance.data;
|
||||
@@ -276,6 +296,16 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
|
||||
</div>
|
||||
<div className="action-strip">
|
||||
<span className={cx("status-pill", statusClass(instance.data.state))}>{stateLabel(instance.data.state)}</span>
|
||||
{canDeployServer(instance.data.state) && <button
|
||||
type="button"
|
||||
className="primary-command"
|
||||
disabled={deployment.status !== "ready" || !deployment.data.mode || operations.isPending(instance.data.id, instance.data.state === "failed" ? "重试部署服务器" : "部署服务器")}
|
||||
title={deployment.status !== "ready" || !deployment.data.mode ? "请先配置部署定义" : undefined}
|
||||
onClick={() => requestDeployment(instance.data)}
|
||||
>
|
||||
<Rocket size={15} />
|
||||
<span>{instance.data.state === "failed" ? "重试部署" : "部署"}</span>
|
||||
</button>}
|
||||
<button
|
||||
type="button"
|
||||
className="icon-command"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { GamePluginResponse } from "../api/types";
|
||||
import { defaultServerCreateForm, runtimeBindingFields } from "../contracts/serverManagement";
|
||||
import { canDeployServer, defaultServerCreateForm, runtimeBindingFields } from "../contracts/serverManagement";
|
||||
import { serverCreateRequestFromForm, serverInstanceIdFromName } from "./serverManagement";
|
||||
|
||||
const plugin: GamePluginResponse = {
|
||||
@@ -42,6 +42,13 @@ const plugin: GamePluginResponse = {
|
||||
};
|
||||
|
||||
describe("runtime profile server creation contracts", () => {
|
||||
it("allows deployment only from draft or failed server states", () => {
|
||||
expect(canDeployServer("draft")).toBe(true);
|
||||
expect(canDeployServer("failed")).toBe(true);
|
||||
expect(canDeployServer("installing")).toBe(false);
|
||||
expect(canDeployServer("ready")).toBe(false);
|
||||
});
|
||||
|
||||
it("derives logical binding fields from the selected profile", () => {
|
||||
expect(runtimeBindingFields(plugin, "local")).toEqual([
|
||||
{ key: "java-runtime", required: false, sensitive: false },
|
||||
|
||||
@@ -52,7 +52,7 @@ export function serverInstanceIdFromName(name: string, sequence = Date.now()): s
|
||||
return normalized ? `server-${normalized}-${suffix}` : `server-${suffix}`;
|
||||
}
|
||||
|
||||
export function serverLifecycleCommandRequest(instance: ServerInstanceResponse, action: "start" | "stop" | "status", sequence = Date.now()): ServerLifecycleCommandRequest {
|
||||
export function serverLifecycleCommandRequest(instance: ServerInstanceResponse, action: "deploy" | "start" | "stop" | "status", sequence = Date.now()): ServerLifecycleCommandRequest {
|
||||
return {
|
||||
expectedConfigVersion: instance.configVersion,
|
||||
expectedChecksum: instance.configChecksum,
|
||||
@@ -130,6 +130,6 @@ export function runtimeIdempotencyKey(action: string, serverInstanceId: string,
|
||||
return `web:${action}:${serverInstanceId}:${sequence}`;
|
||||
}
|
||||
|
||||
export function lifecycleIdempotencyKey(action: "create" | "start" | "stop" | "status", serverInstanceId: string, sequence: number): string {
|
||||
export function lifecycleIdempotencyKey(action: "create" | "deploy" | "start" | "stop" | "status", serverInstanceId: string, sequence: number): string {
|
||||
return `web:${action}:${serverInstanceId}:${sequence}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user