Files
browser/platform_web/components/ServerGameUpdatePanel.tsx
T
npc0-hue 05f5a97ba9 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 版本更新”面板;点“检查更新”查询公开分支版本,
  只有检测到更新时“更新版本”按钮才会置为可用并高亮,点击后先确认再派发更新任务。
2026-09-15 13:30:15 +08:00

209 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}