From 5d4fca14f9815cbe2458d194a66029c964e67827 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Mon, 3 Aug 2026 15:50:05 +0800 Subject: [PATCH] Allow active deployment edits --- platform/service/server_deployment.go | 4 +-- platform/service/server_deployment_test.go | 34 +++++++++++++++++++ .../acceptance/browser-acceptance.mjs | 2 +- platform_web/pages/ConsolePages.test.tsx | 12 +++++++ platform_web/pages/ServersPage.tsx | 27 +++------------ .../bin/scum-install-update.cmd | 9 ++++- plugins/tests/manifest-validation.test.ts | 5 ++- 7 files changed, 65 insertions(+), 28 deletions(-) diff --git a/platform/service/server_deployment.go b/platform/service/server_deployment.go index 845c76a..1419244 100644 --- a/platform/service/server_deployment.go +++ b/platform/service/server_deployment.go @@ -35,8 +35,8 @@ func (svc *CoreService) UpdateServerDeploymentForSession(sessionID, serverInstan if err != nil { return domain.ServerDeploymentView{}, err } - if instance.State == domain.ServerInstanceStateInstalling || instance.State == domain.ServerInstanceStateRunning || instance.State == domain.ServerInstanceStateDeleted { - return domain.ServerDeploymentView{}, validationError("deployment definition cannot be changed while the server is active") + if instance.State == domain.ServerInstanceStateDeleted { + return domain.ServerDeploymentView{}, validationError("deleted server deployment definition cannot be changed") } seenClearFields := map[string]bool{} for _, field := range update.ClearFields { diff --git a/platform/service/server_deployment_test.go b/platform/service/server_deployment_test.go index 5bfc149..9a65ebd 100644 --- a/platform/service/server_deployment_test.go +++ b/platform/service/server_deployment_test.go @@ -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) { svc := newTestCoreService() runHello := validRunControlHello() diff --git a/platform_web/acceptance/browser-acceptance.mjs b/platform_web/acceptance/browser-acceptance.mjs index 14a12c0..b134323 100644 --- a/platform_web/acceptance/browser-acceptance.mjs +++ b/platform_web/acceptance/browser-acceptance.mjs @@ -465,7 +465,7 @@ async function clickAndVerify(chrome, buttonText, markers) { } async function verifyServerQuickRuntimeMenu(chrome, label) { - const markers = ["生成 run", "下载 run", "更新 run", "生成客户端", "依赖检查", "依赖安装", "实时日志", "历史日志"]; + const markers = ["生成 run", "下载 run", "更新 run", "生成客户端", "依赖检查", "依赖安装", "历史日志"]; await chrome.evaluate(() => { const trigger = Array.from(document.querySelectorAll("button")).find((item) => item.textContent?.includes("运行操作")); if (!(trigger instanceof HTMLButtonElement)) { diff --git a/platform_web/pages/ConsolePages.test.tsx b/platform_web/pages/ConsolePages.test.tsx index 3a855a5..20c110b 100644 --- a/platform_web/pages/ConsolePages.test.tsx +++ b/platform_web/pages/ConsolePages.test.tsx @@ -173,6 +173,7 @@ describe("first-party console pages", () => { expect(serversPageSource).toContain(" { } }); + 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", () => { expect(serverDeploymentWorkflowSource).not.toContain('name="deploymentTargetId"'); expect(serverDeploymentWorkflowSource).not.toContain("请选择部署目标"); diff --git a/platform_web/pages/ServersPage.tsx b/platform_web/pages/ServersPage.tsx index 296e4d2..9d236b2 100644 --- a/platform_web/pages/ServersPage.tsx +++ b/platform_web/pages/ServersPage.tsx @@ -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 { createPortal } from "react-dom"; @@ -16,7 +16,6 @@ import { runtimeUpdateStages, useRuntimeTaskController } from "../components/RuntimeTaskProgress"; -import { ServerLiveLogDrawer, ServerManagementTerminalDrawer } from "../components/ServerLiveOperations"; import { ConfirmDialog, ManagementDialog, UsageMeter } from "../components/OperationControls"; import { ServerDeploymentWorkflow } from "../components/ServerDeploymentWorkflow"; import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews"; @@ -82,8 +81,6 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr const [deletePassword, setDeletePassword] = useState(""); const [deleteBusy, setDeleteBusy] = useState(false); const [runTargetSelection, setRunTargetSelection] = useState(null); - const [liveLogTarget, setLiveLogTarget] = useState(null); - const [terminalTarget, setTerminalTarget] = useState(null); const refreshList = useCallback(async (showLoading = true) => { if (showLoading) setListState("loading"); @@ -184,7 +181,6 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr } async function openEditDeployment(instance: ServerInstanceResponse) { - if (instance.state === "running" || instance.state === "installing") return; try { const deployment = await platformApiClient.getServerDeployment(instance.id); 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)); 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)); return `历史日志回填任务已排队,job ${job.id}`; } @@ -612,8 +604,6 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr deleteDisabledReason={serverDeleteDisabledReason(session, card.instance)} onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })} onEdit={() => void openEditDeployment(card.instance)} - onOpenLogs={() => setLiveLogTarget(card.instance)} - onOpenTerminal={() => setTerminalTarget(card.instance)} onQuickAction={(action) => void handleQuickRuntimeAction(card.instance, action)} onDelete={() => { setDeletePassword(""); @@ -648,8 +638,6 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr - setLiveLogTarget(null)} /> - setTerminalTarget(null)} /> ); } @@ -662,7 +650,6 @@ type ServerQuickRuntimeAction = | "generate-client-manager" | "dependencies-check" | "dependencies-install" - | "live-logs" | "historical-logs"; async function requireQuickRuntimeActionAvailable(serverInstanceId: string, action: ServerQuickRuntimeAction): Promise { @@ -694,13 +681,11 @@ interface ServerCardProps { deleteDisabledReason: string; onOpen: () => void; onEdit: () => void; - onOpenLogs: () => void; - onOpenTerminal: () => void; onQuickAction: (action: ServerQuickRuntimeAction) => 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 online = serverIsOnline(instance.state); const canDelete = deleteDisabledReason === ""; @@ -825,9 +810,7 @@ function ServerCard({ card, metricsPending, metricsUnavailable, canManage, delet 详情 - - - + @@ -892,7 +875,7 @@ const serverQuickActionGroups: Array<{ label: string; actions: ServerQuickRuntim }, { 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 "依赖检查"; case "dependencies-install": return "依赖安装"; - case "live-logs": - return "实时日志"; case "historical-logs": return "历史日志"; } diff --git a/plugins/examples/scum-server-plugin/bin/scum-install-update.cmd b/plugins/examples/scum-server-plugin/bin/scum-install-update.cmd index 40ee8e3..3aa4e6f 100755 --- a/plugins/examples/scum-server-plugin/bin/scum-install-update.cmd +++ b/plugins/examples/scum-server-plugin/bin/scum-install-update.cmd @@ -29,7 +29,9 @@ if not exist "%STEAMCMD_EXE%" ( 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%" 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:/=\%" 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 for %%I in ("%SERVER_ROOT_WINDOWS%") do set "SERVER_ROOT_DRIVE=%%~dI" if "%SERVER_ROOT_DRIVE%"=="" ( diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index 68bd005..b41e4b5 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -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.timeoutMs).toBe(7200000); 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_INSTALL_DIR=%SERVER_ROOT_WINDOWS%"); expect(installScript).toContain("SERVER_STEAMCMD_DIR=%SERVER_ROOT_DRIVE%\\steamcmd");