feat(panel): backup management
This commit is contained in:
22 files changed
+1182
-81
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user