Add graceful SCUM stop, restart, and version update flow

SCUM 停止/重启/更新以前只有“结束进程”这一条路,插件没有声明任何优雅关闭方式,
平台也没有把停止后重新启动串起来。现在插件声明自己的关闭脚本,run 先执行它,
平台在停止或更新成功后再自动拉起服务。

run:
- lifecycle stop 支持插件声明的 gracefulStop(可执行文件、参数、环境、超时、
  fallback=report|terminate);关闭命令超时且声明 report 时任务失败,不再默默杀进程。
- 新增 steam.update 依赖探针:调用 steamcmd +app_info_print 获取公开分支 buildid,
  与本地 steamapps/appmanifest_<appid>.acf 的 buildid 比较,输出
  installed/latest/update=yes|no|unknown。

platform:
- 新增 POST /api/v1/server-instances/{id}/restart 与 /update。
- restart 派发插件 stop 动作(走优雅关闭),终态成功后入队 start 作业。
- update 派发插件 install 动作;插件在更新前必须先优雅关闭 SCUM,关闭失败直接拒绝
  SteamCMD 更新,成功后平台再拉起服务。
- 依赖检查输入带上插件声明的服务器安装根目录,供 steam.update 读取 appmanifest。

plugin (SCUM server plugin 0.1.16):
- bin/scum-stop.cmd:解析已声明的可执行文件路径,定位同路径正在运行的 SCUMServer.exe,
  通过本地 RCON 公告并发送关闭命令,等待进程自行退出;不再使用 taskkill。
- bin/scum-rcon.ps1:插件自有的 Source RCON 客户端,从 UE4SS mod config.ini 读取
  密码/端口,密钥不离开本机。
- actions/stop.json 声明 gracefulStop;actions/install.json 更新前先执行同一关闭脚本。

platform_web:
- 服务器详情新增“重启”按钮和“SCUM 版本更新”面板;点“检查更新”查询公开分支版本,
  只有检测到更新时“更新版本”按钮才会置为可用并高亮,点击后先确认再派发更新任务。
This commit is contained in:
npc0-hue
2026-09-15 13:30:15 +08:00
parent 4ea27bda6a
commit 05f5a97ba9
39 changed files with 1197 additions and 56 deletions
+11 -1
View File
@@ -385,6 +385,14 @@ describe("PlatformApiClient AI providers", () => {
if (url.endsWith("/api/v1/server-instances/server-1/stop") && init?.method === "POST") {
return jsonResponse({ accepted: true, action: "stop", instance: server, job: { ...job, capability: "process.stop" } });
}
if (url.endsWith("/api/v1/server-instances/server-1/restart") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ expectedConfigVersion: 1, idempotencyKey: "idem-restart" });
return jsonResponse({ accepted: true, action: "restart", instance: server, job: { ...job, capability: "process.stop" } });
}
if (url.endsWith("/api/v1/server-instances/server-1/update") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ expectedConfigVersion: 1, idempotencyKey: "idem-update" });
return jsonResponse({ accepted: true, action: "update", instance: server, job: { ...job, capability: "process.install" } });
}
if (url.endsWith("/api/v1/server-instances/server-1/process/status") && init?.method === "POST") {
return jsonResponse({ accepted: true, action: "status", instance: server, job: { ...job, capability: "process.status", executionResult: { kind: "process", processState: "running", summary: "private supervised process identity" } } });
}
@@ -604,6 +612,8 @@ describe("PlatformApiClient AI providers", () => {
});
await expect(client.startServerInstance(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-start" })).resolves.toMatchObject({ action: "start" });
await expect(client.stopServerInstance(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-stop" })).resolves.toMatchObject({ action: "stop" });
await expect(client.restartServerInstance(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-restart" })).resolves.toMatchObject({ action: "restart", job: { capability: "process.stop" } });
await expect(client.updateServerGame(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-update" })).resolves.toMatchObject({ action: "update", job: { capability: "process.install" } });
await expect(client.queryServerProcessStatus(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-status" })).resolves.toMatchObject({ action: "status", job: { executionResult: { processState: "running" } } });
await expect(client.listServerAdministratorCandidates(server.id)).resolves.toMatchObject({ count: 1 });
await expect(client.addServerAdministrator(server.id, { userId: "user-2" })).resolves.toMatchObject({ adminUserIds: ["user-admin-1", "user-2"] });
@@ -637,7 +647,7 @@ describe("PlatformApiClient AI providers", () => {
client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" })
).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } });
expect(fetchMock).toHaveBeenCalledTimes(49);
expect(fetchMock).toHaveBeenCalledTimes(51);
});
it("normalizes server file workspace null arrays from older platform responses", async () => {
+14
View File
@@ -237,6 +237,20 @@ export class PlatformApiClient {
});
}
async restartServerInstance(id: string, request: ServerLifecycleCommandRequest): Promise<ServerLifecycleResponse> {
return this.request<ServerLifecycleResponse>(`/server-instances/${encodeURIComponent(id)}/restart`, {
method: "POST",
body: request
});
}
async updateServerGame(id: string, request: ServerLifecycleCommandRequest): Promise<ServerLifecycleResponse> {
return this.request<ServerLifecycleResponse>(`/server-instances/${encodeURIComponent(id)}/update`, {
method: "POST",
body: request
});
}
async queryServerProcessStatus(id: string, request: ServerLifecycleCommandRequest): Promise<ServerLifecycleResponse> {
return this.request<ServerLifecycleResponse>(`/server-instances/${encodeURIComponent(id)}/process/status`, {
method: "POST",
+1 -1
View File
@@ -24,7 +24,7 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia
- `createServerWorkflow` posts `ServerLifecycleCreateRequest` with the create-wizard deployment definition to `/server-instances/workflows/create`, including deployment mode, plugin create inputs, and custom startup fields when provided. It never includes a deployment target, Run endpoint, lifecycle profile, or Run identity binding. The generated Run package uses plugin defaults and Platform observes the active Run from its authenticated heartbeat.
- `getServerRuntimeBinding` reads `/server-instances/{id}/runtime-binding`; `updateServerRuntimeBinding` patches the selected profile and logical refs for internal/advanced logical transports. Server detail must not expose a manual runtime-binding tab or require these fields before normal start/stop when plugin-declared deployment/lifecycle data is sufficient. Responses contain only profile metadata, logical key names, configured/secret-backed flags, missing keys, and safe reasons. They never contain stored refs or secret values.
- `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response.
- `startServerInstance`, `stopServerInstance`, `restartServerInstance`, and `updateServerGame` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response. `restart` queues the plugin-declared stop action before the matching start action, and `update` queues the plugin-declared install/update action, which closes a running server gracefully before the game files change.
- `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators.
- Game-specific pages use the scoped `plugin-data` collection API and declared plugin bridge machine actions; Platform does not expose game-specific projection or workflow clients.
- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths; it remains the low-level compatibility dispatch for file work.
@@ -0,0 +1,208 @@
import { Download, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { DependencyProbeViewResponse, JobResponse, ServerInstanceResponse } from "../api/types";
import type { PageComponentProps } from "../contracts/page";
import {
canUpdateServerGame,
gameUpdateStatusFromProbe,
gameUpdateStatusLabel,
steamUpdateProbe,
type GameUpdateStatus
} from "../contracts/serverManagement";
import { dependencyJobRequest, serverLifecycleCommandRequest } from "../schemas/serverManagement";
import { ConfirmDialog } from "./OperationControls";
import { ResultBadge } from "./StateViews";
const dependencyCheckPollAttempts = 45;
const dependencyCheckPollMs = 2000;
export function gameUpdateCheckNote(status?: GameUpdateStatus): string {
if (!status) {
return "尚未检查过 SCUM 公开分支版本。";
}
if (status.availability === "available") {
const installed = status.installed ? `本地 ${status.installed}` : "本地版本未知";
const latest = status.latest ? `公开分支 ${status.latest}` : "公开分支版本未知";
return `${installed}${latest},可以执行更新。`;
}
if (status.availability === "up-to-date") {
return status.installed ? `本地版本 ${status.installed} 已与公开分支一致。` : "本地版本已与公开分支一致。";
}
return "SteamCMD 未返回可比较的版本号,请稍后重新检查或在服务器上确认 SteamCMD 可用。";
}
interface ServerGameUpdatePanelProps {
instance: ServerInstanceResponse;
session: PageComponentProps["session"];
operations: PageComponentProps["operations"];
canManage: boolean;
onChanged: () => void;
}
export function ServerGameUpdatePanel({ instance, session, operations, canManage, onChanged }: ServerGameUpdatePanelProps) {
const [probe, setProbe] = useState<DependencyProbeViewResponse | null>(null);
const [supported, setSupported] = useState(false);
const [busy, setBusy] = useState<"check" | "update" | null>(null);
const [note, setNote] = useState<string | null>(null);
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
const [confirmUpdate, setConfirmUpdate] = useState(false);
const status = useMemo(() => gameUpdateStatusFromProbe(probe ?? undefined), [probe]);
const updateAvailable = status?.availability === "available";
const updateAllowed = updateAvailable && canUpdateServerGame(instance.state);
const loadCatalog = useCallback(async () => {
try {
const catalog = await platformApiClient.getDependencyCatalog(instance.id);
const candidate = steamUpdateProbe(catalog) ?? null;
setProbe(candidate);
setSupported(candidate !== null);
} catch {
setProbe(null);
setSupported(false);
}
}, [instance.id]);
useEffect(() => {
void loadCatalog();
}, [loadCatalog]);
async function checkForUpdate() {
if (!probe) {
return;
}
const operationId = operations.begin({ intent: "检查游戏更新", targetKind: "server", targetId: instance.id, requester: session.displayName });
setBusy("check");
setNote("正在向 SteamCMD 查询 SCUM 公开分支版本…");
setResult({ status: "pending", label: "正在检查版本" });
try {
const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, probe.key));
const finalJob = await waitForDependencyCheckJob(job.id);
if (!finalJob) {
throw new Error("版本检查任务还没有返回结果,可稍后在运维控制台查看该任务。");
}
if (finalJob.state !== "succeeded") {
throw new Error(finalJob.progress.message || finalJob.executionResult?.summary || "版本检查失败");
}
const catalog = await platformApiClient.getDependencyCatalog(instance.id);
const candidate = steamUpdateProbe(catalog) ?? null;
setProbe(candidate);
const label = gameUpdateStatusLabel(gameUpdateStatusFromProbe(candidate ?? undefined));
const summary = gameUpdateCheckNote(gameUpdateStatusFromProbe(candidate ?? undefined));
setNote(summary);
setResult({ status: "succeeded", label });
operations.succeed(operationId, `游戏版本检查完成:${label}${summary}`, finalJob);
} catch (error) {
const reason = error instanceof Error ? error.message : "版本检查失败";
setNote(reason);
setResult({ status: "failed", label: reason });
operations.fail(operationId, reason, operationId);
} finally {
setBusy(null);
}
}
async function submitUpdate() {
const operationId = operations.begin({ intent: "更新游戏版本", targetKind: "server", targetId: instance.id, requester: session.displayName });
setBusy("update");
try {
const response = await platformApiClient.updateServerGame(instance.id, serverLifecycleCommandRequest(instance, "update"));
const message = `更新任务 ${response.job.id} 已派发:先按插件声明优雅关闭 SCUM 服务,再执行 SteamCMD 更新,成功后自动重新启动。`;
setNote(message);
setResult({ status: "pending", label: "更新任务已派发" });
operations.succeed(operationId, message, response.job);
onChanged();
} catch (error) {
const reason = error instanceof Error ? error.message : "更新派发失败";
setNote(reason);
setResult({ status: "failed", label: reason });
operations.fail(operationId, reason, operationId);
} finally {
setBusy(null);
}
}
if (!supported || !probe) {
return null;
}
return (
<article className="console-panel console-module server-game-update-panel" aria-label="game version update">
<div className="panel-header">
<h2>
<RefreshCw size={16} style={{ verticalAlign: "-2px" }} />
</h2>
{result && <ResultBadge status={result.status} label={result.label} />}
</div>
<p className="section-copy"> Steam SCUM SCUM </p>
<dl className="console-stat-strip">
<div>
<dt></dt>
<dd>{status?.installed ?? "未记录"}</dd>
</div>
<div>
<dt></dt>
<dd>{status?.latest ?? "未记录"}</dd>
</div>
<div>
<dt></dt>
<dd>{gameUpdateStatusLabel(status)}</dd>
</div>
</dl>
<p className="console-note">{note ?? gameUpdateCheckNote(status)}</p>
<div className="console-row-actions">
<button type="button" className="icon-command" disabled={!canManage || busy !== null} title={canManage ? "向 SteamCMD 查询最新 SCUM 版本号" : "当前账号没有管理权限"} onClick={() => void checkForUpdate()}>
<RefreshCw size={15} />
<span></span>
</button>
<button
type="button"
className={updateAvailable ? "primary-command" : "icon-command"}
disabled={!canManage || !updateAllowed || busy !== null}
title={updateTitle(canManage, updateAvailable, updateAllowed, instance.state)}
onClick={() => setConfirmUpdate(true)}
>
<Download size={15} />
<span></span>
</button>
</div>
<ConfirmDialog
open={confirmUpdate}
title="更新 SCUM 服务端"
description={`确认更新 ${instance.name}${instance.id})?平台会先让插件优雅关闭 SCUM 服务(RCON 通知玩家并等待进程退出),关闭失败则中止更新;SteamCMD 校验更新完成后会自动重新启动服务。`}
confirmLabel="关闭并更新"
busy={busy === "update"}
onCancel={() => setConfirmUpdate(false)}
onConfirm={() => {
setConfirmUpdate(false);
void submitUpdate();
}}
/>
</article>
);
}
function updateTitle(canManage: boolean, updateAvailable: boolean, updateAllowed: boolean, state: ServerInstanceResponse["state"]): string {
if (!canManage) {
return "当前账号没有管理权限";
}
if (!updateAvailable) {
return "先检查更新;检测到新版本后该按钮会点亮";
}
if (!updateAllowed) {
return `当前状态 ${state} 不能执行游戏更新`;
}
return "优雅关闭 SCUM 服务后执行 SteamCMD 更新,成功后自动启动";
}
async function waitForDependencyCheckJob(jobId: string): Promise<JobResponse | null> {
for (let attempt = 0; attempt < dependencyCheckPollAttempts; attempt += 1) {
const current = await platformApiClient.getJob(jobId);
if (current.state === "succeeded" || current.state === "failed" || current.state === "cancelled") {
return current;
}
await new Promise((resolve) => window.setTimeout(resolve, dependencyCheckPollMs));
}
return null;
}
+1 -1
View File
@@ -23,7 +23,7 @@ Default landing page for server owners and server administrators. Shows searchab
## 服务器详情
Daily operations hub for one server. Status header shows online state, player count, TPS, latency, CPU/memory/disk progress, metric freshness, and confirmed start/stop lifecycle actions. Plugin-declared pages render as first-class server tabs before platform sections, so each game owns its safe menu surface; SCUM user and vehicle pages read platform-maintained SCUM tables while plugin-owned squads, map settings, gifts, and workflows stay in scoped plugin records. Built-in sections are 管理 (deployment status, metadata, administrators) and AI 助手 (LLM suggestions produce reviewable config diffs or typed workflow drafts; no raw AI keys reach the frontend). Raw logs, management terminal/RCON input, arbitrary config workbench, generic operation history, runtime-binding, and generic plugin-control tabs must not be exposed in server detail.
Daily operations hub for one server. Status header shows online state, player count, TPS, latency, CPU/memory/disk progress, metric freshness, and confirmed start, stop, restart, and plugin-declared graceful-update lifecycle actions, plus a game-version module that checks the plugin-declared Steam build probe and only lights up the update action when a newer public build is reported. Plugin-declared pages render as first-class server tabs before platform sections, so each game owns its safe menu surface; SCUM user and vehicle pages read platform-maintained SCUM tables while plugin-owned squads, map settings, gifts, and workflows stay in scoped plugin records. Built-in sections are 管理 (deployment status, metadata, administrators) and AI 助手 (LLM suggestions produce reviewable config diffs or typed workflow drafts; no raw AI keys reach the frontend). Raw logs, management terminal/RCON input, arbitrary config workbench, generic operation history, runtime-binding, and generic plugin-control tabs must not be exposed in server detail.
## 插件市场
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { canStartServer, canStopServer, runtimeObservationFreshness } from "./serverManagement";
import type { RunEndpointResponse, ServerInstanceResponse, ServerInstanceState } from "../api/types";
import { canRestartServer, canStartServer, canStopServer, canUpdateServerGame, gameUpdateStatusFromProbe, gameUpdateStatusLabel, steamUpdateProbe, runtimeObservationFreshness } from "./serverManagement";
import type { DependencyCatalogResponse, DependencyProbeViewResponse, RunEndpointResponse, ServerInstanceResponse, ServerInstanceState } from "../api/types";
describe("server management lifecycle contracts", () => {
it("allows explicit starts from recoverable non-running states", () => {
@@ -17,6 +17,41 @@ describe("server management lifecycle contracts", () => {
expect(canStopServer("failed")).toBe(false);
});
it("keeps restart and update dispatch inside the states the platform accepts", () => {
const restartable: ServerInstanceState[] = ["running", "stopped"];
const updatable: ServerInstanceState[] = ["running", "stopped", "ready", "failed"];
expect(restartable.every(canRestartServer)).toBe(true);
expect((["draft", "installing", "ready", "failed", "deleted"] as ServerInstanceState[]).some(canRestartServer)).toBe(false);
expect(updatable.every(canUpdateServerGame)).toBe(true);
expect((["draft", "installing", "deleted"] as ServerInstanceState[]).some(canUpdateServerGame)).toBe(false);
});
it("reads the Steam build probe evidence into an update decision", () => {
const catalog = {
serverInstanceId: "server-1",
pluginId: "game.scum",
pluginVersion: "0.1.16",
profileKey: "run-local",
targetOs: "windows",
targetArch: "amd64",
updatedAt: "2026-09-15T00:00:00Z",
plans: [],
probes: [{ key: "scum-server-build", kind: "steam.update", required: false, state: "present", evidence: "installed=100 latest=200 update=yes" }]
} as DependencyCatalogResponse;
const probe = steamUpdateProbe(catalog);
expect(probe?.key).toBe("scum-server-build");
const status = gameUpdateStatusFromProbe(probe);
expect(status).toEqual({ availability: "available", installed: "100", latest: "200" });
expect(gameUpdateStatusLabel(status)).toBe("发现新版本");
const current = gameUpdateStatusFromProbe({ ...probe, evidence: "installed=200 latest=200 update=no" } as DependencyProbeViewResponse);
expect(current?.availability).toBe("up-to-date");
const unknown = gameUpdateStatusFromProbe({ ...probe, evidence: "installed=none latest=200 update=yes" } as DependencyProbeViewResponse);
expect(unknown).toEqual({ availability: "available", installed: "none", latest: "200" });
const missing = gameUpdateStatusFromProbe({ ...probe, kind: "java.version" } as DependencyProbeViewResponse);
expect(missing).toBeUndefined();
});
it("distinguishes a fresh Run observation from an unverified historical lifecycle state", () => {
const instance = { id: "server-1", state: "running", runEndpointId: "run-1" } as ServerInstanceResponse;
const endpoint = { id: "run-1", status: "online", lastHeartbeatAt: "2026-08-07T10:00:00Z" } as RunEndpointResponse;
@@ -1,4 +1,6 @@
import type {
DependencyCatalogResponse,
DependencyProbeViewResponse,
GamePluginResponse,
JobResponse,
RunEndpointResponse,
@@ -120,6 +122,61 @@ export function canStopServer(state: ServerInstanceState): boolean {
return state === "running";
}
export function canRestartServer(state: ServerInstanceState): boolean {
return state === "running" || state === "stopped";
}
export function canUpdateServerGame(state: ServerInstanceState): boolean {
return state === "running" || state === "stopped" || state === "ready" || state === "failed";
}
export type GameUpdateAvailability = "unknown" | "available" | "up-to-date";
export interface GameUpdateStatus {
availability: GameUpdateAvailability;
installed?: string;
latest?: string;
}
export function steamUpdateProbe(catalog?: DependencyCatalogResponse): DependencyProbeViewResponse | undefined {
return catalog?.probes.find((probe) => probe.kind === "steam.update");
}
export function gameUpdateStatusFromProbe(probe?: DependencyProbeViewResponse): GameUpdateStatus | undefined {
if (!probe || probe.kind !== "steam.update") {
return undefined;
}
const update = dependencyEvidenceValue(probe.evidence, "update");
return {
availability: update === "yes" ? "available" : update === "no" ? "up-to-date" : "unknown",
installed: dependencyEvidenceValue(probe.evidence, "installed"),
latest: dependencyEvidenceValue(probe.evidence, "latest")
};
}
export function gameUpdateStatusLabel(status?: GameUpdateStatus): string {
switch (status?.availability) {
case "available": return "发现新版本";
case "up-to-date": return "已是最新版本";
case "unknown": return "版本未知";
default: return "尚未检查";
}
}
function dependencyEvidenceValue(evidence: string | undefined, key: string): string | undefined {
if (!evidence) {
return undefined;
}
for (const token of evidence.split(/\s+/)) {
const separator = token.indexOf("=");
if (separator <= 0 || token.slice(0, separator) !== key) {
continue;
}
return token.slice(separator + 1) || undefined;
}
return undefined;
}
export function isPendingJobState(state: JobResponse["state"]): boolean {
return state === "queued" || state === "accepted" || state === "running" || state === "retrying";
}
+18 -2
View File
@@ -4,6 +4,7 @@ import { configDiffViewFromPreview } from "./ServerDetailPage";
import serversPageSource from "./ServersPage.tsx?raw";
import serverManagementTerminalSource from "../components/ServerManagementTerminalDrawer.tsx?raw";
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
import serverGameUpdatePanelSource from "../components/ServerGameUpdatePanel.tsx?raw";
import type { ServerConfigDiffPreviewResponse } from "../api/types";
const preview: ServerConfigDiffPreviewResponse = {
@@ -170,12 +171,27 @@ describe("ServerDetailPage config write approval", () => {
expect(serverDetailPageSource).not.toContain("插件控制");
expect(serverDetailPageSource).toContain("platformApiClient.startServerInstance(current.id");
expect(serverDetailPageSource).toContain("platformApiClient.stopServerInstance(current.id");
expect(serverDetailPageSource).toContain("serverLifecycleCommandRequest(current, \"start\")");
expect(serverDetailPageSource).toContain("serverLifecycleCommandRequest(current, \"stop\")");
expect(serverDetailPageSource).toContain("platformApiClient.restartServerInstance(current.id");
expect(serverDetailPageSource).toContain("serverLifecycleCommandRequest(current, action)");
expect(serverDetailPageSource).not.toContain('capability: "process.start"');
expect(serverDetailPageSource).not.toContain('capability: "process.stop"');
});
it("lights the SCUM update action only after the plugin-declared build check reports a new version", () => {
expect(serverDetailPageSource).toContain("ServerGameUpdatePanel");
expect(serverDetailPageSource).toContain("canRestartServer(instance.data.state)");
expect(serverGameUpdatePanelSource).toContain("platformApiClient.getDependencyCatalog");
expect(serverGameUpdatePanelSource).toContain("platformApiClient.checkDependencies");
expect(serverGameUpdatePanelSource).toContain("platformApiClient.updateServerGame");
expect(serverGameUpdatePanelSource).toContain("steamUpdateProbe");
expect(serverGameUpdatePanelSource).toContain("gameUpdateStatusFromProbe");
expect(serverGameUpdatePanelSource).toContain('serverLifecycleCommandRequest(instance, "update")');
expect(serverGameUpdatePanelSource).toContain("优雅关机");
expect(serverGameUpdatePanelSource).not.toContain("taskkill");
expect(serverGameUpdatePanelSource).not.toContain('capability: "process.stop"');
expect(serverGameUpdatePanelSource).not.toContain('capability: "process.install"');
});
it("leaves guided deployment to the generated Run heartbeat workflow", () => {
expect(serverDetailPageSource).not.toContain("canDeployServer(instance.data.state)");
expect(serverDetailPageSource).not.toContain("requestDeployment(instance.data)");
+33 -8
View File
@@ -1,4 +1,4 @@
import { ChevronRight, Download, Eye, FileText, Folder, MoonStar, PackageOpen, Pencil, RefreshCw, Save, Search, Settings2, ShieldCheck, Sparkles, Square, Terminal, Upload, UserRoundMinus, UserRoundPlus, WandSparkles, X } from "lucide-react";
import { ChevronRight, Download, Eye, FileText, Folder, MoonStar, PackageOpen, Pencil, RefreshCw, RotateCcw, Save, Search, Settings2, ShieldCheck, Sparkles, Square, Terminal, Upload, UserRoundMinus, UserRoundPlus, WandSparkles, X } from "lucide-react";
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { platformApiClient } from "../api/client";
@@ -19,9 +19,10 @@ import type {
import { ConfirmDialog } from "../components/OperationControls";
import { ServerManagementTerminalDrawer } from "../components/ServerManagementTerminalDrawer";
import { ServerConfigEditor } from "../components/ServerConfigEditor";
import { ServerGameUpdatePanel } from "../components/ServerGameUpdatePanel";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import { canStartServer, canStopServer, runtimeObservationFreshness, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
import { canRestartServer, canStartServer, canStopServer, runtimeObservationFreshness, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
import {
serverDetailSections,
type ConfigDiffView,
@@ -129,26 +130,32 @@ export function ServerDetailPage(props: PageComponentProps) {
setSection(`plugin:${defaultPluginPage.key}`);
}, [defaultPluginPage, params.routeKey, section]);
function requestLifecycle(current: ServerInstanceResponse, action: "start" | "stop") {
function requestLifecycle(current: ServerInstanceResponse, action: "start" | "stop" | "restart") {
const intent = action === "start" ? "启动服务器" : action === "stop" ? "停止服务器" : "重启服务器";
setConfirm({
title: action === "start" ? "启动服务器" : "停止服务器",
title: action === "start" ? "启动服务器" : action === "stop" ? "停止服务器" : "重启服务器",
description:
action === "start"
? `确认启动服务器 ${current.name}${current.id})?`
: `停止服务器 ${current.name}${current.id})会断开所有在线玩家,确认继续?`,
: action === "stop"
? `停止服务器 ${current.name}${current.id})会断开所有在线玩家,确认继续?`
: `重启服务器 ${current.name}${current.id})会先按插件声明优雅关闭 SCUM 服务,停止成功后自动重新启动;在线玩家会被断开。`,
danger: action === "stop",
run: async () => {
const operationId = operations.begin({
intent: action === "start" ? "启动服务器" : "停止服务器",
intent,
targetKind: "server",
targetId: current.id,
requester: session.displayName
});
try {
const request = serverLifecycleCommandRequest(current, action);
const result =
action === "start"
? await platformApiClient.startServerInstance(current.id, serverLifecycleCommandRequest(current, "start"))
: await platformApiClient.stopServerInstance(current.id, serverLifecycleCommandRequest(current, "stop"));
? await platformApiClient.startServerInstance(current.id, request)
: action === "stop"
? await platformApiClient.stopServerInstance(current.id, request)
: await platformApiClient.restartServerInstance(current.id, request);
operations.succeed(operationId, `任务 ${result.job.id}${result.job.capability})已派发`, result.job);
await refresh();
} catch (error) {
@@ -231,6 +238,15 @@ export function ServerDetailPage(props: PageComponentProps) {
<Square size={15} />
<span></span>
</button>
<button
type="button"
className="icon-command"
disabled={!canManageServers || !canRestartServer(instance.data.state) || operations.isPending(instance.data.id, "重启服务器")}
onClick={() => requestLifecycle(instance.data, "restart")}
>
<RotateCcw size={15} />
<span></span>
</button>
<span className={cx("status-pill", detailFreshness === "fresh" ? statusClass(instance.data.state) : "status-disabled")}>{detailStateText}</span>
</div>
</div>
@@ -249,6 +265,15 @@ export function ServerDetailPage(props: PageComponentProps) {
/>
)}
{section === "manage" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
{section === "manage" && (
<ServerGameUpdatePanel
instance={instance.data}
session={session}
operations={operations}
canManage={canManageServers}
onChanged={() => void refreshOperationalState()}
/>
)}
{section === "files" && <ServerFilesSection instance={instance.data} session={session} operations={operations} />}
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
<ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} onClose={() => setTerminalOpen(false)} />
+2 -2
View File
@@ -55,7 +55,7 @@ export function serverInstanceIdFromName(name: string, sequence = Date.now()): s
return normalized ? `server-${normalized}-${suffix}` : `server-${suffix}`;
}
export function serverLifecycleCommandRequest(instance: ServerInstanceResponse, action: "deploy" | "start" | "stop" | "status", sequence = Date.now()): ServerLifecycleCommandRequest {
export function serverLifecycleCommandRequest(instance: ServerInstanceResponse, action: "deploy" | "start" | "stop" | "restart" | "update" | "status", sequence = Date.now()): ServerLifecycleCommandRequest {
return {
expectedConfigVersion: instance.configVersion,
idempotencyKey: lifecycleIdempotencyKey(action, instance.id, sequence)
@@ -113,6 +113,6 @@ export function runtimeIdempotencyKey(action: string, serverInstanceId: string,
return `web:${action}:${serverInstanceId}:${sequence}`;
}
export function lifecycleIdempotencyKey(action: "create" | "deploy" | "start" | "stop" | "status", serverInstanceId: string, sequence: number): string {
export function lifecycleIdempotencyKey(action: "create" | "deploy" | "start" | "stop" | "restart" | "update" | "status", serverInstanceId: string, sequence: number): string {
return `web:${action}:${serverInstanceId}:${sequence}`;
}