Allow forced server deletion
This commit is contained in:
@@ -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"
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -493,6 +493,8 @@ export interface ServerInstanceUpdateRequest {
|
||||
|
||||
export interface ServerDeletionRequest {
|
||||
password: string;
|
||||
force?: boolean;
|
||||
confirmation?: string;
|
||||
}
|
||||
|
||||
export interface ServerInstanceListResponse {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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("请输入当前登录密码");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user