Allow active deployment edits

This commit is contained in:
npc0-hue
2026-08-03 15:50:05 +08:00
parent cf4d0d0ba5
commit 5d4fca14f9
7 changed files with 65 additions and 28 deletions
+2 -2
View File
@@ -35,8 +35,8 @@ func (svc *CoreService) UpdateServerDeploymentForSession(sessionID, serverInstan
if err != nil { if err != nil {
return domain.ServerDeploymentView{}, err return domain.ServerDeploymentView{}, err
} }
if instance.State == domain.ServerInstanceStateInstalling || instance.State == domain.ServerInstanceStateRunning || instance.State == domain.ServerInstanceStateDeleted { if instance.State == domain.ServerInstanceStateDeleted {
return domain.ServerDeploymentView{}, validationError("deployment definition cannot be changed while the server is active") return domain.ServerDeploymentView{}, validationError("deleted server deployment definition cannot be changed")
} }
seenClearFields := map[string]bool{} seenClearFields := map[string]bool{}
for _, field := range update.ClearFields { for _, field := range update.ClearFields {
@@ -65,6 +65,40 @@ func TestCoreServiceSavesDraftDeploymentRedactsReadsAndDispatchesOnlyToCompatibl
} }
} }
func TestCoreServiceUpdatesDeploymentWhileServerIsActive(t *testing.T) {
svc, _ := newLifecycleRunService(t)
createLifecyclePlugin(t, svc)
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "deployment-active-owner", DisplayName: "Deployment Active Owner", Email: "deployment-active-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
for _, state := range []domain.ServerInstanceState{domain.ServerInstanceStateInstalling, domain.ServerInstanceStateRunning} {
stateLabel := string(state)
created, err := svc.CreateServerInstanceWorkflowForSession(ownerSession, domain.ServerLifecycleCreate{
ID: "active-deployment-edit-" + stateLabel, PluginID: "server.scum", Name: "Active Deployment Edit " + stateLabel, IdempotencyKey: "active-deployment-edit-" + stateLabel,
Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeCustom, ServerRoot: "/srv/active-server-" + stateLabel, WorkingDirectory: "/srv/active-server-" + stateLabel, StartCommand: "./start"},
})
if err != nil {
t.Fatalf("create %s deployment fixture: %v", state, err)
}
instance, err := svc.store.ServerInstances().Get(created.Instance.ID)
if err != nil {
t.Fatalf("get %s deployment fixture: %v", state, err)
}
instance.RunEndpointID = "run-local"
instance.State = state
if err := svc.store.ServerInstances().Update(instance); err != nil {
t.Fatalf("mark fixture %s: %v", state, err)
}
view, err := svc.UpdateServerDeploymentForSession(ownerSession, instance.ID, domain.ServerDeploymentUpdate{Mode: domain.ServerDeploymentModeCustom, ServerRoot: "/srv/active-server-next-" + stateLabel, StartCommand: "./start-next"})
if err != nil {
t.Fatalf("update %s deployment: %v", state, err)
}
if view.Revision != instance.Deployment.Revision+1 || !view.ServerRootConfigured || !view.StartCommandConfigured {
t.Fatalf("expected %s deployment revision and protected field flags to update, view=%+v", state, view)
}
}
}
func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedInstallAction(t *testing.T) { func TestCoreServiceSCUMGuidedDeployDispatchesPluginOwnedInstallAction(t *testing.T) {
svc := newTestCoreService() svc := newTestCoreService()
runHello := validRunControlHello() runHello := validRunControlHello()
@@ -465,7 +465,7 @@ async function clickAndVerify(chrome, buttonText, markers) {
} }
async function verifyServerQuickRuntimeMenu(chrome, label) { async function verifyServerQuickRuntimeMenu(chrome, label) {
const markers = ["生成 run", "下载 run", "更新 run", "生成客户端", "依赖检查", "依赖安装", "实时日志", "历史日志"]; const markers = ["生成 run", "下载 run", "更新 run", "生成客户端", "依赖检查", "依赖安装", "历史日志"];
await chrome.evaluate(() => { await chrome.evaluate(() => {
const trigger = Array.from(document.querySelectorAll("button")).find((item) => item.textContent?.includes("运行操作")); const trigger = Array.from(document.querySelectorAll("button")).find((item) => item.textContent?.includes("运行操作"));
if (!(trigger instanceof HTMLButtonElement)) { if (!(trigger instanceof HTMLButtonElement)) {
+12
View File
@@ -173,6 +173,7 @@ describe("first-party console pages", () => {
expect(serversPageSource).toContain("<ServerDeploymentWorkflow"); expect(serversPageSource).toContain("<ServerDeploymentWorkflow");
expect(serversPageSource).toContain("openEditDeployment"); expect(serversPageSource).toContain("openEditDeployment");
expect(serversPageSource).toContain("编辑部署"); expect(serversPageSource).toContain("编辑部署");
expect(serversPageSource).not.toContain('instance.state === "running" || instance.state === "installing"');
expect(serverDetailPageSource).not.toContain("<ServerDeploymentWorkflow"); expect(serverDetailPageSource).not.toContain("<ServerDeploymentWorkflow");
expect(serverDetailPageSource).toContain("ServerDeploymentSection"); expect(serverDetailPageSource).toContain("ServerDeploymentSection");
expect(serverDeploymentWorkflowSource).toContain("基本信息"); expect(serverDeploymentWorkflowSource).toContain("基本信息");
@@ -209,6 +210,17 @@ describe("first-party console pages", () => {
} }
}); });
it("keeps live logs and management terminal on server detail instead of the server list", () => {
expect(serversPageSource).not.toContain("ServerLiveLogDrawer");
expect(serversPageSource).not.toContain("ServerManagementTerminalDrawer");
expect(serversPageSource).not.toContain("live-logs");
expect(serversPageSource).not.toContain("管理终端");
expect(serverDetailPageSource).toContain("ServerLiveLogDrawer");
expect(serverDetailPageSource).toContain("ServerManagementTerminalDrawer");
expect(serverDetailPageSource).toContain("实时日志");
expect(serverDetailPageSource).toContain("管理终端");
});
it("keeps create target and profile controls out while preserving deployment steps", () => { it("keeps create target and profile controls out while preserving deployment steps", () => {
expect(serverDeploymentWorkflowSource).not.toContain('name="deploymentTargetId"'); expect(serverDeploymentWorkflowSource).not.toContain('name="deploymentTargetId"');
expect(serverDeploymentWorkflowSource).not.toContain("请选择部署目标"); expect(serverDeploymentWorkflowSource).not.toContain("请选择部署目标");
+4 -23
View File
@@ -1,4 +1,4 @@
import { AlertTriangle, CakeSlice, Candy, ScrollText, Search, Sparkles, Terminal, Trash2 } from "lucide-react"; import { AlertTriangle, CakeSlice, Candy, Search, Sparkles, Trash2 } from "lucide-react";
import { type CSSProperties, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type CSSProperties, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
@@ -16,7 +16,6 @@ import {
runtimeUpdateStages, runtimeUpdateStages,
useRuntimeTaskController useRuntimeTaskController
} from "../components/RuntimeTaskProgress"; } from "../components/RuntimeTaskProgress";
import { ServerLiveLogDrawer, ServerManagementTerminalDrawer } from "../components/ServerLiveOperations";
import { ConfirmDialog, ManagementDialog, UsageMeter } from "../components/OperationControls"; import { ConfirmDialog, ManagementDialog, UsageMeter } from "../components/OperationControls";
import { ServerDeploymentWorkflow } from "../components/ServerDeploymentWorkflow"; import { ServerDeploymentWorkflow } from "../components/ServerDeploymentWorkflow";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews"; import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
@@ -82,8 +81,6 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const [deletePassword, setDeletePassword] = useState(""); const [deletePassword, setDeletePassword] = useState("");
const [deleteBusy, setDeleteBusy] = useState(false); const [deleteBusy, setDeleteBusy] = useState(false);
const [runTargetSelection, setRunTargetSelection] = useState<RunTargetSelectionState | null>(null); const [runTargetSelection, setRunTargetSelection] = useState<RunTargetSelectionState | null>(null);
const [liveLogTarget, setLiveLogTarget] = useState<ServerInstanceResponse | null>(null);
const [terminalTarget, setTerminalTarget] = useState<ServerInstanceResponse | null>(null);
const refreshList = useCallback(async (showLoading = true) => { const refreshList = useCallback(async (showLoading = true) => {
if (showLoading) setListState("loading"); if (showLoading) setListState("loading");
@@ -184,7 +181,6 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
} }
async function openEditDeployment(instance: ServerInstanceResponse) { async function openEditDeployment(instance: ServerInstanceResponse) {
if (instance.state === "running" || instance.state === "installing") return;
try { try {
const deployment = await platformApiClient.getServerDeployment(instance.id); const deployment = await platformApiClient.getServerDeployment(instance.id);
const plugin = plugins.find((item) => item.id === instance.pluginId); const plugin = plugins.find((item) => item.id === instance.pluginId);
@@ -363,10 +359,6 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probe.key, plan.key, plan.digest)); const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probe.key, plan.key, plan.digest));
return `依赖安装任务已排队,job ${job.id}`; return `依赖安装任务已排队,job ${job.id}`;
} }
if (action === "live-logs") {
onNavigate("serverDetail", { serverId: instance.id });
return "已打开服务器详情,可切换到日志页查看实时日志";
}
const job = await platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, defaults.logSourceKey)); const job = await platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, defaults.logSourceKey));
return `历史日志回填任务已排队,job ${job.id}`; return `历史日志回填任务已排队,job ${job.id}`;
} }
@@ -612,8 +604,6 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
deleteDisabledReason={serverDeleteDisabledReason(session, card.instance)} deleteDisabledReason={serverDeleteDisabledReason(session, card.instance)}
onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })} onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })}
onEdit={() => void openEditDeployment(card.instance)} onEdit={() => void openEditDeployment(card.instance)}
onOpenLogs={() => setLiveLogTarget(card.instance)}
onOpenTerminal={() => setTerminalTarget(card.instance)}
onQuickAction={(action) => void handleQuickRuntimeAction(card.instance, action)} onQuickAction={(action) => void handleQuickRuntimeAction(card.instance, action)}
onDelete={() => { onDelete={() => {
setDeletePassword(""); setDeletePassword("");
@@ -648,8 +638,6 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
</label> </label>
</ConfirmDialog> </ConfirmDialog>
<RuntimeTaskProgressDialog task={runtimeTask.task} onClose={runtimeTask.closeTask} actions={runtimeTaskActions} /> <RuntimeTaskProgressDialog task={runtimeTask.task} onClose={runtimeTask.closeTask} actions={runtimeTaskActions} />
<ServerLiveLogDrawer open={liveLogTarget !== null} serverId={liveLogTarget?.id ?? ""} serverName={liveLogTarget?.name ?? ""} onClose={() => setLiveLogTarget(null)} />
<ServerManagementTerminalDrawer open={terminalTarget !== null} serverId={terminalTarget?.id ?? ""} serverName={terminalTarget?.name ?? ""} pluginId={terminalTarget?.pluginId ?? ""} canManage={canManageServers} onClose={() => setTerminalTarget(null)} />
</section> </section>
); );
} }
@@ -662,7 +650,6 @@ type ServerQuickRuntimeAction =
| "generate-client-manager" | "generate-client-manager"
| "dependencies-check" | "dependencies-check"
| "dependencies-install" | "dependencies-install"
| "live-logs"
| "historical-logs"; | "historical-logs";
async function requireQuickRuntimeActionAvailable(serverInstanceId: string, action: ServerQuickRuntimeAction): Promise<void> { async function requireQuickRuntimeActionAvailable(serverInstanceId: string, action: ServerQuickRuntimeAction): Promise<void> {
@@ -694,13 +681,11 @@ interface ServerCardProps {
deleteDisabledReason: string; deleteDisabledReason: string;
onOpen: () => void; onOpen: () => void;
onEdit: () => void; onEdit: () => void;
onOpenLogs: () => void;
onOpenTerminal: () => void;
onQuickAction: (action: ServerQuickRuntimeAction) => void; onQuickAction: (action: ServerQuickRuntimeAction) => void;
onDelete: () => void; onDelete: () => void;
} }
function ServerCard({ card, metricsPending, metricsUnavailable, canManage, deleteDisabledReason, onOpen, onEdit, onOpenLogs, onOpenTerminal, onQuickAction, onDelete }: ServerCardProps) { function ServerCard({ card, metricsPending, metricsUnavailable, canManage, deleteDisabledReason, onOpen, onEdit, onQuickAction, onDelete }: ServerCardProps) {
const { instance, metrics, pendingJobs, failedJobs = 0 } = card; const { instance, metrics, pendingJobs, failedJobs = 0 } = card;
const online = serverIsOnline(instance.state); const online = serverIsOnline(instance.state);
const canDelete = deleteDisabledReason === ""; const canDelete = deleteDisabledReason === "";
@@ -825,9 +810,7 @@ function ServerCard({ card, metricsPending, metricsUnavailable, canManage, delet
<Sparkles size={14} /> <Sparkles size={14} />
<span></span> <span></span>
</button> </button>
<button type="button" className="icon-command" disabled={!canManage || instance.state === "running" || instance.state === "installing"} title={instance.state === "running" || instance.state === "installing" ? "请先停止服务器再编辑部署" : "编辑部署"} onClick={onEdit}><span></span></button> <button type="button" className="icon-command" disabled={!canManage} title={canManage ? "编辑部署" : "当前账号没有运行操作权限"} onClick={onEdit}><span></span></button>
<button type="button" className="icon-command" onClick={onOpenLogs}><ScrollText size={14} /><span></span></button>
<button type="button" className="icon-command" disabled={!canManage} title={canManage ? "管理终端" : "当前账号没有运行操作权限"} onClick={onOpenTerminal}><Terminal size={14} /><span></span></button>
<button ref={menuButtonRef} type="button" className="icon-command" disabled={!canOpenActions} title={canOpenActions ? "运行操作" : "当前账号没有运行操作权限"} aria-haspopup="menu" aria-expanded={menuOpen} onClick={toggleMenu}> <button ref={menuButtonRef} type="button" className="icon-command" disabled={!canOpenActions} title={canOpenActions ? "运行操作" : "当前账号没有运行操作权限"} aria-haspopup="menu" aria-expanded={menuOpen} onClick={toggleMenu}>
<span></span> <span></span>
</button> </button>
@@ -892,7 +875,7 @@ const serverQuickActionGroups: Array<{ label: string; actions: ServerQuickRuntim
}, },
{ {
label: "诊断维护", label: "诊断维护",
actions: ["dependencies-check", "dependencies-install", "live-logs", "historical-logs"] actions: ["dependencies-check", "dependencies-install", "historical-logs"]
} }
]; ];
@@ -912,8 +895,6 @@ function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string {
return "依赖检查"; return "依赖检查";
case "dependencies-install": case "dependencies-install":
return "依赖安装"; return "依赖安装";
case "live-logs":
return "实时日志";
case "historical-logs": case "historical-logs":
return "历史日志"; return "历史日志";
} }
@@ -29,7 +29,9 @@ if not exist "%STEAMCMD_EXE%" (
if errorlevel 1 exit /b 1 if errorlevel 1 exit /b 1
) )
taskkill /IM SCUMServer.exe /F >nul 2>nul call :resolve_scum_exe
if exist "%SCUM_EXE%" call :stop_matching_scum
if errorlevel 1 exit /b 1
pushd "%STEAMCMD_DIR%" pushd "%STEAMCMD_DIR%"
set "SCUM_STEAMCMD_RESULT=1" set "SCUM_STEAMCMD_RESULT=1"
@@ -58,6 +60,11 @@ if exist "%SCUM_EXE%" exit /b 0
set "SCUM_EXE=%SERVER_ROOT_WINDOWS%\SCUM Server\%SERVER_EXECUTABLE_REF:/=\%" set "SCUM_EXE=%SERVER_ROOT_WINDOWS%\SCUM Server\%SERVER_EXECUTABLE_REF:/=\%"
exit /b 0 exit /b 0
:stop_matching_scum
if "%SCUM_EXE%"=="" exit /b 0
powershell -NoProfile -ExecutionPolicy Bypass -Command "$target=[IO.Path]::GetFullPath($env:SCUM_EXE); Get-CimInstance Win32_Process -Filter \"name='SCUMServer.exe'\" | Where-Object { $_.ExecutablePath -and ([IO.Path]::GetFullPath($_.ExecutablePath) -ieq $target) } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"
exit /b %ERRORLEVEL%
:default_steamcmd_dir :default_steamcmd_dir
for %%I in ("%SERVER_ROOT_WINDOWS%") do set "SERVER_ROOT_DRIVE=%%~dI" for %%I in ("%SERVER_ROOT_WINDOWS%") do set "SERVER_ROOT_DRIVE=%%~dI"
if "%SERVER_ROOT_DRIVE%"=="" ( if "%SERVER_ROOT_DRIVE%"=="" (
+4 -1
View File
@@ -202,7 +202,10 @@ describe("plugin manifest validation", () => {
expect(installAction).toMatchObject({ executableKey: "bin/scum-install-update.cmd", environment: { SERVER_STEAM_APP_ID: "3792580", SERVER_STEAMCMD_UPDATE_ARGS: "+login anonymous +app_update 3792580 +quit" } }); expect(installAction).toMatchObject({ executableKey: "bin/scum-install-update.cmd", environment: { SERVER_STEAM_APP_ID: "3792580", SERVER_STEAMCMD_UPDATE_ARGS: "+login anonymous +app_update 3792580 +quit" } });
expect(installAction.timeoutMs).toBe(7200000); expect(installAction.timeoutMs).toBe(7200000);
expect(startAction).toMatchObject({ executableKey: "bin/scum-start.cmd", environment: { SERVER_LOG_FLAG: "-log" } }); expect(startAction).toMatchObject({ executableKey: "bin/scum-start.cmd", environment: { SERVER_LOG_FLAG: "-log" } });
expect(installScript).toContain("taskkill /IM SCUMServer.exe /F"); expect(installScript).not.toContain("taskkill /IM SCUMServer.exe /F");
expect(installScript).toContain("call :stop_matching_scum");
expect(installScript).toContain("Get-CimInstance Win32_Process");
expect(installScript).toContain("[IO.Path]::GetFullPath($_.ExecutablePath) -ieq $target");
expect(installScript).toContain("SERVER_ROOT_WINDOWS=%SERVER_ROOT:/=\\%"); expect(installScript).toContain("SERVER_ROOT_WINDOWS=%SERVER_ROOT:/=\\%");
expect(installScript).toContain("SERVER_INSTALL_DIR=%SERVER_ROOT_WINDOWS%"); expect(installScript).toContain("SERVER_INSTALL_DIR=%SERVER_ROOT_WINDOWS%");
expect(installScript).toContain("SERVER_STEAMCMD_DIR=%SERVER_ROOT_DRIVE%\\steamcmd"); expect(installScript).toContain("SERVER_STEAMCMD_DIR=%SERVER_ROOT_DRIVE%\\steamcmd");