From 8ae65ae74a5c6579f3872f3583089b0c083d9c4a Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Thu, 2 Jul 2026 02:14:48 +0800 Subject: [PATCH] feat(panel): backup management --- internal/api/api_test.go | 14 + internal/api/handlers_backups.go | 80 +++- internal/api/handlers_backups_test.go | 123 +++++ internal/api/pgrepo.go | 15 + internal/api/repo.go | 4 + panel/dev/mockApi.ts | 104 ++++- panel/src/App.tsx | 2 + panel/src/components/LogConsole.tsx | 8 +- panel/src/components/ui/dialog.tsx | 18 +- panel/src/i18n/index.ts | 4 + panel/src/i18n/resources/en-US/backups.json | 36 ++ panel/src/i18n/resources/en-US/errors.json | 3 + panel/src/i18n/resources/en-US/servers.json | 2 + panel/src/i18n/resources/zh-CN/backups.json | 36 ++ panel/src/i18n/resources/zh-CN/errors.json | 3 + panel/src/i18n/resources/zh-CN/servers.json | 2 + panel/src/lib/api.ts | 35 ++ panel/src/lib/format.test.ts | 77 ++++ panel/src/lib/format.ts | 65 +++ panel/src/lib/types.ts | 25 + panel/src/pages/ServerBackups.tsx | 486 ++++++++++++++++++++ panel/src/pages/ServerConsole.tsx | 121 +++-- 22 files changed, 1182 insertions(+), 81 deletions(-) create mode 100644 panel/src/i18n/resources/en-US/backups.json create mode 100644 panel/src/i18n/resources/zh-CN/backups.json create mode 100644 panel/src/lib/format.test.ts create mode 100644 panel/src/lib/format.ts create mode 100644 panel/src/pages/ServerBackups.tsx diff --git a/internal/api/api_test.go b/internal/api/api_test.go index ddba2b3..ea779fe 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -517,6 +517,20 @@ func (f *fakeRepo) LatestBackup(_ context.Context, serverName string) (*BackupRe }, nil } +func (f *fakeRepo) BackupByID(_ context.Context, id string) (*BackupRecord, error) { + for i := range f.backups { + b := &f.backups[i] + if b.view.Status == "present" && b.view.ID == id { + return &BackupRecord{ + ID: b.view.ID, ServerName: b.view.ServerName, + FormerOwner: b.view.FormerOwner, BackupRef: b.ref, + SizeBytes: b.view.SizeBytes, + }, nil + } + } + return nil, ErrNotFound +} + // ---- local-password auth fakes (spec §B) ---- // Each method mirrors the PGRepo contract: a returned StaffUser is copied so a // test cannot mutate the stored row by reference, SessionUser re-reads the diff --git a/internal/api/handlers_backups.go b/internal/api/handlers_backups.go index 58b2e3d..459aa7b 100644 --- a/internal/api/handlers_backups.go +++ b/internal/api/handlers_backups.go @@ -3,6 +3,7 @@ package api import ( "errors" "net/http" + "strings" "felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/naming" @@ -37,9 +38,10 @@ func (a *API) handleListBackups(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"backups": backups}) } -// handleRestoreBackup starts restoring a server's world from its most recent -// backup (spec §7 POST /servers/{name}/restore-backup; spec §466: former_owner -// 3mo 内重新 claim → restore PVC). The authorization is deliberately stricter than +// handleRestoreBackup starts restoring a server's world from a backup (spec §7 +// POST /servers/{name}/restore-backup; spec §466). It accepts an optional JSON +// body with a backup_id; when absent it restores the latest backup for the server +// (backward-compatible default). The authorization is deliberately stricter than // ordinary owner-or-admin, in this order: // // ① name validation @@ -47,20 +49,22 @@ func (a *API) handleListBackups(w http.ResponseWriter, r *http.Request) { // ③ owner-or-admin, else 403. A released world's server row is unowned // (owner_id NULL → OwnerID ""), so this also enforces "重新 claim": a former // owner must re-claim the server before they can restore into it. -// ④ the latest present backup, else 404 no_backup -// ⑤ former-owner match: a non-admin may restore ONLY a world they formerly owned. -// The current-owner gate in ③ is not enough — user B who re-claims a released -// server could otherwise resurrect user A's world (the backup still carries -// former_owner=A), a data leak. Admin skips this check. -// ⑥ stopped gate: the world PVC must be free, so restore is refused unless the -// server is fully stopped. A running OR starting server still holds the RWO -// world volume, which a restore Job could not mount — a clean 409 beats a Job -// that fails to schedule. -// ⑦ hand off to the Restorer. Restore is asynchronous (a restore Job, like an +// ④ if the optional backup_id is supplied the handler resolves the specific +// backup; otherwise it picks the most recent present backup, else +// 404 no_backup +// ⑤ cross-server guard: a backup requested by id must belong to the server in +// the path — restoring server A's backup onto server B would be a data leak +// ⑥ former-owner match: a non-admin may restore ONLY a world they formerly +// owned. The current-owner gate in ③ is not enough — user B who +// re-claims a released server could otherwise resurrect user A's world (the +// backup still carries former_owner=A), a data leak. Admin skips this check. +// ⑦ stopped gate: the world PVC must be free, so restore is refused unless the +// server is fully stopped. +// ⑧ hand off to the Restorer. Restore is asynchronous (a restore Job, like an // image build Job), so success means "enqueued" and the handler answers 202. // -// The opaque backup_ref is resolved server-side from the latest backup and handed -// to the Restorer directly; the client never names a backup by handle (spec §286 +// The opaque backup_ref is resolved server-side from the backup and handed to the +// Restorer directly; the client never names a backup by handle (spec §286 // principle). func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) @@ -83,15 +87,47 @@ func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) { return } - backup, err := a.Repo.LatestBackup(r.Context(), name) - if err != nil { - if errors.Is(err, ErrNotFound) { - writeError(w, r, newError(http.StatusNotFound, "no_backup", - "no restorable backup exists for this server")) + // Optional backup_id in the JSON body; absent → LatestBackup (backward compat). + var body struct { + BackupID string `json:"backup_id"` + } + if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { + if err := decodeJSON(w, r, &body); err != nil { + writeError(w, r, err) + return + } + } + + // Resolve the backup record. When backup_id is specified the handler resolves + // that exact backup; otherwise it picks the most recent present one. + var backup *BackupRecord + if body.BackupID != "" { + backup, err = a.Repo.BackupByID(r.Context(), body.BackupID) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, r, newError(http.StatusNotFound, "no_backup", + "no matching backup exists")) + return + } + writeError(w, r, err) + return + } + // Cross-server guard: the backup must belong to the server named in the path. + if backup.ServerName != name { + writeError(w, r, errForbidden) + return + } + } else { + backup, err = a.Repo.LatestBackup(r.Context(), name) + if err != nil { + if errors.Is(err, ErrNotFound) { + writeError(w, r, newError(http.StatusNotFound, "no_backup", + "no restorable backup exists for this server")) + return + } + writeError(w, r, err) return } - writeError(w, r, err) - return } // A non-admin may restore only a world they formerly owned (spec §466). Without diff --git a/internal/api/handlers_backups_test.go b/internal/api/handlers_backups_test.go index 8521aa3..167db1b 100644 --- a/internal/api/handlers_backups_test.go +++ b/internal/api/handlers_backups_test.go @@ -290,4 +290,127 @@ func TestRestoreBackup(t *testing.T) { t.Fatalf("code = %d, want 400", w.Code) } }) + + // ---- restore by backup_id ---- + + // mkTwo supplements the base mk with two present backups for the same server + // so tests can exercise restoring the older one by id. bk2 is older than bk1, + // so LatestBackup still returns bk1 — restoring by "bk2" proves it reached the + // correct record. + mkTwo := func() (*API, *fakeRepo, *fakeCluster, *fakeRestorer) { + repo := newFakeRepo() + repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"} + repo.backups = []fakeBackup{ + {view: BackupView{ID: "bk1", ServerName: "survival", FormerOwner: "owner1", + Status: "present", Reason: "inactive_15d", SizeBytes: 1024, + CreatedAt: time.Unix(1_699_000_000, 0), ExpiresAt: time.Unix(1_706_000_000, 0)}, ref: "ref-bk1"}, + {view: BackupView{ID: "bk2", ServerName: "survival", FormerOwner: "owner1", + Status: "present", Reason: "manual", SizeBytes: 2048, + CreatedAt: time.Unix(1_698_000_000, 0), ExpiresAt: time.Unix(1_706_000_000, 0)}, ref: "ref-bk2"}, + } + cl := newFakeCluster() + cl.byName["survival"] = &ServerInfo{Name: "survival", Phase: "Stopped", + Ready: false, DesiredState: string(v1alpha1.DesiredStopped)} + restorer := &fakeRestorer{} + api := newTestAPI(repo, cl) + api.Restorer = restorer + return api, repo, cl, restorer + } + + jsonHeaders := map[string]string{"Content-Type": "application/json"} + + t.Run("restore by backup_id -> 202, correct BackupRef sent", func(t *testing.T) { + api, _, _, restorer := mkTwo() + api.External = staticExternal{p: owner} + body := `{"backup_id":"bk2"}` + w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders) + if w.Code != http.StatusAccepted { + t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String()) + } + var resp struct { + Name string `json:"name"` + Status string `json:"status"` + BackupID string `json:"backup_id"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("body not JSON: %v (%s)", err, w.Body.String()) + } + if resp.BackupID != "bk2" { + t.Fatalf("backup_id = %q, want bk2", resp.BackupID) + } + if restorer.gotRef != "ref-bk2" { + t.Fatalf("restorer ref = %q, want ref-bk2 (proves BackupByID, not LatestBackup)", restorer.gotRef) + } + }) + + t.Run("restore by backup_id not found -> 404 no_backup", func(t *testing.T) { + api, _, _, restorer := mkTwo() + api.External = staticExternal{p: owner} + body := `{"backup_id":"nonexistent"}` + w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders) + if w.Code != http.StatusNotFound || decodeErr(t, w) != "no_backup" { + t.Fatalf("code = %d body %s", w.Code, w.Body.String()) + } + if restorer.calls != 0 { + t.Fatal("non-existent backup must not reach the restorer") + } + }) + + t.Run("restore by backup_id that is deleted -> 404 no_backup", func(t *testing.T) { + api, repo, _, _ := mkTwo() + repo.backups[1].view.Status = "deleted" + api.External = staticExternal{p: owner} + body := `{"backup_id":"bk2"}` + w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders) + if w.Code != http.StatusNotFound || decodeErr(t, w) != "no_backup" { + t.Fatalf("code = %d body %s", w.Code, w.Body.String()) + } + }) + + t.Run("restore by backup_id cross-server -> 403", func(t *testing.T) { + api, repo, _, _ := mkTwo() + // bk2 belongs to a different server; restoring it onto survival is forbidden. + repo.backups[1].view.ServerName = "creative" + api.External = staticExternal{p: owner} + body := `{"backup_id":"bk2"}` + w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders) + if w.Code != http.StatusForbidden { + t.Fatalf("code = %d, want 403 (cross-server guard)", w.Code) + } + }) + + t.Run("no body -> falls back to LatestBackup (backward compat)", func(t *testing.T) { + api, _, _, restorer := mkTwo() + api.External = staticExternal{p: owner} + w := do(api.ExternalHandler(), "POST", path, "", nil) + if w.Code != http.StatusAccepted { + t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String()) + } + if restorer.gotRef != "ref-bk1" { + t.Fatalf("restorer ref = %q, want ref-bk1 (LatestBackup)", restorer.gotRef) + } + }) + + t.Run("empty JSON body -> falls back to LatestBackup", func(t *testing.T) { + api, _, _, restorer := mkTwo() + api.External = staticExternal{p: owner} + body := `{}` + w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders) + if w.Code != http.StatusAccepted { + t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String()) + } + if restorer.gotRef != "ref-bk1" { + t.Fatalf("restorer ref = %q, want ref-bk1 (LatestBackup)", restorer.gotRef) + } + }) + + t.Run("malformed JSON body -> 400", func(t *testing.T) { + api, _, _, _ := mkTwo() + api.External = staticExternal{p: owner} + body := `not json` + w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders) + if w.Code != http.StatusBadRequest { + t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String()) + } + }) } diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index 9fdbef4..517af76 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -462,6 +462,21 @@ func (p *PGRepo) LatestBackup(ctx context.Context, serverName string) (*BackupRe return &b, nil } +// BackupByID returns a single present backup by its id, or ErrNotFound. +func (p *PGRepo) BackupByID(ctx context.Context, id string) (*BackupRecord, error) { + const q = `SELECT id, server_name, COALESCE(former_owner, ''), backup_ref, COALESCE(size_bytes, 0) + FROM world_backups WHERE id = $1 AND status = 'present'` + var b BackupRecord + switch err := p.db.QueryRowContext(ctx, q, id).Scan( + &b.ID, &b.ServerName, &b.FormerOwner, &b.BackupRef, &b.SizeBytes); { + case errors.Is(err, sql.ErrNoRows): + return nil, ErrNotFound + case err != nil: + return nil, err + } + return &b, nil +} + func (p *PGRepo) Audit(ctx context.Context, e AuditEntry) error { // A nil Payload must land as SQL NULL, not the text "null"; a non-nil Payload is // passed as a JSON text the jsonb column parses (same idiom as reaper.PGStore). diff --git a/internal/api/repo.go b/internal/api/repo.go index 364a359..aefc06d 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -221,6 +221,10 @@ type Repo interface { // carries the server-side backup_ref + former_owner the restore path needs; the // client never sees them. LatestBackup(ctx context.Context, serverName string) (*BackupRecord, error) + // BackupByID returns a single present backup by its id, or ErrNotFound when + // none matches. Like LatestBackup the returned BackupRecord carries the + // server-side backup_ref the restore path needs; the client never sees it. + BackupByID(ctx context.Context, id string) (*BackupRecord, error) // SeedServer inserts the business-layer rows for a newly created server (spec // §15): a servers row (owner_id NULL — claimed later, spec §9.3) and its // subdomain alias, both idempotent. It returns ErrConflict if the subdomain is diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index 07eaf8d..d625bed 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { Plugin } from "vite"; import type { AutostartPolicy, + BackupView, CreateServerRequest, FleetServer, Identity, @@ -50,6 +51,10 @@ interface MockState { // server only gets an entry once its access is touched; "survival" is pre-seeded // so the whitelist panel demos a populated list out of the box. access: Record; + // World backups (GET /backups). Global, not keyed by server — the page filters by + // server_name client-side, mirroring the real global list endpoint. Scoped per + // caller at dispatch (admin sees all; a user only worlds they formerly owned). + backups: BackupView[]; } // PLAYER_NAME mirrors the backend's mcNameRe (handlers_access.go) so the mock @@ -130,6 +135,39 @@ const LOGIN_HINT_SCRIPT = ` })(); `; +// World-backup seed. A backup is written when the reaper archives an inactive +// world, so these read as "sleep saves": a handful for survival (recent through one +// nearly expired, to exercise the relative-time and near-expiry states), one for +// modded, none for the rest so the empty state shows too. former_owner is the +// archiving owner; GET /backups is scoped by it for non-admins (BackupsForUser). +const GiB = 1024 ** 3; +const DAY_MS = 86_400_000; +const RETENTION_DAYS = 90; + +function backup(server: string, daysAgo: number, sizeBytes: number, formerOwner: string): BackupView { + const created = Date.now() - daysAgo * DAY_MS; + return { + id: `bk-${server}-${daysAgo}`, + server_name: server, + former_owner: formerOwner, + size_bytes: Math.round(sizeBytes), + reason: "inactive_15d", + status: "present", + created_at: new Date(created).toISOString(), + expires_at: new Date(created + RETENTION_DAYS * DAY_MS).toISOString(), + }; +} + +function mockBackups(): BackupView[] { + return [ + backup("survival", 5, 1.4 * GiB, "owner"), + backup("survival", 20, 1.3 * GiB, "owner"), + backup("survival", 45, 1.2 * GiB, "owner"), + backup("survival", 88, 2.1 * GiB, "owner"), // ~2 days from expiry — exercises the urgency state + backup("modded", 12, 0.6 * GiB, "owner"), + ]; +} + function initialState(): MockState { return { accounts: { @@ -203,6 +241,7 @@ function initialState(): MockState { ], }, }, + backups: mockBackups(), }; } @@ -499,6 +538,15 @@ async function handleSession(ctx: SessionContext): Promise { case "GET images": sendJSON(ctx.res, 200, { images: ctx.state.images }); return true; + case "GET backups": + // Admin sees every archive; a user only worlds they formerly owned — mirrors + // AllBackups vs BackupsForUser. The panel filters by server_name client-side. + sendJSON(ctx.res, 200, { + backups: ctx.state.backups.filter( + (b) => ctx.account.role === "admin" || b.former_owner === ctx.account.id, + ), + }); + return true; case "POST servers": await createServerRoute(ctx); return true; @@ -509,7 +557,7 @@ async function handleSession(ctx: SessionContext): Promise { await verifyLinkRoute(ctx); return true; default: - return handleServerRoute(ctx); + return await handleServerRoute(ctx); } } @@ -542,7 +590,7 @@ async function verifyLinkRoute(ctx: SessionContext): Promise { sendJSON(ctx.res, 200, { linked: true, mc_uuid: MC_UUID }); } -function handleServerRoute(ctx: SessionContext): boolean { +async function handleServerRoute(ctx: SessionContext): Promise { if (ctx.parts[2] !== "servers" || !ctx.parts[3]) return false; const serverInfo = findServer(ctx.state, decodeURIComponent(ctx.parts[3])); @@ -597,6 +645,9 @@ function handleServerRoute(ctx: SessionContext): boolean { claimServer(ctx, serverInfo); return true; } + if (is("POST", ctx) && ctx.parts[4] === "restore-backup") { + return await handleRestoreBackupMock(ctx, serverInfo); + } if (ctx.parts[4] === "access") { return handleAccessMock(ctx, serverInfo); } @@ -604,6 +655,55 @@ function handleServerRoute(ctx: SessionContext): boolean { return false; } +// handleRestoreBackupMock mirrors the backend's restore authorization order +// (handlers_backups.go): owner-or-admin → specific backup by id or latest present +// backup else 404 no_backup → non-admin former-owner match → stopped gate else +// 409 not_stopped → 202. +async function handleRestoreBackupMock(ctx: SessionContext, serverInfo: MockServer): Promise { + if (!canManage(ctx.account, serverInfo)) { + sendError(ctx.res, 403, "forbidden", "server is not owned by this account"); + return true; + } + let backupId: string | undefined; + try { + const body = await readJSON<{ backup_id?: string }>(ctx.req); + backupId = body.backup_id; + } catch (e) { + // Ignore if body is empty or unparsable + } + + let backup: any = null; + if (backupId) { + backup = ctx.state.backups.find((b) => b.id === backupId && b.server_name === serverInfo.name && b.status === "present"); + if (!backup) { + sendError(ctx.res, 404, "no_backup", "no restorable backup exists for this server"); + return true; + } + } else { + const latest = ctx.state.backups + .filter((b) => b.server_name === serverInfo.name && b.status === "present") + .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))[0]; + if (!latest) { + sendError(ctx.res, 404, "no_backup", "no restorable backup exists for this server"); + return true; + } + backup = latest; + } + + // Non-admins may restore only a world they formerly owned (spec §466). + if (ctx.account.role !== "admin" && backup.former_owner !== ctx.account.id) { + sendError(ctx.res, 403, "forbidden", "not the former owner of this world"); + return true; + } + // The world PVC must be free — a running/starting server still holds it. + if (serverInfo.phase !== "Stopped") { + sendError(ctx.res, 409, "not_stopped", "stop the server before restoring a backup"); + return true; + } + sendJSON(ctx.res, 202, { name: serverInfo.name, status: "restoring", backup_id: backup.id }); + return true; +} + function accessFor(state: MockState, name: string): AccessState { let entry = state.access[name]; if (!entry) { diff --git a/panel/src/App.tsx b/panel/src/App.tsx index cf68007..40217f5 100644 --- a/panel/src/App.tsx +++ b/panel/src/App.tsx @@ -10,6 +10,7 @@ import { Dashboard } from "@/pages/Dashboard"; import { MyServers } from "@/pages/MyServers"; import { ServerConsole } from "@/pages/ServerConsole"; import { ServerPlayers } from "@/pages/ServerPlayers"; +import { ServerBackups } from "@/pages/ServerBackups"; import { Account } from "@/pages/Account"; import { ServerAdmin } from "@/pages/admin/ServerAdmin"; import { ImageAdmin } from "@/pages/admin/ImageAdmin"; @@ -43,6 +44,7 @@ export default function App() { } /> } /> } /> + } /> } /> {/* Admin-Side — admin-tier (server & content ops). diff --git a/panel/src/components/LogConsole.tsx b/panel/src/components/LogConsole.tsx index fa28e56..bfd92dc 100644 --- a/panel/src/components/LogConsole.tsx +++ b/panel/src/components/LogConsole.tsx @@ -72,9 +72,9 @@ export function LogConsole({ url }: { url: string }) { const jumpToLatest = useCallback(() => setPinned(true), []); return ( -
+
{/* Toolbar */} -
+
{status === "ended" && ( @@ -100,11 +100,11 @@ export function LogConsole({ url }: { url: string }) {
{/* Viewport */} -
+
{lines.length === 0 ? (

diff --git a/panel/src/components/ui/dialog.tsx b/panel/src/components/ui/dialog.tsx index d472469..da62705 100644 --- a/panel/src/components/ui/dialog.tsx +++ b/panel/src/components/ui/dialog.tsx @@ -25,8 +25,12 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; export const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => { + React.ComponentPropsWithoutRef & { + /** Hide the corner ✕. Use when a dialog must not be dismissed mid-operation + * (pair with an onOpenChange guard) so the close affordance isn't left inert. */ + hideClose?: boolean; + } +>(({ className, children, hideClose, ...props }, ref) => { const { t } = useTranslation("common"); return ( @@ -40,10 +44,12 @@ export const DialogContent = React.forwardRef< {...props} > {children} - - - {t("close_sr")} - + {!hideClose && ( + + + {t("close_sr")} + + )} ); diff --git a/panel/src/i18n/index.ts b/panel/src/i18n/index.ts index 0b60d97..b396ef4 100644 --- a/panel/src/i18n/index.ts +++ b/panel/src/i18n/index.ts @@ -10,6 +10,7 @@ import enAdmin from "./resources/en-US/admin.json"; import enOps from "./resources/en-US/ops.json"; import enErrors from "./resources/en-US/errors.json"; import enNavigation from "./resources/en-US/navigation.json"; +import enBackups from "./resources/en-US/backups.json"; import zhCommon from "./resources/zh-CN/common.json"; import zhAuth from "./resources/zh-CN/auth.json"; import zhDashboard from "./resources/zh-CN/dashboard.json"; @@ -19,6 +20,7 @@ import zhAdmin from "./resources/zh-CN/admin.json"; import zhOps from "./resources/zh-CN/ops.json"; import zhErrors from "./resources/zh-CN/errors.json"; import zhNavigation from "./resources/zh-CN/navigation.json"; +import zhBackups from "./resources/zh-CN/backups.json"; i18next .use(LanguageDetector) @@ -35,6 +37,7 @@ i18next ops: enOps, errors: enErrors, navigation: enNavigation, + backups: enBackups, }, "zh-CN": { common: zhCommon, @@ -46,6 +49,7 @@ i18next ops: zhOps, errors: zhErrors, navigation: zhNavigation, + backups: zhBackups, }, }, fallbackLng: "en-US", diff --git a/panel/src/i18n/resources/en-US/backups.json b/panel/src/i18n/resources/en-US/backups.json new file mode 100644 index 0000000..77baefc --- /dev/null +++ b/panel/src/i18n/resources/en-US/backups.json @@ -0,0 +1,36 @@ +{ + "title": "Backups & restore", + "subtitle": "A backup is saved automatically when a world 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.", + "latest_title": "Latest backup", + "latest_note": "Default restore target. You can also select and restore an older backup from the history below.", + "history_title": "Backup history", + "history_note": "Historical backups can be used for restore before they expire. They are automatically cleaned up when they expire.", + "reason_inactive": "Idle archive", + "reason_manual": "Manual backup", + "reason_label": "Reason: {{reason}}", + "expires_in": "Expires {{when}}", + "expired": "Expired", + "former_owner": "Former owner: {{owner}}", + "empty_title": "No backups for this server", + "empty_hint": "A backup is saved only when a world is archived after long inactivity.", + "restore_btn": "Restore this backup", + "restore_btn_short": "Restore", + "restore_confirm": "Restoring a backup will stop the server (all online players will be disconnected) and completely overwrite the current world with this backup. This operation is irreversible. Do you want to continue?", + "restore_confirm_yes": "Confirm restore", + "cancel": "Cancel", + "restore_started": "Restore started — the world is being rebuilt from this backup. Wake the server once it finishes to see the restored world.", + "restore_started_short": "Restore started", + "stopping": "Stopping the server…", + "restoring": "Restoring…", + "stop_timeout": "The server did not stop in time. Close this window and try again shortly.", + "expired_cannot_restore": "This backup has expired and can no longer be restored.", + "col_created": "Backup Time", + "col_size": "Size", + "col_reason": "Type / Reason", + "col_expires": "Expires", + "col_owner": "Former Owner", + "col_actions": "Actions" +} diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json index 161a923..d4730b2 100644 --- a/panel/src/i18n/resources/en-US/errors.json +++ b/panel/src/i18n/resources/en-US/errors.json @@ -14,6 +14,9 @@ "cooldown": "Wake is cooling down — try again shortly.", "not_running": "The server isn't running — wake it before managing access.", "console_unavailable": "Can't reach the server console right now — try again shortly.", + "no_backup": "There's no restorable backup for this server yet.", + "not_stopped": "Stop the server completely before restoring — a restore overwrites the live world volume.", + "restore_unavailable": "Restore isn't available right now — try again later.", "session_expired": "Your session expired — please sign in again.", "forbidden": "You are not allowed to do that.", "generic": "Something went wrong." diff --git a/panel/src/i18n/resources/en-US/servers.json b/panel/src/i18n/resources/en-US/servers.json index cf7232d..8b7ecf4 100644 --- a/panel/src/i18n/resources/en-US/servers.json +++ b/panel/src/i18n/resources/en-US/servers.json @@ -41,6 +41,8 @@ "players_title": "Player management", "players_link_title": "Player management", "players_link_desc": "Manage the whitelist, online players, and bans.", + "backups_link_title": "Backups & restore", + "backups_link_desc": "View and restore world backups for this server.", "players_back_to_console": "Back to console", "players_not_yours_title": "Not your server", "players_not_yours_body": "Only the owner or an admin can manage this server's players.", diff --git a/panel/src/i18n/resources/zh-CN/backups.json b/panel/src/i18n/resources/zh-CN/backups.json new file mode 100644 index 0000000..fd76574 --- /dev/null +++ b/panel/src/i18n/resources/zh-CN/backups.json @@ -0,0 +1,36 @@ +{ + "title": "备份与恢复", + "subtitle": "服务器长期闲置并被自动回收前,系统会自动创建备份。可以使用备份随时恢复世界,恢复前需要先停止服务器。", + "back_to_console": "返回控制台", + "not_yours_title": "无权访问备份", + "not_yours_body": "只有所有者或管理员才能查看并恢复该服务器的备份。", + "latest_title": "最新备份", + "latest_note": "默认的恢复目标。你也可以从下方的历史备份中选择更早的备份进行恢复。", + "history_title": "历史备份", + "history_note": "历史备份在过期前均可用于恢复。到期后系统会自动清理,无需手动删除或管理。", + "reason_inactive": "闲置自动回收", + "reason_manual": "手动备份", + "reason_label": "原因:{{reason}}", + "expires_in": "{{when}}过期", + "expired": "已过期", + "former_owner": "原世界所有者:{{owner}}", + "empty_title": "暂无备份", + "empty_hint": "仅在服务器长期闲置被系统自动回收时才会生成备份。", + "restore_btn": "恢复此备份", + "restore_btn_short": "恢复", + "restore_confirm": "恢复备份将停止服务器(所有在线玩家将被断开),并用该备份完全覆盖当前世界的全部数据。此操作不可逆,请确认是否继续?", + "restore_confirm_yes": "确认恢复", + "cancel": "取消", + "restore_started": "已开始恢复备份——正在用此备份重建世界。完成后启动服务器即可看到恢复后的世界。", + "restore_started_short": "已启动恢复", + "stopping": "正在停止服务器……", + "restoring": "正在恢复备份……", + "stop_timeout": "服务器停止超时。请关闭此窗口,稍后重试。", + "expired_cannot_restore": "此备份已过期,无法恢复。", + "col_created": "创建时间", + "col_size": "大小", + "col_reason": "类型 / 原因", + "col_expires": "过期时间", + "col_owner": "原所有者", + "col_actions": "操作" +} diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json index d7667ed..01e2a1e 100644 --- a/panel/src/i18n/resources/zh-CN/errors.json +++ b/panel/src/i18n/resources/zh-CN/errors.json @@ -14,6 +14,9 @@ "cooldown": "启动冷却中——请稍后再试。", "not_running": "服务器未在运行——请先唤醒它再管理访问权限。", "console_unavailable": "暂时无法连接服务器控制台,请稍后重试。", + "no_backup": "这台服务器暂时没有可回档的备份。", + "not_stopped": "回档会覆盖世界的实时存储卷,请先把服务器完全停止再回档。", + "restore_unavailable": "回档功能当前不可用,请稍后再试。", "session_expired": "会话已过期——请重新登录。", "forbidden": "你无权执行此操作。", "generic": "出了点问题,请稍后重试。" diff --git a/panel/src/i18n/resources/zh-CN/servers.json b/panel/src/i18n/resources/zh-CN/servers.json index 0826760..da2132d 100644 --- a/panel/src/i18n/resources/zh-CN/servers.json +++ b/panel/src/i18n/resources/zh-CN/servers.json @@ -41,6 +41,8 @@ "players_title": "玩家管理", "players_link_title": "玩家管理", "players_link_desc": "管理白名单、在线玩家与封禁。", + "backups_link_title": "备份与恢复", + "backups_link_desc": "查看并恢复该服务器的世界备份。", "players_back_to_console": "返回控制台", "players_not_yours_title": "这不是你的服务器", "players_not_yours_body": "只有所有者或管理员才能管理此服务器的玩家。", diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 0042f15..7f2e0e9 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -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"); diff --git a/panel/src/lib/format.test.ts b/panel/src/lib/format.test.ts new file mode 100644 index 0000000..bf5a293 --- /dev/null +++ b/panel/src/lib/format.test.ts @@ -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); + }); +}); diff --git a/panel/src/lib/format.ts b/panel/src/lib/format.ts new file mode 100644 index 0000000..78be562 --- /dev/null +++ b/panel/src/lib/format.ts @@ -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; +} diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index da71041..2ff319d 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -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; diff --git a/panel/src/pages/ServerBackups.tsx b/panel/src/pages/ServerBackups.tsx new file mode 100644 index 0000000..90febcb --- /dev/null +++ b/panel/src/pages/ServerBackups.tsx @@ -0,0 +1,486 @@ +import { useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { + ArrowLeft, + Archive, + CheckCircle2, + Clock, + HardDrive, + Loader2, + RotateCcw, + ShieldX, + UserMinus, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { PhaseBadge } from "@/components/PhaseBadge"; +import { Loading, ErrorState, EmptyState } from "@/components/States"; +import { api, humanizeError } from "@/lib/api"; +import { useAsync } from "@/lib/hooks"; +import { useTier } from "@/lib/tier"; +import { formatBytes, formatRelative, formatAbsolute, isExpired } from "@/lib/format"; +import { cn } from "@/lib/utils"; +import type { BackupView } from "@/lib/types"; + +/** NotYours mirrors ServerPlayers: viewing and restoring backups is owner-or-admin + * gated on the backend, but this is a real route anyone can type, so it says so + * plainly and offers a way back rather than rendering blank. */ +function NotYours() { + const { t } = useTranslation("backups"); + return ( +

+ +
+

{t("not_yours_title")}

+

{t("not_yours_body")}

+
+ + {t("my_servers_breadcrumb", { ns: "servers" })} + +
+ ); +} + +/** LatestBackupCard renders the most-recent backup as the restore card — the one a + * restore actually recovers (the list is created_at-descending and the backend's + * LatestBackup selects the same present row), with the restore action beneath. Older + * archives are shown separately as a compact, read-only history (HistoryRow): a restore + * ALWAYS recovers this latest one, so giving an older backup an action card of its own + * would falsely imply you could restore (or delete) it — the backend offers neither. + * `showOwner` surfaces the former owner (admins list every world's backups; a user only + * ever sees their own). */ +function BackupRow({ + b, + isLatest, + now, + locale, + showOwner, + serverName, + onReloadStatus, +}: { + b: BackupView; + isLatest: boolean; + now: number; + locale: string; + showOwner?: boolean; + serverName: string; + onReloadStatus: () => void; +}) { + const { t } = useTranslation("backups"); + const expired = isExpired(b.expires_at, now); + const reasonLabel = + b.reason === "inactive_15d" + ? t("reason_inactive") + : b.reason === "manual" + ? t("reason_manual") + : t("reason_label", { reason: b.reason }); + + return ( + + +
+
+ + {formatRelative(b.created_at, now, locale)} + + {isLatest && ( + + {t("latest_title")} + + )} +
+
+ + {reasonLabel} +
+
+ + + + + {formatBytes(b.size_bytes)} + + + + + {expired ? t("expired") : t("expires_in", { when: formatRelative(b.expires_at, now, locale) })} + + + {showOwner && ( + + {b.former_owner ? ( + + + {b.former_owner} + + ) : ( + — + )} + + )} + + {!expired ? ( + + ) : ( + + {t("expired")} + + )} + + + ); +} + + + +/** RestoreControls is the restore ACTION, living only on the latest backup card. + * Restore is the panel's one irreversible operation, so it hides behind a single + * button that opens a confirm dialog. The dialog states the full cost up front — the + * server is stopped (online players drop) and the current world is overwritten by + * THIS exact backup (named by relative + absolute time), and it can't be undone — and + * then, on confirm, runs the whole chain itself: stop → wait for Stopped → restore. + * The backend refuses a restore unless the world volume is free (409 not_stopped), so + * stopping here means the user never has to detour to the console and come back. There + * is deliberately no type-the-name step: the friction that matters is owning the + * server plus consciously confirming a player-kicking, world-overwriting act. + * + * The stop-and-wait is a bounded async loop (not an effect): after api.stop it polls + * status until Stopped is observed, giving up after ~60s with a retryable timeout — so + * restore only fires once the volume is provably free. While the chain runs the dialog + * is locked (no ✕, no dismiss) so a mid-flight close can't strand it. A 202 is + * terminal: the dialog closes and the card shows a "restore started" note instead of + * re-offering the trigger, so a second restore Job can't race the first. */ +const POLL_MS = 2500; +const MAX_POLLS = 24; // ~60s ceiling before we stop waiting for Stopped +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function RestoreControls({ + serverName, + backup, + now, + locale, + onReloadStatus, + buttonVariant = "destructive", + buttonSize = "sm", + layout = "card", +}: { + serverName: string; + backup: BackupView; + now: number; + locale: string; + onReloadStatus: () => void; + buttonVariant?: "destructive" | "outline" | "ghost" | "default"; + buttonSize?: "default" | "sm" | "lg" | "icon"; + layout?: "card" | "row"; +}) { + const { t } = useTranslation("backups"); + const [open, setOpen] = useState(false); + // submitting flips synchronously on click (before the first await) so a double-click + // on this destructive confirm can't launch two concurrent restore chains; step only + // drives which progress label shows once the chain reaches a concrete stage. + const [submitting, setSubmitting] = useState(false); + const [step, setStep] = useState<"stopping" | "restoring" | null>(null); + const [done, setDone] = useState(false); // restore enqueued — terminal + const [error, setError] = useState(null); + + if (isExpired(backup.expires_at, now)) { + if (layout === "row") return null; + return ( +

+ {t("expired_cannot_restore")} +

+ ); + } + + if (done) { + if (layout === "row") { + return ( +
+ + {t("restore_started_short")} +
+ ); + } + return ( +
+ + {t("restore_started")} +
+ ); + } + + // Poll until the world volume is provably free. Returns true once Stopped is + // observed, false after the ceiling — restore MUST NOT proceed on a false. + async function waitForStopped(): Promise { + for (let i = 0; i < MAX_POLLS; i++) { + await sleep(POLL_MS); + const s = await api.status(serverName); + if (s.phase === "Stopped") return true; + } + return false; + } + + // The whole irreversible chain behind the one confirm. Re-checks live status first + // (a public server may have autowoken), stops + waits only if needed, then restores. + async function confirmRestore() { + setSubmitting(true); // first + synchronous: disables the confirm before any await + setError(null); + try { + const s0 = await api.status(serverName); + if (s0.phase !== "Stopped") { + setStep("stopping"); + await api.stop(serverName); + if (!(await waitForStopped())) { + setError(t("stop_timeout")); + return; + } + } + setStep("restoring"); + await api.restoreBackup(serverName, backup.id); + setDone(true); + setOpen(false); + } catch (e) { + setError(humanizeError(e)); + } finally { + setSubmitting(false); + setStep(null); + onReloadStatus(); // resync the header phase badge after stop/restore + } + } + + const trigger = ( + + + + ); + + const dialogContent = ( + + + {t("restore_btn")} + + {t("restore_confirm", { + relative: formatRelative(backup.created_at, now, locale), + absolute: formatAbsolute(backup.created_at, locale), + })} + + + + {step && ( +
+ + {step === "stopping" ? t("stopping") : t("restoring")} +
+ )} + {error &&

{error}

} + + + + + +
+ ); + + if (layout === "row") { + return ( + { + if (submitting) return; + if (next) setError(null); + setOpen(next); + }} + > + {trigger} + {dialogContent} + + ); + } + + return ( +
+ { + if (submitting) return; // locked while the stop→restore chain runs + if (next) setError(null); // fresh each open + setOpen(next); + }} + > + {trigger} + {dialogContent} + +
+ ); +} + +/** 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 the most + * recent one. 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. */ +export function ServerBackups() { + const { name = "" } = useParams(); + const { t, i18n } = useTranslation("backups"); + const { isAdmin, loading: tierLoading } = useTier(); + const statusQ = useAsync(() => api.status(name), [name]); + const mineQ = useAsync( + () => (isAdmin ? Promise.resolve([]) : api.myServers()), + [isAdmin, name], + ); + const backupsQ = useAsync(() => api.listBackups(), []); + + const back = ( + + {t("back_to_console")} + + ); + + if (statusQ.loading && !statusQ.data) { + return ( + <> + {back} + + + ); + } + if (statusQ.error) { + return ( + <> + {back} + + + ); + } + if (!statusQ.data) return back; + + // 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 + // at someone who may not own it; if that read itself failed, break to a retry so a + // real owner never fails closed to NotYours on a transient blip. + const ownershipPending = tierLoading || (!isAdmin && mineQ.data === null && !mineQ.error); + const owned = isAdmin || (mineQ.data ?? []).some((s) => s.name === name && s.owned === true); + + const now = Date.now(); + const locale = i18n.language; + // The global list, narrowed to this server. Already created_at-descending from the + // API, but re-sorted defensively so all[0] is unambiguously the restore target. + const all = (backupsQ.data ?? []) + .filter((b) => b.server_name === name) + .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)); + + const header = ( +
+
+ +
+

+ {statusQ.data.displayName || statusQ.data.name} +

+

{t("title")}

+
+
+ +
+ ); + + return ( + <> + {back} + {header} + {ownershipPending ? ( + + ) : mineQ.error ? ( + + ) : !owned ? ( + + ) : ( +
+

{t("subtitle")}

+ {backupsQ.loading && !backupsQ.data ? ( + + ) : backupsQ.error ? ( + + ) : all.length === 0 ? ( + + ) : ( + <> + +
+ + + + + + + {isAdmin && } + + + + + {all.map((b, idx) => ( + + ))} + +
{t("col_created")}{t("col_size")}{t("col_expires")}{t("col_owner")}{t("col_actions")}
+
+
+ + + + {t("history_note")} + + + + )} +
+ )} + + ); +} diff --git a/panel/src/pages/ServerConsole.tsx b/panel/src/pages/ServerConsole.tsx index 5b01547..d1ef2cf 100644 --- a/panel/src/pages/ServerConsole.tsx +++ b/panel/src/pages/ServerConsole.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useCallback, useLayoutEffect, type KeyboardEvent } from "react"; import { Link, useParams } from "react-router-dom"; -import { ArrowLeft, Terminal, Moon, ShieldAlert, HelpCircle, Loader2, Users, ChevronRight, type LucideIcon } from "lucide-react"; +import { ArrowLeft, Terminal, Moon, ShieldAlert, HelpCircle, Loader2, Users, Archive, ChevronRight, type LucideIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -186,13 +186,15 @@ export function ServerConsole() { const streamable = data?.phase === "Running" || data?.phase === "Starting"; return ( - <> - - {t("my_servers_breadcrumb")} - +
+
+ + {t("my_servers_breadcrumb")} + +
{loading && !data ? ( @@ -200,7 +202,7 @@ export function ServerConsole() { ) : data ? ( <> -
+
@@ -232,47 +234,72 @@ export function ServerConsole() {
- - - - {t("console_card_title")} - - - - {!streamable ? ( - - ) : cfg ? ( - <> - {/* key on name so navigating between servers remounts the viewport - (fresh stream + scroll state). useLogStream also resets its - controller when the url changes, so this is belt-and-suspenders. */} - - {data.phase === "Running" && ( - +
+ {/* Left/Main column: Console */} +
+ + + + {t("console_card_title")} + + + + {!streamable ? ( +
+ +
+ ) : cfg ? ( +
+ {/* key on name so navigating between servers remounts the viewport + (fresh stream + scroll state). useLogStream also resets its + controller when the url changes, so this is belt-and-suspenders. */} + + {data.phase === "Running" && ( + + )} +
+ ) : ( + )} - - ) : ( - - )} -
-
- - {/* Player management lives on its own subpage (whitelist / online / bans), - not crammed under the console. This is the doorway to it; the page - itself owns the ownership + readiness gating. */} - - -
-

{t("players_link_title")}

-

{t("players_link_desc")}

+ +
- - + + {/* Right/Sidebar column: Navigation */} +
+ {/* Player management lives on its own subpage (whitelist / online / bans), + not crammed under the console. This is the doorway to it; the page + itself owns the ownership + readiness gating. */} + + +
+

{t("players_link_title")}

+

{t("players_link_desc")}

+
+ + + + {/* Backups & restore — its own subpage, doorway mirrors the players card. + The page owns its ownership gating and works while the server sleeps (a + restore in fact needs it stopped, so this doorway shows at every phase). */} + + +
+

{t("backups_link_title")}

+

{t("backups_link_desc")}

+
+ + +
+
) : null} - +
); }