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
@@ -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;
}