feat(backups): 主人和管理员可删除单个备份,归档由 reaper 下一轮删除、异地副本随下次同步删除,恢复可能在读时拒绝
This commit is contained in:
23 files changed
+969
-45
No files matched your search
@@ -1277,6 +1277,26 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
||||
sendJSON(ctx.res, 200, { ok: true, signed_out: signedOut });
|
||||
return true;
|
||||
}
|
||||
if (ctx.method === "DELETE" && ctx.parts[2] === "backups" && ctx.parts[3] && ctx.parts.length === 4) {
|
||||
// Mirrors handleDeleteBackup: the scope that lists a backup deletes it and
|
||||
// any other id is 404 no_backup; a restore on its server that may still be
|
||||
// reading it is 409 restore_in_progress. The row turns expired at once.
|
||||
const b = ctx.state.backups.find((x) => x.id === ctx.parts[3] && x.status === "present");
|
||||
if (!b || !(isAdmin(ctx.account.role) || b.former_owner === ctx.account.id)) {
|
||||
sendError(ctx.res, 404, "no_backup", "no matching backup exists");
|
||||
return true;
|
||||
}
|
||||
const reading = (ctx.state.jobs[b.server_name] ?? []).some(
|
||||
(j) => (j.kind === "restore" && j.state === "running") || (j.then_restore === "pending" && j.restore_backup_id === b.id),
|
||||
);
|
||||
if (reading) {
|
||||
sendError(ctx.res, 409, "restore_in_progress", "a restore is running on this backup's server and may be reading it; delete it once the restore finishes");
|
||||
return true;
|
||||
}
|
||||
b.status = "expired";
|
||||
sendJSON(ctx.res, 200, { id: b.id, status: "expired" });
|
||||
return true;
|
||||
}
|
||||
if (ctx.method === "DELETE" && ctx.parts[2] === "account" && ctx.parts[3] === "passkey" && ctx.parts[4] === "credentials" && ctx.parts[5]) {
|
||||
// The real API asks for the re-auth before it looks the passkey up.
|
||||
if (refusedForReauth(ctx)) return true;
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"subtitle": "A world that was played is backed up automatically once a day after its server stops, and again before it is archived after long inactivity. You can use these backups to restore the world — the server must be stopped first.",
|
||||
"back_to_console": "Back to console",
|
||||
"not_yours_title": "No permission to access backups",
|
||||
"not_yours_body": "Only the owner or an admin can view and restore this server's backups.",
|
||||
"not_yours_body": "Only the owner or an admin can view, restore and delete this server's backups.",
|
||||
"latest_title": "Latest backup",
|
||||
"history_note": "Backups can be restored until they expire, then they are cleaned up automatically. Each server keeps only its most recent manual backups and its most recent scheduled backups (older ones of the same kind are removed once a new one finishes), and its last 3 pre-restore snapshots.",
|
||||
"history_note": "Backups can be restored until they expire, then they are cleaned up automatically. Each server keeps only its most recent manual backups and its most recent scheduled backups (older ones of the same kind are removed once a new one finishes), and its last 3 pre-restore snapshots. Backups you no longer need can be deleted by hand.",
|
||||
"reason_inactive": "Idle archive",
|
||||
"reason_released": "Archived when given up or deleted",
|
||||
"reason_manual": "Manual backup",
|
||||
@@ -57,7 +57,7 @@
|
||||
"restoring": "Restoring…",
|
||||
"stop_timeout": "The server did not stop in time. Close this window and try again shortly.",
|
||||
"corrupt_badge": "Corrupt",
|
||||
"corrupt_hint": "A read-back found this archive no longer matches what was written, so it can't be restored intact. It stays listed until it expires so the loss is visible.",
|
||||
"corrupt_hint": "A read-back found this archive no longer matches what was written, so it can't be restored intact. It stays listed until it expires so the loss is visible, unless you delete it.",
|
||||
"corrupt_short": "Corrupt",
|
||||
"verified_at": "Verified {{when}}",
|
||||
"skipped_entries_one": "{{count}} entry not archived",
|
||||
@@ -67,5 +67,15 @@
|
||||
"col_size": "Size",
|
||||
"col_expires": "Expires",
|
||||
"col_owner": "Former Owner",
|
||||
"col_actions": "Actions"
|
||||
"col_actions": "Actions",
|
||||
"delete_btn": "Delete backup",
|
||||
"delete_title": "Delete this backup?",
|
||||
"delete_confirm": "The backup from {{relative}} ({{absolute}}, {{size}}) leaves this list at once and can no longer be restored. This cannot be undone.",
|
||||
"delete_cleanup_note": "Its archive is removed at the next daily cleanup, and its off-site copy at the next sync.",
|
||||
"delete_archive_warning": "This backup is the archive kept when the world was reclaimed or released. Unless the world on the server has been restored from it, deleting it loses that world for good.",
|
||||
"delete_only_warning": "This is the only backup this server has.",
|
||||
"delete_confirm_yes": "Delete backup",
|
||||
"delete_restore_busy": "A restore may be reading this backup. Delete it once the restore finishes.",
|
||||
"deleted": "Backup deleted.",
|
||||
"delete_gone": "This backup was already gone; the list has been refreshed."
|
||||
}
|
||||
@@ -3,9 +3,9 @@
|
||||
"subtitle": "玩过的世界会在服务器停止后每天自动备份一次,长期闲置被自动回收前也会自动备份。可以使用备份随时恢复世界,恢复前需要先停止服务器。",
|
||||
"back_to_console": "返回控制台",
|
||||
"not_yours_title": "无权访问备份",
|
||||
"not_yours_body": "只有所有者或管理员才能查看并恢复该服务器的备份。",
|
||||
"not_yours_body": "只有所有者或管理员才能查看、恢复和删除该服务器的备份。",
|
||||
"latest_title": "最新备份",
|
||||
"history_note": "历史备份在过期前均可用于恢复,到期后自动清理。手动备份和定时备份每台服务器各只保留最近几份,新备份完成后会自动删除更早的同类备份;恢复前快照保留最近 3 份。",
|
||||
"history_note": "历史备份在过期前均可用于恢复,到期后自动清理。手动备份和定时备份每台服务器各只保留最近几份,新备份完成后会自动删除更早的同类备份;恢复前快照保留最近 3 份。不再需要的备份可以手动删除。",
|
||||
"reason_inactive": "闲置自动回收",
|
||||
"reason_released": "放弃或删除时归档",
|
||||
"reason_manual": "手动备份",
|
||||
@@ -57,7 +57,7 @@
|
||||
"restoring": "正在恢复备份……",
|
||||
"stop_timeout": "服务器停止超时。请关闭此窗口,稍后重试。",
|
||||
"corrupt_badge": "已损坏",
|
||||
"corrupt_hint": "回读校验发现这份归档与写入时不一致,已无法完整恢复。它会保留到过期,方便排查。",
|
||||
"corrupt_hint": "回读校验发现这份归档与写入时不一致,已无法完整恢复。它会保留到过期,方便排查,不需要时也可以直接删除。",
|
||||
"corrupt_short": "已损坏",
|
||||
"verified_at": "{{when}}已校验",
|
||||
"skipped_entries": "{{count}} 个条目未归档",
|
||||
@@ -66,5 +66,15 @@
|
||||
"col_size": "大小",
|
||||
"col_expires": "过期时间",
|
||||
"col_owner": "原所有者",
|
||||
"col_actions": "操作"
|
||||
"col_actions": "操作",
|
||||
"delete_btn": "删除备份",
|
||||
"delete_title": "删除这份备份?",
|
||||
"delete_confirm": "{{relative}}({{absolute}})的这份备份({{size}})会立刻从列表中移除,之后不能再用它恢复,也无法撤销。",
|
||||
"delete_cleanup_note": "归档文件在下一次每日清理时删除,异地副本在下一次同步时删除。",
|
||||
"delete_archive_warning": "这份备份是世界被回收或放弃时存下的归档。服务器上的世界如果还没从它恢复回来,删掉它就再也找不回这个世界了。",
|
||||
"delete_only_warning": "这是这台服务器目前唯一的一份备份。",
|
||||
"delete_confirm_yes": "删除备份",
|
||||
"delete_restore_busy": "有一次恢复可能正在读取这份备份,等恢复完成后再删除。",
|
||||
"deleted": "备份已删除。",
|
||||
"delete_gone": "这份备份已经不在了,列表已刷新。"
|
||||
}
|
||||
@@ -792,6 +792,17 @@ describe("image whitelist and builds wire shapes", () => {
|
||||
expect(String(url)).toBe("/backups");
|
||||
});
|
||||
|
||||
it("deleteBackup sends DELETE to the escaped backup id", async () => {
|
||||
const fetchSpy = fakeFetch({ id: "bk/1", status: "expired" });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.deleteBackup("bk/1");
|
||||
expect(res).toEqual({ id: "bk/1", status: "expired" });
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(url)).toBe("/backups/bk%2F1");
|
||||
expect((opts as RequestInit).method).toBe("DELETE");
|
||||
expect((opts as RequestInit).body).toBeUndefined();
|
||||
});
|
||||
|
||||
it("restoreBackup sends backup_id, and safety_snapshot only when turned off", async () => {
|
||||
const fetchSpy = fakeFetch(
|
||||
{ name: "survival", status: "restoring", backup_id: "bk1", safety_snapshot: true },
|
||||
|
||||
@@ -631,6 +631,14 @@ export const api = rejectingSync({
|
||||
);
|
||||
},
|
||||
|
||||
// deleteBackup deletes one backup, in the same scope that lists it (an admin any,
|
||||
// a user only a world they formerly owned; any other id is 404 no_backup). It
|
||||
// leaves every list and restore at once; the reaper deletes the archive on its
|
||||
// next daily run and the off-site sync its copy. 409 restore_in_progress while a
|
||||
// restore on that server may still be reading it.
|
||||
deleteBackup: (id: string) =>
|
||||
request<{ id: string; status: "expired" }>("DELETE", urlPath`/backups/${id}`),
|
||||
|
||||
// restoreBackup starts an ASYNC restore of a server's world from a backup
|
||||
// (spec §7 POST restore-backup). It accepts an optional backupId in the body: when
|
||||
// absent the backend restores the latest backup and resolves its opaque ref
|
||||
|
||||
@@ -1174,6 +1174,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/backups/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/**
|
||||
* Delete one world backup (admin, or the user who owned the world).
|
||||
* @description The backup leaves every list, restore and the backup budget at once; the reaper's next daily run deletes the archive and the off-site copy's next sync removes the bucket's copy. A user gets 404 for a backup outside their scope, as their list never shows it. Refused while a restore on the backup's server may still read it.
|
||||
*/
|
||||
delete: operations["deleteBackup"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/servers/{name}/restore-backup": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -5685,6 +5705,51 @@ export interface operations {
|
||||
401: components["responses"]["Unauthorized"];
|
||||
};
|
||||
};
|
||||
deleteBackup: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description The backup is deleted; its archive goes at the reaper's next run. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
id: string;
|
||||
/** @constant */
|
||||
status: "expired";
|
||||
};
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
/** @description No present backup with this id in the caller's scope (no_backup). */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description A restore running on the backup's server may be reading it (restore_in_progress). */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
restoreBackup: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import i18next from "i18next";
|
||||
@@ -15,6 +15,7 @@ const calls = vi.hoisted(() => ({
|
||||
listBackups: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
restoreBackup: vi.fn(),
|
||||
deleteBackup: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/tier", () => ({
|
||||
useTier: () => ({
|
||||
@@ -217,3 +218,154 @@ describe("ServerBackups back up now", () => {
|
||||
expect(backUp().disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServerBackups delete", () => {
|
||||
const row = (when: string) => screen.getByText(when).closest("tr") as HTMLElement;
|
||||
const deleteIn = (when: string) =>
|
||||
within(row(when)).getByRole("button", { name: "Delete backup" }) as HTMLButtonElement;
|
||||
const archiveWarning = () => screen.queryByText(i18next.t("backups:delete_archive_warning"));
|
||||
const onlyWarning = () => screen.queryByText(i18next.t("backups:delete_only_warning"));
|
||||
|
||||
it("deletes the picked backup after the confirm, then rereads the list", async () => {
|
||||
calls.listBackups
|
||||
.mockResolvedValueOnce({ backups: [backup("bk-new", 1), backup("bk-ok", 2)], total: 2 })
|
||||
.mockResolvedValue({ backups: [backup("bk-new", 1)], total: 1 });
|
||||
calls.deleteBackup.mockResolvedValue({ id: "bk-ok", status: "expired" });
|
||||
renderPage();
|
||||
await screen.findByText("2 hours ago");
|
||||
await userEvent.click(deleteIn("2 hours ago"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(within(dialog).getByText("Delete this backup?")).toBeTruthy();
|
||||
expect(dialog.textContent).toContain("1.0 MiB");
|
||||
expect(dialog.textContent).toContain(i18next.t("backups:delete_cleanup_note"));
|
||||
expect(archiveWarning()).toBeNull();
|
||||
expect(onlyWarning()).toBeNull();
|
||||
expect(calls.deleteBackup).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Delete backup" }));
|
||||
expect(calls.deleteBackup).toHaveBeenCalledExactlyOnceWith("bk-ok");
|
||||
expect(await screen.findByText("Backup deleted.")).toBeTruthy();
|
||||
await vi.waitFor(() => expect(screen.queryByText("2 hours ago")).toBeNull());
|
||||
expect(calls.listBackups).toHaveBeenCalledTimes(2);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["inactive_15d", "released"])("warns that a %s archive may be the world's only copy", async (reason) => {
|
||||
calls.listBackups.mockResolvedValue({
|
||||
backups: [{ ...backup("bk-arc", 4), reason }, backup("bk-man", 5)],
|
||||
total: 2,
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText("4 hours ago");
|
||||
await userEvent.click(deleteIn("4 hours ago"));
|
||||
await screen.findByRole("dialog");
|
||||
expect(archiveWarning()).toBeTruthy();
|
||||
expect(onlyWarning()).toBeNull();
|
||||
});
|
||||
|
||||
it("says when it is the server's only backup", async () => {
|
||||
calls.listBackups.mockResolvedValue({ backups: [backup("bk-1", 3)], total: 1 });
|
||||
renderPage();
|
||||
await screen.findByText("3 hours ago");
|
||||
await userEvent.click(deleteIn("3 hours ago"));
|
||||
await screen.findByRole("dialog");
|
||||
expect(onlyWarning()).toBeTruthy();
|
||||
expect(archiveWarning()).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the dialog open and says why when a restore may be reading it", async () => {
|
||||
calls.listBackups.mockResolvedValue({ backups: [backup("bk-1", 3), backup("bk-2", 4)], total: 2 });
|
||||
calls.deleteBackup.mockRejectedValue({ status: 409, code: "restore_in_progress", message: "busy" });
|
||||
renderPage();
|
||||
await screen.findByText("3 hours ago");
|
||||
await userEvent.click(deleteIn("3 hours ago"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Delete backup" }));
|
||||
expect(await within(dialog).findByText(i18next.t("backups:delete_restore_busy"))).toBeTruthy();
|
||||
expect(screen.getByRole("dialog")).toBe(dialog);
|
||||
expect(calls.listBackups).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText("Backup deleted.")).toBeNull();
|
||||
});
|
||||
|
||||
it("rereads the list when the backup was already gone", async () => {
|
||||
calls.listBackups
|
||||
.mockResolvedValueOnce({ backups: [backup("bk-1", 3), backup("bk-2", 4)], total: 2 })
|
||||
.mockResolvedValue({ backups: [backup("bk-2", 4)], total: 1 });
|
||||
calls.deleteBackup.mockRejectedValue({ status: 404, code: "no_backup", message: "no matching backup exists" });
|
||||
renderPage();
|
||||
await screen.findByText("3 hours ago");
|
||||
await userEvent.click(deleteIn("3 hours ago"));
|
||||
await userEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: "Delete backup" }));
|
||||
expect(await screen.findByText(i18next.t("backups:delete_gone"))).toBeTruthy();
|
||||
await vi.waitFor(() => expect(screen.queryByText("3 hours ago")).toBeNull());
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("waits while any restore runs on the server", async () => {
|
||||
calls.listBackups.mockResolvedValue({ backups: [backup("bk-1", 3), backup("bk-2", 4)], total: 2 });
|
||||
calls.serverJobs.mockResolvedValue([{ name: "restore-survival-aa", kind: "restore", state: "running" }]);
|
||||
renderPage();
|
||||
await screen.findByText("3 hours ago");
|
||||
await vi.waitFor(() => expect(deleteIn("3 hours ago").disabled).toBe(true));
|
||||
expect(deleteIn("4 hours ago").disabled).toBe(true);
|
||||
expect(deleteIn("3 hours ago").parentElement?.title).toBe(i18next.t("backups:delete_restore_busy"));
|
||||
});
|
||||
|
||||
it("waits only on the backup a safety snapshot is about to restore", async () => {
|
||||
calls.listBackups.mockResolvedValue({ backups: [backup("bk-1", 3), backup("bk-2", 4)], total: 2 });
|
||||
calls.serverJobs.mockResolvedValue([
|
||||
{ name: "backup-survival-bb", kind: "backup", state: "succeeded", then_restore: "pending", restore_backup_id: "bk-2" },
|
||||
{ name: "restore-survival-cc", kind: "restore", state: "succeeded" },
|
||||
]);
|
||||
renderPage();
|
||||
await screen.findByText("4 hours ago");
|
||||
await vi.waitFor(() => expect(deleteIn("4 hours ago").disabled).toBe(true));
|
||||
expect(deleteIn("3 hours ago").disabled).toBe(false);
|
||||
expect(deleteIn("3 hours ago").parentElement?.title).toBe("");
|
||||
});
|
||||
|
||||
it("steps back a page when the delete empties the last one", async () => {
|
||||
let lastGone = false;
|
||||
calls.listBackups.mockImplementation(async ({ offset }: { offset: number }) => ({
|
||||
backups: offset === 40 ? (lastGone ? [] : [backup("bk-last", 90)]) : [backup(`bk-${offset}`, 50)],
|
||||
total: lastGone ? 40 : 41,
|
||||
}));
|
||||
calls.deleteBackup.mockImplementation(async () => {
|
||||
lastGone = true;
|
||||
return { id: "bk-last", status: "expired" };
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText("Page 1 of 3");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await screen.findByText("Page 3 of 3");
|
||||
await screen.findByText("4 days ago");
|
||||
await userEvent.click(deleteIn("4 days ago"));
|
||||
await userEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: "Delete backup" }));
|
||||
expect(await screen.findByText("Page 2 of 2")).toBeTruthy();
|
||||
expect(calls.listBackups).toHaveBeenLastCalledWith({ server: "survival", limit: 20, offset: 20 });
|
||||
});
|
||||
|
||||
it("keeps stepping back past pages emptied meanwhile", async () => {
|
||||
let gone = false;
|
||||
calls.listBackups.mockImplementation(async ({ offset }: { offset: number }) => ({
|
||||
backups: offset === 0 ? [backup("bk-0", 50)] : gone ? [] : [backup(`bk-${offset}`, 90)],
|
||||
total: gone ? 20 : 41,
|
||||
}));
|
||||
calls.deleteBackup.mockImplementation(async () => {
|
||||
gone = true;
|
||||
return { id: "bk-40", status: "expired" };
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText("Page 1 of 3");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await screen.findByText("Page 3 of 3");
|
||||
await screen.findByText("4 days ago");
|
||||
await userEvent.click(deleteIn("4 days ago"));
|
||||
await userEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: "Delete backup" }));
|
||||
await vi.waitFor(() => expect(calls.listBackups).toHaveBeenLastCalledWith({ server: "survival", limit: 20, offset: 0 }));
|
||||
expect(await screen.findByText("2 days ago")).toBeTruthy();
|
||||
expect(screen.queryByText(/^Page \d+ of/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
RotateCcw,
|
||||
ShieldCheck,
|
||||
ShieldX,
|
||||
Trash2,
|
||||
UserMinus,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
@@ -17,6 +18,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { BackLink } from "@/components/BackLink";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { ConfirmFooter } from "@/components/ConfirmFooter";
|
||||
import { MessageLine, InlineError } from "@/components/MessageLine";
|
||||
import {
|
||||
@@ -43,12 +45,13 @@ const BACKUP_PAGE_SIZE = 20;
|
||||
|
||||
const CELL = "whitespace-nowrap md:px-4 md:py-3.5";
|
||||
|
||||
/** BackupRow is one backup in the table, with its own restore action. `isLatest`
|
||||
/** BackupRow is one backup in the table, with its own restore and delete actions. `isLatest`
|
||||
* marks the row a restore with no pick recovers: the newest one that is not
|
||||
* corrupt, which is what the backend's LatestBackup selects. Under the reason it
|
||||
* shows what the reaper's read-back found — corrupt (restore refused), verified,
|
||||
* or entries the archive could not hold. `showOwner` surfaces the former owner
|
||||
* (admins list every world's backups; a user only ever sees their own). */
|
||||
* (admins list every world's backups; a user only ever sees their own). `only`
|
||||
* says this is the one backup the server has, which the delete confirm names. */
|
||||
function BackupRow({
|
||||
b,
|
||||
isLatest,
|
||||
@@ -56,7 +59,10 @@ function BackupRow({
|
||||
locale,
|
||||
showOwner,
|
||||
serverName,
|
||||
jobs,
|
||||
only,
|
||||
onReloadStatus,
|
||||
onDeleted,
|
||||
}: {
|
||||
b: BackupView;
|
||||
isLatest: boolean;
|
||||
@@ -64,7 +70,10 @@ function BackupRow({
|
||||
locale: string;
|
||||
showOwner?: boolean;
|
||||
serverName: string;
|
||||
jobs: ServerJob[];
|
||||
only: boolean;
|
||||
onReloadStatus: () => void;
|
||||
onDeleted: (gone: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("backups");
|
||||
const expired = isExpired(b.expires_at, now);
|
||||
@@ -132,29 +141,136 @@ function BackupRow({
|
||||
</td>
|
||||
)}
|
||||
<td className={cn(CELL, "ml-auto text-right")}>
|
||||
{b.corrupt ? (
|
||||
<span className="text-xs text-destructive/70 font-medium px-3 py-1.5">
|
||||
{t("corrupt_short")}
|
||||
</span>
|
||||
) : !expired ? (
|
||||
<RestoreControls
|
||||
serverName={serverName}
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{b.corrupt ? (
|
||||
<span className="text-xs text-destructive/70 font-medium px-3 py-1.5">
|
||||
{t("corrupt_short")}
|
||||
</span>
|
||||
) : !expired ? (
|
||||
<RestoreControls
|
||||
serverName={serverName}
|
||||
backup={b}
|
||||
now={now}
|
||||
locale={locale}
|
||||
onReloadStatus={onReloadStatus}
|
||||
buttonVariant={isLatest ? "destructive" : "outline"}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground/40 font-medium px-3 py-1.5">
|
||||
{t("expired")}
|
||||
</span>
|
||||
)}
|
||||
<DeleteBackupButton
|
||||
backup={b}
|
||||
now={now}
|
||||
locale={locale}
|
||||
onReloadStatus={onReloadStatus}
|
||||
buttonVariant={isLatest ? "destructive" : "outline"}
|
||||
only={only}
|
||||
busy={restoreMayRead(jobs, b.id)}
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground/40 font-medium px-3 py-1.5">
|
||||
{t("expired")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/** restoreMayRead mirrors the backend's delete gate: a restore Job still running on
|
||||
* the server (which archive it extracts is not on the Job), or a safety snapshot
|
||||
* whose restore of this backup has yet to start, may be reading the archive. */
|
||||
function restoreMayRead(jobs: ServerJob[], id: string): boolean {
|
||||
return jobs.some(
|
||||
(j) =>
|
||||
(j.kind === "restore" && j.state === "running") ||
|
||||
(j.then_restore === "pending" && j.restore_backup_id === id),
|
||||
);
|
||||
}
|
||||
|
||||
/** DeleteBackupButton deletes one backup behind a confirm that says what goes: the
|
||||
* row leaves the list at once and can no longer be restored, and the archive and
|
||||
* its off-site copy follow at the next cleanup and sync. A reclaimed or released
|
||||
* world's archive may be the only copy of that world, and the server's only backup
|
||||
* is the last way back, so the dialog says either before it asks. While a restore
|
||||
* may be reading the archive the button waits, since the API would refuse (409);
|
||||
* a backup someone else deleted meanwhile just refreshes the list. */
|
||||
function DeleteBackupButton({
|
||||
backup,
|
||||
now,
|
||||
locale,
|
||||
only,
|
||||
busy,
|
||||
onDeleted,
|
||||
}: {
|
||||
backup: BackupView;
|
||||
now: number;
|
||||
locale: string;
|
||||
only: boolean;
|
||||
busy: boolean;
|
||||
onDeleted: (gone: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("backups");
|
||||
const [open, setOpen] = useState(false);
|
||||
const archive = backup.reason === "inactive_15d" || backup.reason === "released";
|
||||
|
||||
async function confirmDelete() {
|
||||
try {
|
||||
await api.deleteBackup(backup.id);
|
||||
} catch (e: any) {
|
||||
if (e && e.code === "no_backup") {
|
||||
onDeleted(true);
|
||||
return;
|
||||
}
|
||||
if (e && e.code === "restore_in_progress") throw new Error(t("delete_restore_busy"));
|
||||
throw e;
|
||||
}
|
||||
onDeleted(false);
|
||||
}
|
||||
|
||||
// A disabled button takes no pointer events, so the reason sits on a wrapper.
|
||||
return (
|
||||
<>
|
||||
<span title={busy ? t("delete_restore_busy") : undefined}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={busy}
|
||||
aria-label={t("delete_btn")}
|
||||
title={t("delete_btn")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={t("delete_title")}
|
||||
description={
|
||||
<>
|
||||
{t("delete_confirm", {
|
||||
relative: formatRelative(backup.created_at, now, locale),
|
||||
absolute: formatAbsolute(backup.created_at, locale),
|
||||
size: formatBytes(backup.size_bytes),
|
||||
})}
|
||||
{(archive || only) && (
|
||||
<span className="mt-3 flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-amber-700 dark:text-amber-400">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span className="flex flex-col gap-1">
|
||||
{archive && <span>{t("delete_archive_warning")}</span>}
|
||||
{only && <span>{t("delete_only_warning")}</span>}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-3 block text-xs">{t("delete_cleanup_note")}</span>
|
||||
</>
|
||||
}
|
||||
confirmLabel={t("delete_confirm_yes")}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** IntegrityNote is what the reaper's read-back says about one archive: corrupt
|
||||
* (it no longer matches what was written, so it cannot be restored), when it
|
||||
* was last read back intact, and how many entries of the world it could not
|
||||
@@ -481,6 +597,12 @@ export function ServerBackups() {
|
||||
[name, page],
|
||||
{ keepPrevious: true },
|
||||
);
|
||||
// Deleting the last row of a later page leaves it empty: step back, one settled
|
||||
// read at a time, to a page that still has backups.
|
||||
const pageEmpty = !backupsQ.loading && !!backupsQ.data && backupsQ.data.backups.length === 0 && page > 1;
|
||||
useEffect(() => {
|
||||
if (pageEmpty) setPage((p) => Math.max(1, p - 1));
|
||||
}, [pageEmpty]);
|
||||
|
||||
// Ownership resolves from /me/servers for a non-admin (status carries no `owned`).
|
||||
// While it is pending show the header with a spinner rather than flashing the list
|
||||
@@ -539,6 +661,12 @@ export function ServerBackups() {
|
||||
}
|
||||
}
|
||||
|
||||
// A deleted backup (or one already gone when asked) leaves the list, reread now.
|
||||
function handleDeleted(gone: boolean) {
|
||||
setBackupMsg({ kind: "success", text: t(gone ? "delete_gone" : "deleted") });
|
||||
backupsQ.reload();
|
||||
}
|
||||
|
||||
const back = (
|
||||
<BackLink to={`/servers/${name}`} label={t("back_to_console")} />
|
||||
);
|
||||
@@ -657,10 +785,13 @@ export function ServerBackups() {
|
||||
locale={locale}
|
||||
showOwner={isAdmin}
|
||||
serverName={name}
|
||||
jobs={jobsQ.data ?? []}
|
||||
only={total === 1}
|
||||
onReloadStatus={() => {
|
||||
statusQ.reload();
|
||||
jobsQ.reload();
|
||||
}}
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
Reference in new issue
Block a user