feat(backups): 主人和管理员可下载单个备份、导出停服世界,字节经一次性票据从导出 Job 流式转给浏览器,备份按 sha256 核对

This commit is contained in:
Lemon-miaow committed 2026-09-28 00:58:42 +08:00
1 parent 34b81ee8fe
commit 5dffadb40d
35 files changed
+4018 -70

No files matched your search

+17 -2
View File
@@ -15,19 +15,34 @@
"backup_now": "Back up now",
"backup_in_progress": "Starting backup…",
"backup_started": "Backup started — its progress shows under recent operations below.",
"backup_world_busy": "A backup or restore is already running; back up once it finishes.",
"backup_world_busy": "A backup, restore or world export is already running; back up once it finishes.",
"backup_requires_stopped": "Stop the server before backing it up.",
"export_world": "Export world",
"export_world_hint": "Download the server's current world as a .tar.gz archive.",
"export_preparing": "Preparing…",
"export_requires_stopped": "Stop the server before exporting its world.",
"export_world_busy": "A backup, restore or export is running on this world; export once it finishes.",
"export_another": "Another download is being prepared; wait for it to start.",
"export_progress_world": "Archiving the world — the download starts by itself once it is ready. Keep this page open; the server can't start until the download ends.",
"export_progress_backup": "Preparing the backup — the download starts by itself in a moment. Keep this page open.",
"export_started_world": "Download started: your browser is saving {{filename}}. The server can start again once it finishes.",
"export_started_backup": "Download started: your browser is saving {{filename}}.",
"export_failed": "The export failed and nothing was downloaded.",
"export_failed_because": "The export failed: {{reason}}",
"download_btn": "Download backup",
"expires_in": "Expires {{when}}",
"expired": "Expired",
"former_owner": "Former owner: {{owner}}",
"empty_title": "No backups for this server",
"empty_hint": "A scheduled backup is saved once the server has run and stopped — or create one now with “Back up now”.",
"jobs_title": "Recent operations",
"jobs_empty": "No recent backup or restore operations.",
"jobs_empty": "No recent backup, restore or export operations.",
"job_backup": "Backup",
"job_restore": "Restore",
"job_pre_restore": "Pre-restore snapshot",
"job_scheduled": "Scheduled backup",
"job_export_world": "World export",
"job_export_backup": "Backup download",
"job_running": "Running",
"job_succeeded": "Succeeded",
"job_failed": "Failed",
+5 -1
View File
@@ -25,13 +25,17 @@
"no_backup": "There's no restorable backup for this server yet.",
"backup_corrupt": "This backup failed its read-back check and can't be restored intact — pick another backup.",
"not_stopped": "Stop the server completely first — this changes its world volume, which the running server holds.",
"maintenance_in_progress": "This server's world is busy with a restore, backup, file write or idle reclaim — try again once it finishes. A restore, backup or file write usually takes a minute or two; an idle reclaim can take longer on a large world.",
"maintenance_in_progress": "This server's world is busy with a restore, backup, world export, file write or idle reclaim — try again once it finishes. A restore, backup or file write usually takes a minute or two; a world export lasts until its download ends, and an idle reclaim can take longer on a large world.",
"no_world_volume": "This server has no world volume yet — start it once so it is created, then retry.",
"file_changed": "This file changed after you opened it (another manager saved it, or the server rewrote it on its last run), so your save was not written, to keep that change.",
"volume_full": "The server's volume is full, so the change was not written and the files there are unchanged. Delete files it no longer needs, or ask an admin to grow its volume.",
"backup_cooldown": "This server was backed up moments ago, and manual backups have a cooldown — try again in a few minutes.",
"backup_store_full": "The backup store is full, so manual backups are paused — ask an administrator to free space.",
"restore_unavailable": "Restore isn't available right now — try again later.",
"export_busy": "Too many downloads are being prepared right now (one at a time per person, six an hour) — try again in a few minutes.",
"export_expired": "This download has expired or was already used — start the export again.",
"export_not_ready": "The download isn't ready yet — wait a moment and try again.",
"export_unavailable": "Downloading worlds and backups isn't set up on this deployment — ask an administrator.",
"session_expired": "Your session expired — please sign in again.",
"forbidden": "You are not allowed to do that.",
"self_protected": "You can't do that to the account you're signed in with.",
+17 -2
View File
@@ -15,19 +15,34 @@
"backup_now": "立即备份",
"backup_in_progress": "正在启动备份……",
"backup_started": "已开始备份——进度可在下方“最近操作”中查看。",
"backup_world_busy": "已有备份或回档在进行,等它结束再备份。",
"backup_world_busy": "已有备份、恢复或世界导出在进行,等它结束再备份。",
"backup_requires_stopped": "备份前需要先停止服务器。",
"export_world": "导出世界",
"export_world_hint": "把服务器当前的世界打包成 .tar.gz 下载。",
"export_preparing": "准备中……",
"export_requires_stopped": "导出世界前需要先停止服务器。",
"export_world_busy": "这个世界正在备份、恢复或导出,等它结束再导出。",
"export_another": "另一个下载正在准备,等它开始后再试。",
"export_progress_world": "正在打包世界——准备好后会自动开始下载。请保持此页面打开;下载结束前服务器无法启动。",
"export_progress_backup": "正在准备备份——稍后会自动开始下载。请保持此页面打开。",
"export_started_world": "已开始下载:浏览器正在保存 {{filename}}。下载结束后服务器即可再次启动。",
"export_started_backup": "已开始下载:浏览器正在保存 {{filename}}。",
"export_failed": "导出失败,没有下载任何内容。",
"export_failed_because": "导出失败:{{reason}}",
"download_btn": "下载备份",
"expires_in": "{{when}}过期",
"expired": "已过期",
"former_owner": "原世界所有者:{{owner}}",
"empty_title": "暂无备份",
"empty_hint": "服务器运行过并停止后会自动生成定时备份,也可以点“立即备份”马上创建。",
"jobs_title": "最近操作",
"jobs_empty": "暂无备份或恢复操作记录。",
"jobs_empty": "暂无备份、恢复或导出操作记录。",
"job_backup": "备份",
"job_restore": "恢复",
"job_pre_restore": "恢复前快照",
"job_scheduled": "定时备份",
"job_export_world": "世界导出",
"job_export_backup": "备份下载",
"job_running": "进行中",
"job_succeeded": "成功",
"job_failed": "失败",
+5 -1
View File
@@ -25,13 +25,17 @@
"no_backup": "这台服务器暂时没有可回档的备份。",
"backup_corrupt": "这份备份回读校验未通过,已无法完整恢复——请选择另一份备份。",
"not_stopped": "请先把服务器完全停止——这项操作要改动世界存储卷,运行中的服务器独占着它。",
"maintenance_in_progress": "这台服务器的世界正在回档、备份、写入文件或闲置回收——等它完成后再试。回档、备份和写文件通常一两分钟,闲置回收视世界大小可能更久。",
"maintenance_in_progress": "这台服务器的世界正在回档、备份、导出、写入文件或闲置回收——等它完成后再试。回档、备份和写文件通常一两分钟,导出要等下载结束,闲置回收视世界大小可能更久。",
"no_world_volume": "这台服务器还没有世界卷——先启动一次让它创建,然后再试。",
"file_changed": "这个文件在你打开之后被改过了(另一位管理者保存过,或者服务器上次运行时改写了它)。为了不覆盖那次修改,这次保存没有写入。",
"volume_full": "服务器的存储卷已满,这次改动没有写入,原有文件保持不变。删掉用不着的文件,或者请管理员给它扩容。",
"backup_cooldown": "这台服务器刚备份过,手动备份之间有冷却时间——请过几分钟再试。",
"backup_store_full": "备份存储已满,暂时无法手动备份——请联系管理员清理空间。",
"restore_unavailable": "回档功能当前不可用,请稍后再试。",
"export_busy": "现在正在准备的下载太多了(每人同时一个、每小时最多六个)——请过几分钟再试。",
"export_expired": "这个下载已过期或已经用过了——请重新导出。",
"export_not_ready": "下载还没准备好——请稍等片刻再试。",
"export_unavailable": "当前部署没有开通世界和备份下载——请联系管理员。",
"session_expired": "会话已过期——请重新登录。",
"forbidden": "你无权执行此操作。",
"self_protected": "不能对当前登录的账号执行此操作。",
+70 -1
View File
@@ -7,8 +7,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// falsy, and EVERY admin silently renders as a non-admin while typecheck/build stay
// green. This fixture is the canonical mirror of handlers_user.go handleMe.
// apiBase stays "" (paths read bare) unless a test that builds a whole URL sets it.
const cfg = vi.hoisted(() => ({ apiBase: "" }));
vi.mock("./config", () => ({
loadConfig: async () => ({ apiBase: "", rootDomain: "example.test" }),
loadConfig: async () => ({ apiBase: cfg.apiBase, rootDomain: "example.test" }),
}));
// Imported after the mock so api.ts picks up the mocked loadConfig.
@@ -1600,3 +1602,70 @@ describe("copy for the generic server codes", () => {
);
});
});
describe("world export wire shapes", () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => {
vi.unstubAllGlobals();
cfg.apiBase = "";
});
function sent(fetchSpy: typeof fetch): [string, RequestInit] {
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
return [String(url), opts as RequestInit];
}
const ticket = { ticket: "ab".repeat(32), state: "pending", filename: "survival-backup-bk1.tar.gz" };
it("startBackupExport POSTs to /servers/{name}/backups/{id}/export with no body", async () => {
const fetchSpy = fakeFetch(ticket, { status: 202 });
vi.stubGlobal("fetch", fetchSpy);
expect(await api.startBackupExport("survival", "bk/1")).toEqual(ticket);
const [url, opts] = sent(fetchSpy);
expect(url).toBe("/servers/survival/backups/bk%2F1/export");
expect(opts.method).toBe("POST");
expect(opts.body).toBeUndefined();
});
it("startWorldExport POSTs to /servers/{name}/world/export", async () => {
const fetchSpy = fakeFetch(ticket, { status: 202 });
vi.stubGlobal("fetch", fetchSpy);
await api.startWorldExport("survival");
const [url, opts] = sent(fetchSpy);
expect(url).toBe("/servers/survival/world/export");
expect(opts.method).toBe("POST");
});
it("exportStatus GETs /exports/{ticket}", async () => {
const fetchSpy = fakeFetch({ state: "failed", message: "felis export: no such file" });
vi.stubGlobal("fetch", fetchSpy);
expect(await api.exportStatus("t/1")).toEqual({ state: "failed", message: "felis export: no such file" });
const [url, opts] = sent(fetchSpy);
expect(url).toBe("/exports/t%2F1");
expect(opts.method).toBe("GET");
});
it("exportDownloadURL is the full download address, and sends nothing", async () => {
cfg.apiBase = "https://api.example.test/api/v1";
const fetchSpy = fakeFetch({});
vi.stubGlobal("fetch", fetchSpy);
expect(await api.exportDownloadURL("t/1")).toBe("https://api.example.test/api/v1/exports/t%2F1/download");
await expect(api.exportDownloadURL("..")).rejects.toMatchObject({ code: "bad_path_param" });
expect(fetchSpy).not.toHaveBeenCalled();
});
it("words the export refusals itself", () => {
expect(humanizeError({ status: 429, code: "export_busy", message: "raw" })).toBe(
"Too many downloads are being prepared right now (one at a time per person, six an hour) — try again in a few minutes.",
);
expect(humanizeError({ status: 410, code: "export_expired", message: "raw" })).toBe(
"This download has expired or was already used — start the export again.",
);
expect(humanizeError({ status: 409, code: "export_not_ready", message: "raw" })).toBe(
"The download isn't ready yet — wait a moment and try again.",
);
expect(humanizeError({ status: 503, code: "export_unavailable", message: "raw" })).toBe(
"Downloading worlds and backups isn't set up on this deployment — ask an administrator.",
);
});
});
+36
View File
@@ -10,6 +10,8 @@ import type {
ContextUploadProgress,
CreateServerRequest,
CreateUserRequest,
ExportStatus,
ExportTicket,
FleetServer,
Identity,
KickResult,
@@ -673,6 +675,29 @@ export const api = rejectingSync({
backupNow: (name: string) =>
request<{ name: string; status: string }>("POST", urlPath`/servers/${name}/backup`),
// World export: a copy of one backup (startBackupExport, the same scope as
// delete and restore) or of a stopped server's current world (startWorldExport,
// 409 not_stopped otherwise; the world is held until the download ends). The
// reply is a one-time ticket bound to the caller. An export Job then hands the
// archive to felis-api: exportStatus reads "pending" until it has, and once
// "ready" the browser must open exportDownloadURL within 90 s. The download
// streams through felis-api, so it is opened as a link (an <a download>) and
// never read into memory. 429 export_busy past the limits (one per person at a
// time, two across the platform, six an hour); 410 export_expired once the
// ticket was used or its time ran out.
startBackupExport: (name: string, id: string) =>
request<ExportTicket>("POST", urlPath`/servers/${name}/backups/${id}/export`),
startWorldExport: (name: string) =>
request<ExportTicket>("POST", urlPath`/servers/${name}/world/export`),
exportStatus: (ticket: string) => request<ExportStatus>("GET", urlPath`/exports/${ticket}`),
exportDownloadURL: async (ticket: string) => {
const { apiBase } = await loadConfig();
return `${apiBase}${urlPath`/exports/${ticket}/download`}`;
},
// serverJobs lists the newest backup/restore Jobs of one server, newest first
// (GET /servers/{name}/jobs). Owner-or-admin gated server-side; a Job's
// failure text rides `message`. The backend answers 503 until the job-status
@@ -1223,6 +1248,17 @@ export function humanizeError(e: unknown): string {
return t("backup_store_full");
case "restore_unavailable":
return t("restore_unavailable");
// World export: over the per-person, platform or hourly limit (429), a
// ticket already used or past its time (410), a download asked for before
// the archive arrived (409), or export not wired on this deployment (503).
case "export_busy":
return t("export_busy");
case "export_expired":
return t("export_expired");
case "export_not_ready":
return t("export_not_ready");
case "export_unavailable":
return t("export_unavailable");
// Server create/edit (spec §22): the portability regex + reservation list are
// enforced server-side, and the create form's own checks are weaker, so these
// refusals reach the dialog as-is.
+373 -4
View File
@@ -159,6 +159,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/internal/exports/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Hand one export's archive over for download (one-time bearer token).
* @description The export Job PUTs the tar.gz here, chunked for a world and with its Content-Length for a backup. The Job holds no service token, so the route is public on the internal face and the bearer token minted with the export is the whole check; an unknown id, a wrong or missing token and a token already used are all the same 404. The request then waits, body unread, up to 90 seconds for the owner's browser to open the download, and is read at the browser's pace: the 16 KiB/s minimum body rate does not apply, and the body fails only after 2 minutes without a byte. It answers once the download has ended.
*/
put: operations["internalExportUpload"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/internal/servers/{name}/join-event": {
parameters: {
query?: never;
@@ -1254,6 +1274,86 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/backups/{id}/export": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Start downloading one backup (owner-or-admin plus a former-owner match).
* @description Starts a Job that reads the archive from the backup store and hands it to felis-api, which streams it to the browser (poll GET /exports/{ticket}, then open its download). The archive is checked against the sha256 recorded when it was written as it streams; a mismatch aborts the download. A user gets 404 for a backup outside their scope, as their list never shows it. One export per user at a time, 2 across the install, 6 per user per hour.
*/
post: operations["exportBackup"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/world/export": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Start downloading a stopped server's world as it is now (owner-or-admin).
* @description Starts a Job that archives the server's data volume, read-only, and hands it to felis-api, which streams it to the browser (poll GET /exports/{ticket}, then open its download). The server must be fully stopped, and it cannot start until the download has ended or the Job's 2 hour deadline passes. Same limits as a backup export.
*/
post: operations["exportWorld"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/exports/{ticket}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Where an export stands (the user who started it only).
* @description The panel polls this until the state reads ready, then opens the download. Another user's ticket is 404, as an unknown one is.
*/
get: operations["exportStatus"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/exports/{ticket}/download": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Download a ready export (once, by the user who started it).
* @description The first request spends the ticket, whatever becomes of it. The archive streams as the Job sends it, with Content-Length when it is known; a download that cannot finish (the Job died, or a backup did not match its recorded sha256) is cut off, so the browser reports it failed. HEAD is refused, since it would spend the ticket on no body.
*/
get: operations["exportDownload"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/jobs": {
parameters: {
query?: never;
@@ -1262,8 +1362,8 @@ export interface paths {
cookie?: never;
};
/**
* Latest async world operations (backup/restore) for a server (owner-or-admin).
* @description Backup and restore run as cluster Jobs, so a 202 that later failed left its only trace in the Job object. This route projects the newest such Jobs, newest first, so failures are observable without kubectl. State is "running" | "succeeded" | "failed".
* Latest async world operations (backup, restore, export) for a server (owner-or-admin).
* @description Backup, restore and export run as cluster Jobs, so a 202 that later failed left its only trace in the Job object. This route projects the newest such Jobs, newest first, so failures are observable without kubectl. State is "running" | "succeeded" | "failed".
*/
get: operations["listServerJobs"];
put?: never;
@@ -2799,6 +2899,25 @@ export interface components {
/** @description World entries the archive could not hold (symbolic links, devices, sockets). Omitted when zero. */
skipped_entries?: number;
};
/** @description An export just started (internal/api/exports.go exportTicketView). The ticket opens GET /exports/{ticket} and its download for the user who started it, and nobody else. */
ExportTicket: {
/** @description 64 hex characters. */
ticket: string;
/** @constant */
state: "pending";
/** @description What the download saves as. */
filename: string;
};
/** @description Where an export stands (internal/api/exports.go exportStatusView). */
ExportStatus: {
/**
* @description pending while its Job starts; ready once the archive waits for the download, which must begin within 90 seconds; failed when the Job died first.
* @enum {string}
*/
state: "pending" | "ready" | "failed";
/** @description Why a failed export's Job died. Omitted otherwise. */
message?: string;
};
/** @description One image build (internal/build Build). */
Build: {
id: string;
@@ -3491,6 +3610,46 @@ export interface operations {
404: components["responses"]["NotFound"];
};
};
internalExportUpload: {
parameters: {
query?: never;
header: {
/** @description Bearer followed by the token minted with the export. */
Authorization: string;
};
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/gzip": string;
};
};
responses: {
204: components["responses"]["NoContent"];
404: components["responses"]["NotFound"];
/** @description The backup did not match the sha256 recorded when it was written, and the download was aborted (backup_corrupt). */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Nobody opened the download within 90 seconds, or the browser left before the archive ended (export_expired). */
410: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
joinEvent: {
parameters: {
query?: never;
@@ -6132,6 +6291,216 @@ export interface operations {
507: components["responses"]["InsufficientStorage"];
};
};
exportBackup: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Export started. */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ExportTicket"];
};
};
/** @description Malformed server name (bad_name). */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
/** @description Unknown server, or 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 The backup failed a read-back (backup_corrupt). */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description An export limit is reached (export_busy); Retry-After gives the seconds to wait. */
429: {
headers: {
"Retry-After"?: number;
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
503: components["responses"]["ServiceUnavailable"];
};
};
exportWorld: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Export started. */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ExportTicket"];
};
};
/** @description Malformed server name (bad_name). */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
/** @description Unknown server. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Server is not stopped (not_stopped), has no world volume yet (no_world_volume), or a restore, backup, file write or export already holds its world volume (maintenance_in_progress). */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description An export limit is reached (export_busy); Retry-After gives the seconds to wait. */
429: {
headers: {
"Retry-After"?: number;
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
503: components["responses"]["ServiceUnavailable"];
};
};
exportStatus: {
parameters: {
query?: never;
header?: never;
path: {
ticket: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The export's state. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ExportStatus"];
};
};
401: components["responses"]["Unauthorized"];
404: components["responses"]["NotFound"];
/** @description The export was downloaded, or expired before it was (export_expired). */
410: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
exportDownload: {
parameters: {
query?: never;
header?: never;
path: {
ticket: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The tar.gz, as an attachment. */
200: {
headers: {
"Content-Disposition"?: string;
[name: string]: unknown;
};
content: {
"application/gzip": string;
};
};
401: components["responses"]["Unauthorized"];
404: components["responses"]["NotFound"];
/** @description A method other than GET (method_not_allowed). */
405: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description The export is still pending (export_not_ready). */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description The export was downloaded, failed, or expired before it was (export_expired). */
410: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
listServerJobs: {
parameters: {
query?: never;
@@ -6143,7 +6512,7 @@ export interface operations {
};
requestBody?: never;
responses: {
/** @description The server's newest backup/restore jobs. */
/** @description The server's newest backup, restore and export jobs. */
200: {
headers: {
[name: string]: unknown;
@@ -6154,7 +6523,7 @@ export interface operations {
jobs: {
name: string;
/** @enum {string} */
kind: "backup" | "restore";
kind: "backup" | "restore" | "export_world" | "export_backup";
/** @enum {string} */
state: "running" | "succeeded" | "failed";
message?: string;
+2
View File
@@ -39,6 +39,8 @@ export type WireParity = [
Holds<Parity<T.Schedule, S["Schedule"]>>,
Holds<Parity<T.ScheduleInput, S["ScheduleInput"]>>,
Holds<Parity<T.BackupView, S["BackupView"]>>,
Holds<Parity<T.ExportTicket, S["ExportTicket"]>>,
Holds<Parity<T.ExportStatus, S["ExportStatus"]>>,
Holds<Parity<T.Build, S["Build"]>>,
Holds<Parity<T.BuildScan, S["BuildScan"]>>,
Holds<Parity<T.ScanSummary, S["ScanSummary"]>>,
+27 -3
View File
@@ -227,11 +227,13 @@ export interface BackupView {
}
/** ServerJob is one row of GET /api/v1/servers/{name}/jobs — the observable
* outcome of an async backup/restore Job. The API only enqueues Jobs, so this
* projection is how a 202 that later failed becomes visible in the panel. */
* outcome of an async backup/restore/export Job. The API only enqueues Jobs, so
* this projection is how a 202 that later failed becomes visible in the panel.
* An export_world Job holds the world until its download ends; an
* export_backup Job only reads the backup store. */
export interface ServerJob {
name: string;
kind: string; // "backup" | "restore"
kind: string; // "backup" | "restore" | "export_world" | "export_backup"
state: string; // "running" | "succeeded" | "failed"
message?: string;
started_at?: string;
@@ -247,6 +249,28 @@ export interface ServerJob {
scheduled?: boolean;
}
// ---- World export (internal/api/exports.go exportTicketView, exportStatusView) ----
/** ExportTicket is the 202 of POST /servers/{name}/backups/{id}/export and
* /servers/{name}/world/export: the one-time handle of a download being
* prepared. Only the viewer who asked can read or use it, and it opens the
* download once. `filename` is what the browser saves. */
export interface ExportTicket {
ticket: string;
state: "pending";
filename: string;
}
/** ExportStatus is GET /exports/{ticket}: "pending" while the export Job is on
* its way, "ready" once felis-api holds the archive and waits (90 s) for the
* browser to fetch it, "failed" when the Job gave up, with its reason in
* message. A ticket already downloaded or past its time answers 410
* export_expired instead. */
export interface ExportStatus {
state: "pending" | "ready" | "failed";
message?: string;
}
// ---- Scheduled tasks (internal/api/schedules.go Schedule, scheduleInput) ----
export type ScheduleAction = "command" | "restart" | "stop" | "start" | "backup";
+243 -1
View File
@@ -16,6 +16,10 @@ const calls = vi.hoisted(() => ({
stop: vi.fn(),
restoreBackup: vi.fn(),
deleteBackup: vi.fn(),
startBackupExport: vi.fn(),
startWorldExport: vi.fn(),
exportStatus: vi.fn(),
exportDownloadURL: vi.fn(),
}));
vi.mock("@/lib/tier", () => ({
useTier: () => ({
@@ -203,7 +207,7 @@ describe("ServerBackups back up now", () => {
renderPage();
await screen.findByText("Page 1 of 3");
await vi.waitFor(() => expect(backUp().disabled).toBe(true));
expect(backUp().title).toBe("A backup or restore is already running; back up once it finishes.");
expect(backUp().title).toBe("A backup, restore or world export is already running; back up once it finishes.");
});
it("keeps the page when a reread fails, and says the status may be stale", async () => {
@@ -369,3 +373,241 @@ describe("ServerBackups delete", () => {
expect(screen.queryByText(/^Page \d+ of/)).toBeNull();
});
});
describe("ServerBackups export", () => {
const row = (when: string) => screen.getByText(when).closest("tr") as HTMLElement;
const downloadIn = (when: string) =>
within(row(when)).getByRole("button", { name: "Download backup" }) as HTMLButtonElement;
const exportWorld = () => screen.getByRole("button", { name: /^(Export world|Preparing…)$/ }) as HTMLButtonElement;
const backUp = () => screen.getByRole("button", { name: "Back up now" }) as HTMLButtonElement;
const text = (key: string, opts?: Record<string, string>) => i18next.t(key, opts);
// Every hidden link the page clicks, as it was at the click.
function watchLinks() {
const clicked: { href: string | null; download: string; hidden: boolean; connected: boolean }[] = [];
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) {
clicked.push({
href: this.getAttribute("href"),
download: this.download,
hidden: this.hidden,
connected: this.isConnected,
});
});
return clicked;
}
// readyAfter answers "pending" for the first n reads of the ticket, then "ready".
function readyAfter(n: number) {
let reads = 0;
calls.exportStatus.mockImplementation(async () => ({ state: reads++ < n ? "pending" : "ready" }));
}
beforeEach(() => {
calls.listBackups.mockResolvedValue({ backups: [backup("bk-1", 3), backup("bk-2", 4)], total: 2 });
calls.exportDownloadURL.mockImplementation(async (ticket: string) => `/api/v1/exports/${ticket}/download`);
});
it("downloads a backup through a hidden link once felis-api holds it, never before", async () => {
const clicked = watchLinks();
calls.startBackupExport.mockResolvedValue({ ticket: "t1", state: "pending", filename: "survival-backup-bk-1.tar.gz" });
readyAfter(2);
renderPage();
await screen.findByText("3 hours ago");
vi.useFakeTimers();
await act(async () => {
fireEvent.click(downloadIn("3 hours ago"));
});
expect(calls.startBackupExport).toHaveBeenCalledExactlyOnceWith("survival", "bk-1");
expect(screen.getByRole("status").textContent).toBe(text("backups:export_progress_backup"));
// One download at a time: every other export waits, and says why.
expect(downloadIn("3 hours ago").disabled).toBe(true);
expect(downloadIn("4 hours ago").disabled).toBe(true);
expect(downloadIn("4 hours ago").parentElement?.title).toBe(text("backups:export_another"));
expect(exportWorld().disabled).toBe(true);
await act(() => vi.advanceTimersByTimeAsync(4_000));
expect(calls.exportStatus).toHaveBeenCalledTimes(2);
expect(calls.exportStatus).toHaveBeenCalledWith("t1");
expect(clicked).toEqual([]);
await act(() => vi.advanceTimersByTimeAsync(2_000));
expect(calls.exportDownloadURL).toHaveBeenCalledExactlyOnceWith("t1");
expect(clicked).toEqual([
{ href: "/api/v1/exports/t1/download", download: "survival-backup-bk-1.tar.gz", hidden: true, connected: true },
]);
expect(document.querySelectorAll("a[download]")).toHaveLength(0);
expect(screen.getByRole("status").textContent).toBe(
text("backups:export_started_backup", { filename: "survival-backup-bk-1.tar.gz" }),
);
expect(downloadIn("4 hours ago").disabled).toBe(false);
expect(exportWorld().disabled).toBe(false);
await act(() => vi.advanceTimersByTimeAsync(10_000));
expect(calls.exportStatus).toHaveBeenCalledTimes(3);
// The next download can start once this one has.
await act(async () => {
fireEvent.click(downloadIn("4 hours ago"));
});
expect(calls.startBackupExport).toHaveBeenLastCalledWith("survival", "bk-2");
expect(calls.startBackupExport).toHaveBeenCalledTimes(2);
});
it("starts one export for a double click", async () => {
watchLinks();
calls.startBackupExport.mockResolvedValue({ ticket: "t1", state: "pending", filename: "a.tar.gz" });
readyAfter(0);
renderPage();
await screen.findByText("3 hours ago");
await act(async () => {
fireEvent.click(downloadIn("3 hours ago"));
fireEvent.click(downloadIn("3 hours ago"));
});
expect(calls.startBackupExport).toHaveBeenCalledTimes(1);
});
it("exports the stopped world and holds the world buttons meanwhile", async () => {
const clicked = watchLinks();
calls.startWorldExport.mockResolvedValue({ ticket: "t2", state: "pending", filename: "survival-world-20231114-221320.tar.gz" });
readyAfter(1);
renderPage();
await screen.findByText("3 hours ago");
expect(exportWorld().disabled).toBe(false);
expect(exportWorld().title).toBe(text("backups:export_world_hint"));
// The export Job holds the world as soon as it exists.
calls.serverJobs.mockResolvedValue([{ name: "export-survival-aa", kind: "export_world", state: "running" }]);
vi.useFakeTimers();
await act(async () => {
fireEvent.click(exportWorld());
});
expect(calls.startWorldExport).toHaveBeenCalledExactlyOnceWith("survival");
expect(calls.startBackupExport).not.toHaveBeenCalled();
expect(exportWorld().textContent).toBe("Preparing…");
expect(screen.getByRole("status").textContent).toBe(text("backups:export_progress_world"));
expect(backUp().disabled).toBe(true);
await act(() => vi.advanceTimersByTimeAsync(4_000));
expect(clicked).toEqual([
{ href: "/api/v1/exports/t2/download", download: "survival-world-20231114-221320.tar.gz", hidden: true, connected: true },
]);
expect(screen.getByRole("status").textContent).toBe(
text("backups:export_started_world", { filename: "survival-world-20231114-221320.tar.gz" }),
);
// Until the download ends the Job still holds the world.
expect(exportWorld().textContent).toBe("Export world");
expect(exportWorld().disabled).toBe(true);
expect(exportWorld().title).toBe(text("backups:export_world_busy"));
expect(backUp().title).toBe(text("backups:backup_world_busy"));
expect(screen.getByText("World export", { selector: "li span" })).toBeTruthy();
});
it("offers the world export only while the server is stopped", async () => {
calls.status.mockResolvedValue({ name: "survival", displayName: "Survival", phase: "Running", desiredState: "Running" });
renderPage();
await screen.findByText("3 hours ago");
expect(exportWorld().disabled).toBe(true);
expect(exportWorld().title).toBe(text("backups:export_requires_stopped"));
// A backup is still downloadable while the server runs.
expect(downloadIn("3 hours ago").disabled).toBe(false);
});
it("waits for a backup or restore that holds the world, but not for a backup download", async () => {
calls.serverJobs.mockResolvedValue([{ name: "backup-survival-aa", kind: "backup", state: "running" }]);
const { unmount } = renderPage();
await screen.findByText("3 hours ago");
await vi.waitFor(() => expect(exportWorld().disabled).toBe(true));
expect(exportWorld().title).toBe(text("backups:export_world_busy"));
unmount();
calls.serverJobs.mockResolvedValue([{ name: "export-survival-bb", kind: "export_backup", state: "running" }]);
renderPage();
expect(await screen.findByText("Backup download", { selector: "li span" })).toBeTruthy();
expect(exportWorld().disabled).toBe(false);
expect(backUp().disabled).toBe(false);
});
it("offers a download on exactly the rows a restore is offered on", async () => {
calls.listBackups.mockResolvedValue({
backups: [
backup("bk-ok", 3),
backup("bk-bad", 4, true),
{ ...backup("bk-old", 5), expires_at: new Date(Date.now() - 3600_000).toISOString() },
],
total: 3,
});
renderPage();
await screen.findByText("3 hours ago");
for (const [when, offered] of [
["3 hours ago", true],
["4 hours ago", false],
["5 hours ago", false],
] as const) {
expect(within(row(when)).queryByRole("button", { name: "Restore" }) !== null).toBe(offered);
expect(within(row(when)).queryByRole("button", { name: "Download backup" }) !== null).toBe(offered);
}
});
it("says why a failed export produced nothing", async () => {
const clicked = watchLinks();
calls.startBackupExport.mockResolvedValue({ ticket: "t3", state: "pending", filename: "a.tar.gz" });
calls.exportStatus
.mockResolvedValueOnce({ state: "pending" })
.mockResolvedValue({ state: "failed", message: "felis export: ref is not under backup root" });
renderPage();
await screen.findByText("3 hours ago");
vi.useFakeTimers();
await act(async () => {
fireEvent.click(downloadIn("3 hours ago"));
});
await act(() => vi.advanceTimersByTimeAsync(4_000));
expect(screen.getByRole("alert").textContent).toBe(
text("backups:export_failed_because", { reason: "felis export: ref is not under backup root" }),
);
expect(clicked).toEqual([]);
expect(calls.exportDownloadURL).not.toHaveBeenCalled();
expect(downloadIn("3 hours ago").disabled).toBe(false);
await act(() => vi.advanceTimersByTimeAsync(10_000));
expect(calls.exportStatus).toHaveBeenCalledTimes(2);
});
it.each([
["the limits", "backup", { status: 429, code: "export_busy", message: "one export at a time per user" }, "errors:export_busy"],
["a stale ticket", "status", { status: 410, code: "export_expired", message: "this export has expired" }, "errors:export_expired"],
["an unwired deployment", "backup", { status: 503, code: "export_unavailable", message: "export is not configured" }, "errors:export_unavailable"],
["a server that is not stopped", "world", { status: 409, code: "not_stopped", message: "stop the server" }, "backups:export_requires_stopped"],
] as const)("words a refusal for %s", async (_, where, err, key) => {
const clicked = watchLinks();
calls.startBackupExport.mockResolvedValue({ ticket: "t4", state: "pending", filename: "a.tar.gz" });
calls.startWorldExport.mockRejectedValue(err);
if (where === "backup") calls.startBackupExport.mockRejectedValue(err);
calls.exportStatus.mockRejectedValue(err);
renderPage();
await screen.findByText("3 hours ago");
vi.useFakeTimers();
await act(async () => {
fireEvent.click(where === "world" ? exportWorld() : downloadIn("3 hours ago"));
});
await act(() => vi.advanceTimersByTimeAsync(2_000));
expect(screen.getByRole("alert").textContent).toBe(text(key));
expect(clicked).toEqual([]);
expect(exportWorld().disabled).toBe(false);
});
it("stops reading the ticket once the page is left", async () => {
const clicked = watchLinks();
calls.startBackupExport.mockResolvedValue({ ticket: "t5", state: "pending", filename: "a.tar.gz" });
readyAfter(1);
const { unmount } = renderPage();
await screen.findByText("3 hours ago");
vi.useFakeTimers();
await act(async () => {
fireEvent.click(downloadIn("3 hours ago"));
});
await act(() => vi.advanceTimersByTimeAsync(2_000));
expect(calls.exportStatus).toHaveBeenCalledTimes(1);
unmount();
await act(() => vi.advanceTimersByTimeAsync(10_000));
expect(calls.exportStatus).toHaveBeenCalledTimes(1);
expect(clicked).toEqual([]);
});
});
+194 -21
View File
@@ -5,6 +5,7 @@ import {
Archive,
CheckCircle2,
Clock,
Download,
HardDrive,
Loader2,
RotateCcw,
@@ -45,13 +46,15 @@ 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 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). `only`
* says this is the one backup the server has, which the delete confirm names. */
/** BackupRow is one backup in the table, with its own restore, download 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). `only` says this is the one backup the server has,
* which the delete confirm names. `exporting` is the download the page is
* preparing, if any. */
function BackupRow({
b,
isLatest,
@@ -61,8 +64,10 @@ function BackupRow({
serverName,
jobs,
only,
exporting,
onReloadStatus,
onDeleted,
onExport,
}: {
b: BackupView;
isLatest: boolean;
@@ -72,8 +77,10 @@ function BackupRow({
serverName: string;
jobs: ServerJob[];
only: boolean;
exporting: ExportTarget | null;
onReloadStatus: () => void;
onDeleted: (gone: boolean) => void;
onExport: (target: ExportTarget) => void;
}) {
const { t } = useTranslation("backups");
const expired = isExpired(b.expires_at, now);
@@ -147,14 +154,17 @@ function BackupRow({
{t("corrupt_short")}
</span>
) : !expired ? (
<RestoreControls
serverName={serverName}
backup={b}
now={now}
locale={locale}
onReloadStatus={onReloadStatus}
buttonVariant={isLatest ? "destructive" : "outline"}
/>
<>
<RestoreControls
serverName={serverName}
backup={b}
now={now}
locale={locale}
onReloadStatus={onReloadStatus}
buttonVariant={isLatest ? "destructive" : "outline"}
/>
<DownloadBackupButton id={b.id} exporting={exporting} onExport={onExport} />
</>
) : (
<span className="text-xs text-muted-foreground/40 font-medium px-3 py-1.5">
{t("expired")}
@@ -185,6 +195,57 @@ function restoreMayRead(jobs: ServerJob[], id: string): boolean {
);
}
/** ExportTarget is what a download copies: the server's current world, or one of
* its backups. */
type ExportTarget = { kind: "world" } | { kind: "backup"; id: string };
/** DownloadBackupButton asks for a copy of one backup. The page prepares the
* download (one at a time, as felis-api allows one per person), so the button
* spins while it is this backup's and waits while it is another's. */
function DownloadBackupButton({
id,
exporting,
onExport,
}: {
id: string;
exporting: ExportTarget | null;
onExport: (target: ExportTarget) => void;
}) {
const { t } = useTranslation("backups");
const mine = exporting?.kind === "backup" && exporting.id === id;
// A disabled button takes no pointer events, so the reason sits on a wrapper.
return (
<span title={exporting && !mine ? t("export_another") : undefined}>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:bg-primary/10 hover:text-primary"
onClick={() => onExport({ kind: "backup", id })}
disabled={exporting !== null}
aria-label={t("download_btn")}
title={t("download_btn")}
>
{mine ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Download className="h-3.5 w-3.5" />}
</Button>
</span>
);
}
/** saveDownload hands a ready export to the browser the way a link would: a
* hidden <a download> it clicks once. felis-api answers with an attachment, so
* the browser's own download manager streams the archive to disk; reading it
* into a Blob first would hold a whole world in the tab's memory. */
function saveDownload(url: string, filename: string) {
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.rel = "noopener";
a.hidden = true;
document.body.appendChild(a);
a.click();
a.remove();
}
/** 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
@@ -409,6 +470,9 @@ const POLL_MS = 2500;
// them in game and holds the stop 30 s, then the pre-stop save and the pod's own
// shutdown save follow.
const MAX_POLLS = 48;
// How often an export being prepared rereads its ticket: the Job needs a few
// seconds to start, and a ready ticket waits 90 s for the browser.
const EXPORT_POLL_MS = 2000;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function RestoreControls({
@@ -566,7 +630,8 @@ function RestoreControls({
/** ServerBackups is the per-server backup surface (/servers/:name/backups): view the
* world archives kept for this server and (B2) roll the world back to any of them
* that has not expired. It owns its own gating — ownership from /me/servers, since GET status
* that has not expired, or download one, or the stopped world itself, as a
* .tar.gz. It owns its own gating — ownership from /me/servers, since GET status
* never carries `owned` — but deliberately does NOT gate on readiness the way
* ServerPlayers does: backups are read from Postgres, not RCON, and a restore in
* fact requires the server to be STOPPED, so this page must work while it is asleep. */
@@ -667,6 +732,68 @@ export function ServerBackups() {
backupsQ.reload();
}
// The download being prepared. The ref guards a second click landing before
// the render that disables the buttons; alive stops the wait once the page
// is left (the export is then abandoned: nobody fetches it).
const [exporting, setExporting] = useState<ExportTarget | null>(null);
const exportRun = useRef(false);
const alive = useRef(true);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
// A download is prepared before it starts: the POST answers a one-time ticket,
// an export Job hands the archive to felis-api, and this reads the ticket
// until felis-api holds it. Only then does the browser fetch it, as a link, so
// any size of world streams straight to disk. A ready ticket waits 90 s for
// that fetch, so it follows at once. A pending ticket ends on its own (410
// export_expired after 10 min), which bounds the wait.
async function runExport(target: ExportTarget) {
if (exportRun.current) return;
exportRun.current = true;
setExporting(target);
setBackupMsg(null);
try {
const tk =
target.kind === "world" ? await api.startWorldExport(name) : await api.startBackupExport(name, target.id);
jobsQ.reload(); // a world export now holds the world
for (;;) {
await sleep(EXPORT_POLL_MS);
if (!alive.current) return;
const s = await api.exportStatus(tk.ticket);
if (s.state === "ready") break;
if (s.state === "failed") {
setBackupMsg({
kind: "error",
text: s.message ? t("export_failed_because", { reason: s.message }) : t("export_failed"),
});
return;
}
}
saveDownload(await api.exportDownloadURL(tk.ticket), tk.filename);
setBackupMsg({
kind: "success",
text: t(target.kind === "world" ? "export_started_world" : "export_started_backup", { filename: tk.filename }),
});
} catch (e: any) {
if (!alive.current) return;
setBackupMsg({
kind: "error",
text:
target.kind === "world" && e && e.code === "not_stopped" ? t("export_requires_stopped") : humanizeError(e),
});
} finally {
exportRun.current = false;
if (alive.current) {
setExporting(null);
jobsQ.reload();
}
}
}
const back = (
<BackLink to={`/servers/${name}`} label={t("back_to_console")} />
);
@@ -699,10 +826,13 @@ export function ServerBackups() {
const total = backupsQ.data?.total ?? 0;
const latestID = page === 1 ? all.find((b) => !b.corrupt)?.id : undefined;
// A server just woken still reads Stopped until its pod starts, so the gate is
// where it is heading. A backup or restore already under way holds the world
// (the API would answer 409), so the button waits for it too.
// where it is heading. A backup, restore or world export already under way
// holds the world (the API would answer 409), so the buttons wait for it too;
// a backup download only reads the backup store and holds nothing.
const phase = shownPhase(statusQ.data);
const worldBusy = (jobsQ.data ?? []).some((j) => j.state === "running" || j.then_restore === "pending");
const worldBusy = (jobsQ.data ?? []).some(
(j) => (j.state === "running" && j.kind !== "export_backup") || j.then_restore === "pending",
);
const header = (
<PageHeader
@@ -711,6 +841,32 @@ export function ServerBackups() {
subtitle={t("title")}
actions={
<div className="flex items-center gap-2">
{owned && (
<Button
size="sm"
variant="outline"
onClick={() => runExport({ kind: "world" })}
disabled={exporting !== null || phase !== "Stopped" || worldBusy}
title={
exporting?.kind === "backup"
? t("export_another")
: exporting
? undefined
: phase !== "Stopped"
? t("export_requires_stopped")
: worldBusy
? t("export_world_busy")
: t("export_world_hint")
}
>
{exporting?.kind === "world" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{exporting?.kind === "world" ? t("export_preparing") : t("export_world")}
</Button>
)}
{owned && (
<Button
size="sm"
@@ -753,6 +909,15 @@ export function ServerBackups() {
<NotYours title={t("not_yours_title")} body={t("not_yours_body")} />
) : (
<div className="space-y-4">
{exporting && (
<div
role="status"
className="flex items-center gap-2 rounded-md border border-sky-500/20 bg-sky-500/10 p-3 text-sm text-sky-600 dark:text-sky-400"
>
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
<p>{t(exporting.kind === "world" ? "export_progress_world" : "export_progress_backup")}</p>
</div>
)}
{backupMsg && <MessageLine kind={backupMsg.kind} message={backupMsg.text} />}
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
{backupsQ.loading && !backupsQ.data ? (
@@ -787,11 +952,13 @@ export function ServerBackups() {
serverName={name}
jobs={jobsQ.data ?? []}
only={total === 1}
exporting={exporting}
onReloadStatus={() => {
statusQ.reload();
jobsQ.reload();
}}
onDeleted={handleDeleted}
onExport={runExport}
/>
))}
</tbody>
@@ -811,8 +978,8 @@ export function ServerBackups() {
</>
)}
{/* Async world-operation history: every backup/restore 202 lands here, so a
Job that later failed stays visible (with its message) without kubectl. */}
{/* Async world-operation history: every backup/restore/export 202 lands here,
so a Job that later failed stays visible (with its message) without kubectl. */}
{jobsQ.error ? (
<ErrorState error={jobsQ.error} onRetry={jobsQ.reload} />
) : (
@@ -833,6 +1000,8 @@ export function ServerBackups() {
<div className="flex min-w-0 items-center gap-2">
{j.kind === "restore" ? (
<RotateCcw className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
) : j.kind === "export_world" || j.kind === "export_backup" ? (
<Download className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
) : (
<Archive className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
)}
@@ -845,6 +1014,10 @@ export function ServerBackups() {
: j.scheduled
? t("job_scheduled")
: t("job_backup")
: j.kind === "export_world"
? t("job_export_world")
: j.kind === "export_backup"
? t("job_export_backup")
: j.kind}
</span>
{at && (