Allow forced server deletion

This commit is contained in:
npc0-hue
2026-08-04 15:03:49 +08:00
parent 96a1939f32
commit cf9af14eaf
17 changed files with 236 additions and 24 deletions
+4 -4
View File
@@ -218,7 +218,7 @@ describe("PlatformApiClient AI providers", () => {
return jsonResponse({ ...server, name: "Example Survival Renamed" });
}
if (url.endsWith("/api/v1/server-instances/server-1") && init?.method === "DELETE") {
expect(JSON.parse(String(init.body))).toEqual({ password: "secret-password" });
expect(JSON.parse(String(init.body))).toEqual({ password: "secret-password", force: true, confirmation: "FORCE DELETE" });
return new Response(null, { status: 204 });
}
if (url.endsWith("/api/v1/metrics/platform")) {
@@ -601,7 +601,7 @@ describe("PlatformApiClient AI providers", () => {
await expect(client.listGamePlugins()).resolves.toMatchObject({ count: 1 });
await expect(client.listServerInstances()).resolves.toMatchObject({ count: 1 });
await expect(client.updateServerInstance(server.id, { name: "Example Survival Renamed" })).resolves.toMatchObject({ name: "Example Survival Renamed" });
await expect(client.deleteServerInstance(server.id, { password: "secret-password" })).resolves.toBeUndefined();
await expect(client.deleteServerInstance(server.id, { password: "secret-password", force: true, confirmation: "FORCE DELETE" })).resolves.toBeUndefined();
await expect(client.getPlatformResourceUsage()).resolves.toMatchObject({ source: "platform-derived", cpuPercent: 28 });
await expect(client.listServerMetrics()).resolves.toMatchObject({ count: 1, items: [{ serverInstanceId: server.id, online: true }] });
await expect(client.getServerConfig(server.id)).resolves.toMatchObject({ content: "server.name=Example Survival #1\n" });
@@ -815,7 +815,7 @@ describe("PlatformApiClient AI providers", () => {
code: "validation_failed",
message: "validation failed",
details: [
"running or installing server instances must be stopped before delete",
"running or installing server instances require forced-delete confirmation",
"config path /Users/operator/private/server.ini is unavailable"
]
}), { status: 400, headers: { "Content-Type": "application/json" } })));
@@ -824,7 +824,7 @@ describe("PlatformApiClient AI providers", () => {
await expect(client.deleteServerInstance("running-server", { password: "secret-password" })).rejects.toMatchObject({
status: 400,
code: "validation_failed",
message: "运行中或安装中的服务器必须先停止再删除。;config path [host-path] is unavailable"
message: "运行中或安装中的服务器需要强制删除确认。;config path [host-path] is unavailable"
});
});
+2
View File
@@ -783,6 +783,8 @@ function safeValidationDetail(detail: string): string | undefined {
return "请输入当前登录密码。";
case "running or installing server instances must be stopped before delete":
return "运行中或安装中的服务器必须先停止再删除。";
case "running or installing server instances require forced-delete confirmation":
return "运行中或安装中的服务器需要强制删除确认。";
default:
return sanitized || undefined;
}
+2
View File
@@ -493,6 +493,8 @@ export interface ServerInstanceUpdateRequest {
export interface ServerDeletionRequest {
password: string;
force?: boolean;
confirmation?: string;
}
export interface ServerInstanceListResponse {
+1 -1
View File
@@ -163,5 +163,5 @@ export function serverMetadataFormFromInstance(instance: ServerInstanceResponse)
}
export function canDeleteServer(state: ServerInstanceState): boolean {
return state !== "running" && state !== "installing" && state !== "deleted";
return state !== "deleted";
}
+4
View File
@@ -253,8 +253,12 @@ describe("first-party console pages", () => {
const deleteHandlerSource = serversPageSource.split("async function handleDeleteServer")[1]?.split("function openRunTargetSelection")[0] ?? "";
expect(deleteHandlerSource).toContain("deleteServerInstance(deleteConfirmation.serverInstanceId, {");
expect(deleteHandlerSource).toContain("password: deletePassword");
expect(deleteHandlerSource).toContain("force: forceDelete");
expect(deleteHandlerSource).toContain("confirmation: forceDelete ? deleteForceConfirmation : undefined");
expect(serversPageSource).toContain("serverDeleteConfirmation(card.instance)");
expect(serversPageSource).toContain("serverDeleteDisabledReason(session, card.instance)");
expect(serversPageSource).toContain("FORCE DELETE");
expect(serversPageSource).toContain("不会停止远端进程");
expect(serversPageSource).toContain("危险操作");
expect(serversPageSource).toContain("删除服务器");
expect(serversPageSource).toContain("请输入当前登录密码");
+36 -4
View File
@@ -59,6 +59,7 @@ const statusFilters: Array<{ id: ServerStatusFilter; label: string }> = [
const serverListRefreshMs = 5000;
const serverMetricFreshMs = 30000;
const serverForceDeleteConfirmation = "FORCE DELETE";
export function ServersPage({ session, operations, onNavigate }: PageComponentProps) {
const [listState, setListState] = useState<ListState>("loading");
@@ -79,6 +80,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
const [deleteConfirmation, setDeleteConfirmation] = useState<ReturnType<typeof serverDeleteConfirmation> | null>(null);
const [deletePassword, setDeletePassword] = useState("");
const [deleteForceConfirmation, setDeleteForceConfirmation] = useState("");
const [deleteBusy, setDeleteBusy] = useState(false);
const [runTargetSelection, setRunTargetSelection] = useState<RunTargetSelectionState | null>(null);
@@ -210,13 +212,19 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
if (!deleteConfirmation) {
return;
}
const forceDelete = deleteConfirmation.state === "running" || deleteConfirmation.state === "installing";
setDeleteBusy(true);
const operationId = operations.begin({ intent: "删除服务器", targetKind: "server", targetId: deleteConfirmation.serverInstanceId, requester: session.displayName });
try {
await platformApiClient.deleteServerInstance(deleteConfirmation.serverInstanceId, { password: deletePassword });
await platformApiClient.deleteServerInstance(deleteConfirmation.serverInstanceId, {
password: deletePassword,
force: forceDelete,
confirmation: forceDelete ? deleteForceConfirmation : undefined
});
operations.succeed(operationId, `服务器已删除:${deleteConfirmation.serverInstanceId}`);
setDeleteConfirmation(null);
setDeletePassword("");
setDeleteForceConfirmation("");
await refresh();
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "服务器删除失败", operationId);
@@ -611,6 +619,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
onQuickAction={(action) => void handleQuickRuntimeAction(card.instance, action)}
onDelete={() => {
setDeletePassword("");
setDeleteForceConfirmation("");
setDeleteConfirmation(serverDeleteConfirmation(card.instance));
}}
/>
@@ -620,14 +629,15 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
<ConfirmDialog
open={deleteConfirmation !== null}
title="删除服务器"
description={`确认删除 ${deleteConfirmation?.name ?? ""}${deleteConfirmation?.serverInstanceId ?? ""})?运行中或安装中的服务器会被平台拒绝,历史记录会保留。`}
description={deleteDialogDescription(deleteConfirmation)}
confirmLabel="确认删除"
danger
busy={deleteBusy}
confirmDisabled={deletePassword.trim() === ""}
confirmDisabled={deletePassword.trim() === "" || (deleteRequiresForceConfirmation(deleteConfirmation) && deleteForceConfirmation.trim() !== serverForceDeleteConfirmation)}
onCancel={() => {
setDeleteConfirmation(null);
setDeletePassword("");
setDeleteForceConfirmation("");
}}
onConfirm={() => void handleDeleteServer()}
>
@@ -645,6 +655,15 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
onChange={(event) => setDeletePassword(event.target.value)}
/>
</label>
{deleteRequiresForceConfirmation(deleteConfirmation) && (
<label>
/ {serverForceDeleteConfirmation}
<input
value={deleteForceConfirmation}
onChange={(event) => setDeleteForceConfirmation(event.target.value)}
/>
</label>
)}
</ConfirmDialog>
<RuntimeTaskProgressDialog task={runtimeTask.task} onClose={runtimeTask.closeTask} actions={runtimeTaskActions} />
</section>
@@ -677,11 +696,24 @@ function serverDeleteDisabledReason(session: PageComponentProps["session"], inst
return "仅创建人或平台管理员可删除";
}
if (!canDeleteServer(instance.state)) {
return "运行中、安装中或已删除的服务器不能直接删除";
return "已删除的服务器不能再次删除";
}
return "";
}
function deleteRequiresForceConfirmation(confirmation: ReturnType<typeof serverDeleteConfirmation> | null): boolean {
return confirmation?.state === "running" || confirmation?.state === "installing";
}
function deleteDialogDescription(confirmation: ReturnType<typeof serverDeleteConfirmation> | null): string {
const name = confirmation?.name ?? "";
const id = confirmation?.serverInstanceId ?? "";
if (deleteRequiresForceConfirmation(confirmation)) {
return `确认强制删除 ${name}${id})?平台只会把实例标记为已删除并保留历史记录,不会停止可能仍在远端运行的进程。`;
}
return `确认删除 ${name}${id})?历史记录会保留。`;
}
interface ServerCardProps {
card: ServerCardView;
metricsPending: boolean;