feat(panel): backup management

This commit is contained in:
Lemon-miaow committed 2026-07-02 03:30:50 +08:00
1 parent 15c58d982d
commit 8ae65ae74a
22 files changed
+1182 -81

No files matched your search

+35
View File
@@ -1,6 +1,7 @@
import type {
AccessResult,
ApiError,
BackupView,
BanlistResult,
CreateServerRequest,
FleetServer,
@@ -161,6 +162,31 @@ export const api = {
req,
),
// World backups (spec §7). listBackups is the app-tier read: an admin sees every
// present backup, a user only the backups of worlds they formerly owned — the
// scope is decided server-side from the principal, not by any client filter, so a
// user cannot widen it. Only present (restorable) rows come back, newest first;
// there is no per-server backups endpoint, so the panel filters by server_name
// client-side and the first matching row is the one a restore would recover.
listBackups: () =>
request<{ backups: BackupView[] }>("GET", "/backups").then((r) => r.backups ?? []),
// 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
// server-side — the client never names a backup by handle (spec §286).
// Preconditions are enforced server-side and surfaced as codes: owner-or-admin +
// former-owner match (403), a present backup must exist (404 no_backup), and the
// server MUST be fully stopped (409 not_stopped) since the restore writes into
// the live world volume. The reply is 202 {name, status:"restoring", backup_id} —
// success means the restore Job was enqueued, not that the world is back yet.
restoreBackup: (name: string, backupId?: string) =>
request<{ name: string; status: string; backup_id: string }>(
"POST",
`/servers/${name}/restore-backup`,
backupId ? { backup_id: backupId } : undefined,
),
// Account linking (spec §10). Both are POST: start reports status from the
// session principal (no body, side-effect-free), verify consumes a code the
// player was shown in-game. The panel can never mint a code — that is the
@@ -222,6 +248,15 @@ export function humanizeError(e: unknown): string {
return t("not_running");
case "console_unavailable":
return t("console_unavailable");
// World restore (spec §7 restore-backup): the world volume must be free, so a
// running/starting server 409s not_stopped; no present backup 404s no_backup;
// the restore subsystem may be unwired (503 restore_unavailable).
case "no_backup":
return t("no_backup");
case "not_stopped":
return t("not_stopped");
case "restore_unavailable":
return t("restore_unavailable");
default:
if (err.status === 401) return t("session_expired");
if (err.status === 403) return t("forbidden");
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect } from "vitest";
import { formatBytes, formatRelative, formatAbsolute, isExpired } from "./format";
describe("formatBytes", () => {
it("renders sub-KiB counts as plain bytes", () => {
expect(formatBytes(0)).toBe("0 B");
expect(formatBytes(512)).toBe("512 B");
});
it("steps up binary units, one decimal below 10 and none above", () => {
expect(formatBytes(1024)).toBe("1.0 KiB");
expect(formatBytes(1024 * 1024)).toBe("1.0 MiB");
expect(formatBytes(1.4 * 1024 * 1024 * 1024)).toBe("1.4 GiB");
expect(formatBytes(140 * 1024 * 1024)).toBe("140 MiB");
});
it("caps at PiB and never overflows the unit list", () => {
expect(formatBytes(5 * 1024 ** 5)).toBe("5.0 PiB");
expect(formatBytes(5000 * 1024 ** 5)).toBe("5000 PiB");
});
it("renders a non-finite or negative input as an em dash, never NaN", () => {
expect(formatBytes(-1)).toBe("—");
expect(formatBytes(NaN)).toBe("—");
expect(formatBytes(Infinity)).toBe("—");
});
});
describe("formatRelative (now injected for determinism)", () => {
const now = Date.parse("2026-07-01T12:00:00Z");
it("renders past timestamps", () => {
expect(formatRelative("2026-07-01T09:00:00Z", now, "en-US")).toBe("3 hours ago");
expect(formatRelative("2026-06-28T12:00:00Z", now, "en-US")).toBe("3 days ago");
});
it("renders future timestamps (retention deadlines)", () => {
expect(formatRelative("2026-07-26T12:00:00Z", now, "en-US")).toBe("in 25 days");
});
it("rolls exactly-30-days up into the month bucket (boundary)", () => {
// 30 days is the day-bucket's exclusive upper edge, so it reads as a month.
expect(formatRelative("2026-07-31T12:00:00Z", now, "en-US")).toBe("next month");
});
it("localizes into zh-CN", () => {
// Intl carries the localization; assert it is non-empty and not the English form.
const zh = formatRelative("2026-06-28T12:00:00Z", now, "zh-CN");
expect(zh).not.toBe("");
expect(zh).not.toContain("ago");
});
it("returns empty string for an unparseable input", () => {
expect(formatRelative("not-a-date", now, "en-US")).toBe("");
});
});
describe("formatAbsolute", () => {
it("returns empty string for an unparseable input", () => {
expect(formatAbsolute("nope", "en-US")).toBe("");
});
it("renders a non-empty localized string for a valid input", () => {
expect(formatAbsolute("2026-07-01T12:00:00Z", "en-US")).not.toBe("");
});
});
describe("isExpired", () => {
const now = Date.parse("2026-07-01T12:00:00Z");
it("is true at or before now, false after", () => {
expect(isExpired("2026-07-01T11:59:59Z", now)).toBe(true);
expect(isExpired("2026-07-01T12:00:00Z", now)).toBe(true);
expect(isExpired("2026-07-01T12:00:01Z", now)).toBe(false);
});
it("is false for an unparseable input (never blocks on garbage)", () => {
expect(isExpired("nope", now)).toBe(false);
});
});
+65
View File
@@ -0,0 +1,65 @@
// Small pure formatters for human-facing sizes and times. Kept dependency-free and
// injectable (the caller passes `now` / `locale`) so they are deterministic under
// test rather than reading the wall clock or ambient locale themselves.
/** formatBytes renders a byte count in binary units (B / KiB / MiB / GiB…), the
* unit world archives are sized in. One decimal below 10 (1.4 GiB) and none above
* (140 MiB) — enough to tell backups apart without noise. A negative or non-finite
* input renders as an em dash rather than "NaN". */
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "—";
if (bytes < 1024) return `${Math.round(bytes)} B`;
const units = ["KiB", "MiB", "GiB", "TiB", "PiB"];
let n = bytes / 1024;
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return `${n < 10 ? n.toFixed(1) : Math.round(n)} ${units[i]}`;
}
// Time buckets for formatRelative, smallest first. Each entry: use `unit` (dividing
// the delta by `div` seconds) while the absolute delta is under `limit` seconds.
const DIVISIONS: { limit: number; div: number; unit: Intl.RelativeTimeFormatUnit }[] = [
{ limit: 60, div: 1, unit: "second" },
{ limit: 3600, div: 60, unit: "minute" },
{ limit: 86400, div: 3600, unit: "hour" },
{ limit: 2592000, div: 86400, unit: "day" },
{ limit: 31536000, div: 2592000, unit: "month" },
{ limit: Infinity, div: 31536000, unit: "year" },
];
/** formatRelative renders an ISO timestamp relative to `now` (ms epoch) — "3 days
* ago", "in 30 days" — localized via Intl.RelativeTimeFormat, so zh-CN reads
* "30 天后" / "3 天前" for free. `now` and `locale` are injected so the result is
* deterministic in tests. Returns "" for an unparseable input so a caller can fall
* back to nothing rather than surfacing "Invalid Date". */
export function formatRelative(iso: string, now: number, locale: string): string {
const then = new Date(iso).getTime();
if (!Number.isFinite(then)) return "";
const deltaSec = (then - now) / 1000; // negative = in the past
const abs = Math.abs(deltaSec);
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
for (const { limit, div, unit } of DIVISIONS) {
if (abs < limit) return rtf.format(Math.round(deltaSec / div), unit);
}
return "";
}
/** formatAbsolute renders an ISO timestamp as a full localized date-time, for the
* `title` tooltip behind a relative label. Empty string on an unparseable input. */
export function formatAbsolute(iso: string, locale: string): string {
const d = new Date(iso);
if (!Number.isFinite(d.getTime())) return "";
return d.toLocaleString(locale);
}
/** isExpired reports whether an ISO retention deadline is at or before `now`. A
* present backup is normally still within retention (the reaper deletes expired
* ones), but the panel guards the edge so a just-expired row reads honestly rather
* than offering a restore that would 404. */
export function isExpired(iso: string, now: number): boolean {
const t = new Date(iso).getTime();
return Number.isFinite(t) && t <= now;
}
+25
View File
@@ -118,6 +118,31 @@ export interface FleetServer {
owner?: string;
}
/** BackupView is one row of GET /api/v1/backups (spec §7 backups). A backup is
* written only when the reaper archives an inactive world's PVC before reclaiming
* it (reason "inactive_15d"), so a backup is the SAVED STATE of a world that was
* put to sleep: `former_owner` is who owned it then, `expires_at` the §466
* retention deadline past which it can no longer be restored. The opaque
* backup_ref is deliberately withheld (spec §286) — the panel never names a backup
* by handle; restore resolves the latest present backup server-side.
*
* Only `status: "present"` rows are ever listed (the query filters them) and the
* list is created_at-descending, so the FIRST row for a given server is exactly
* the one a restore would recover (LatestBackup's WHERE mirrors this) — the UI must
* name that row, not a plausible proxy. `reason`/`status` cross an unvalidated JSON
* boundary; render unknown values tolerantly. */
export interface BackupView {
id: string;
server_name: string;
/** Present only when the world had an owner when it was archived. */
former_owner?: string;
size_bytes: number;
reason: string;
status: string;
created_at: string;
expires_at: string;
}
/** WhitelistImage is one row of GET /images (the create-form dropdown source). */
export interface WhitelistImage {
image_ref: string;