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 版本更新”面板;点“检查更新”查询公开分支版本,
只有检测到更新时“更新版本”按钮才会置为可用并高亮,点击后先确认再派发更新任务。
209 lines
9.0 KiB
TypeScript
209 lines
9.0 KiB
TypeScript
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;
|
||
}
|