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
+1134 -33

No files matched your search

+14
View File
@@ -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
+52 -16
View File
@@ -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,7 +87,38 @@ func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
return
}
backup, err := a.Repo.LatestBackup(r.Context(), name)
// 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",
@@ -93,6 +128,7 @@ func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err)
return
}
}
// A non-admin may restore only a world they formerly owned (spec §466). Without
// this a fresh claimant of a released server could resurrect the previous
+123
View File
@@ -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())
}
})
}
+15
View File
@@ -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).
+4
View File
@@ -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
+102 -2
View File
@@ -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<string, AccessState>;
// 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<boolean> {
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<boolean> {
await verifyLinkRoute(ctx);
return true;
default:
return handleServerRoute(ctx);
return await handleServerRoute(ctx);
}
}
@@ -542,7 +590,7 @@ async function verifyLinkRoute(ctx: SessionContext): Promise<void> {
sendJSON(ctx.res, 200, { linked: true, mc_uuid: MC_UUID });
}
function handleServerRoute(ctx: SessionContext): boolean {
async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
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<boolean> {
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) {
+2
View File
@@ -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() {
<Route path="servers" element={<MyServers />} />
<Route path="servers/:name" element={<ServerConsole />} />
<Route path="servers/:name/players" element={<ServerPlayers />} />
<Route path="servers/:name/backups" element={<ServerBackups />} />
<Route path="account" element={<Account />} />
{/* Admin-Side — admin-tier (server & content ops).
+4 -4
View File
@@ -72,9 +72,9 @@ export function LogConsole({ url }: { url: string }) {
const jumpToLatest = useCallback(() => setPinned(true), []);
return (
<div className="overflow-hidden rounded-md border border-border bg-zinc-950">
<div className="overflow-hidden rounded-md border border-border bg-zinc-950 flex-1 flex flex-col min-h-0">
{/* Toolbar */}
<div className="flex items-center justify-between gap-2 border-b border-zinc-800 bg-zinc-900/60 px-3 py-2">
<div className="flex items-center justify-between gap-2 border-b border-zinc-800 bg-zinc-900/60 px-3 py-2 shrink-0">
<StatusIndicator status={status} />
<div className="flex items-center gap-1.5">
{status === "ended" && (
@@ -100,11 +100,11 @@ export function LogConsole({ url }: { url: string }) {
</div>
{/* Viewport */}
<div className="relative">
<div className="relative flex-1 min-h-0">
<div
ref={scrollRef}
onScroll={onScroll}
className="h-[28rem] overflow-y-auto px-3 py-2 font-mono text-xs leading-relaxed"
className="h-full overflow-y-auto px-3 py-2 font-mono text-xs leading-relaxed"
>
{lines.length === 0 ? (
<p className="select-none py-8 text-center text-zinc-600">
+8 -2
View File
@@ -25,8 +25,12 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
export const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => {
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
/** 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 (
<DialogPrimitive.Portal>
@@ -40,10 +44,12 @@ export const DialogContent = React.forwardRef<
{...props}
>
{children}
{!hideClose && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">{t("close_sr")}</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
);
+4
View File
@@ -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",
@@ -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"
}
@@ -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."
@@ -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.",
@@ -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": "操作"
}
@@ -14,6 +14,9 @@
"cooldown": "启动冷却中——请稍后再试。",
"not_running": "服务器未在运行——请先唤醒它再管理访问权限。",
"console_unavailable": "暂时无法连接服务器控制台,请稍后重试。",
"no_backup": "这台服务器暂时没有可回档的备份。",
"not_stopped": "回档会覆盖世界的实时存储卷,请先把服务器完全停止再回档。",
"restore_unavailable": "回档功能当前不可用,请稍后再试。",
"session_expired": "会话已过期——请重新登录。",
"forbidden": "你无权执行此操作。",
"generic": "出了点问题,请稍后重试。"
@@ -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": "只有所有者或管理员才能管理此服务器的玩家。",
+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;
+486
View File
@@ -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 (
<div className="mx-auto flex max-w-md flex-col items-center justify-center gap-3 py-24 text-center">
<ShieldX className="h-8 w-8 text-destructive" />
<div>
<p className="font-medium">{t("not_yours_title")}</p>
<p className="mt-1 text-sm text-muted-foreground">{t("not_yours_body")}</p>
</div>
<Link to="/servers" className="text-sm font-medium text-primary hover:underline">
{t("my_servers_breadcrumb", { ns: "servers" })}
</Link>
</div>
);
}
/** 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 (
<tr className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 whitespace-nowrap">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span
className="font-medium text-foreground text-sm"
title={formatAbsolute(b.created_at, locale)}
>
{formatRelative(b.created_at, now, locale)}
</span>
{isLatest && (
<span className="inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-500 ring-1 ring-inset ring-emerald-500/20">
{t("latest_title")}
</span>
)}
</div>
<div className="text-[11px] text-muted-foreground flex items-center gap-1.5">
<Clock className="h-3 w-3 text-muted-foreground/60 shrink-0" />
<span>{reasonLabel}</span>
</div>
</div>
</td>
<td className="px-4 py-3.5 whitespace-nowrap text-muted-foreground">
<span className="flex items-center gap-1.5">
<HardDrive className="h-3.5 w-3.5 shrink-0" />
{formatBytes(b.size_bytes)}
</span>
</td>
<td className="px-4 py-3.5 whitespace-nowrap">
<span className={cn("text-muted-foreground", expired && "font-medium text-destructive")}>
{expired ? t("expired") : t("expires_in", { when: formatRelative(b.expires_at, now, locale) })}
</span>
</td>
{showOwner && (
<td className="px-4 py-3.5 whitespace-nowrap text-muted-foreground">
{b.former_owner ? (
<span className="flex items-center gap-1">
<UserMinus className="h-3 w-3 shrink-0" />
{b.former_owner}
</span>
) : (
<span className="text-muted-foreground/30">—</span>
)}
</td>
)}
<td className="px-4 py-3.5 whitespace-nowrap text-right">
{!expired ? (
<RestoreControls
serverName={serverName}
backup={b}
now={now}
locale={locale}
onReloadStatus={onReloadStatus}
buttonVariant={isLatest ? "destructive" : "outline"}
buttonSize="sm"
layout="row"
/>
) : (
<span className="text-xs text-muted-foreground/40 font-medium px-3 py-1.5">
{t("expired")}
</span>
)}
</td>
</tr>
);
}
/** 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<string | null>(null);
if (isExpired(backup.expires_at, now)) {
if (layout === "row") return null;
return (
<p className="mt-4 border-t border-primary/20 pt-4 text-xs text-destructive">
{t("expired_cannot_restore")}
</p>
);
}
if (done) {
if (layout === "row") {
return (
<div className="flex items-center gap-1.5 text-xs text-emerald-500 font-medium">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
<span>{t("restore_started_short")}</span>
</div>
);
}
return (
<div className="mt-4 flex items-start gap-2 border-t border-primary/20 pt-4 text-sm text-emerald-500">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
<span>{t("restore_started")}</span>
</div>
);
}
// 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<boolean> {
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 = (
<DialogTrigger asChild>
<Button variant={buttonVariant} size={buttonSize}>
<RotateCcw className="h-4 w-4" /> {t(layout === "row" ? "restore_btn_short" : "restore_btn")}
</Button>
</DialogTrigger>
);
const dialogContent = (
<DialogContent hideClose={submitting}>
<DialogHeader>
<DialogTitle>{t("restore_btn")}</DialogTitle>
<DialogDescription>
{t("restore_confirm", {
relative: formatRelative(backup.created_at, now, locale),
absolute: formatAbsolute(backup.created_at, locale),
})}
</DialogDescription>
</DialogHeader>
{step && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{step === "stopping" ? t("stopping") : t("restoring")}
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={submitting}>
{t("cancel")}
</Button>
<Button variant="destructive" onClick={confirmRestore} disabled={submitting}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RotateCcw className="h-4 w-4" />
)}
{t("restore_confirm_yes")}
</Button>
</DialogFooter>
</DialogContent>
);
if (layout === "row") {
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (submitting) return;
if (next) setError(null);
setOpen(next);
}}
>
{trigger}
{dialogContent}
</Dialog>
);
}
return (
<div className="mt-4 border-t border-primary/20 pt-4">
<Dialog
open={open}
onOpenChange={(next) => {
if (submitting) return; // locked while the stop→restore chain runs
if (next) setError(null); // fresh each open
setOpen(next);
}}
>
{trigger}
{dialogContent}
</Dialog>
</div>
);
}
/** 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 = (
<Link
to={`/servers/${name}`}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" /> {t("back_to_console")}
</Link>
);
if (statusQ.loading && !statusQ.data) {
return (
<>
{back}
<Loading />
</>
);
}
if (statusQ.error) {
return (
<>
{back}
<ErrorState error={statusQ.error} onRetry={statusQ.reload} />
</>
);
}
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 = (
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Archive className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{statusQ.data.displayName || statusQ.data.name}
</h1>
<p className="text-sm text-muted-foreground">{t("title")}</p>
</div>
</div>
<PhaseBadge phase={statusQ.data.phase} />
</div>
);
return (
<>
{back}
{header}
{ownershipPending ? (
<Loading />
) : mineQ.error ? (
<ErrorState error={mineQ.error} onRetry={mineQ.reload} />
) : !owned ? (
<NotYours />
) : (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
{backupsQ.loading && !backupsQ.data ? (
<Loading />
) : backupsQ.error ? (
<ErrorState error={backupsQ.error} onRetry={backupsQ.reload} />
) : all.length === 0 ? (
<EmptyState title={t("empty_title")} hint={t("empty_hint")} />
) : (
<>
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-border bg-muted/40 text-left text-[11px] uppercase tracking-wider text-muted-foreground">
<th className="px-4 py-2.5 font-medium">{t("col_created")}</th>
<th className="px-4 py-2.5 font-medium">{t("col_size")}</th>
<th className="px-4 py-2.5 font-medium">{t("col_expires")}</th>
{isAdmin && <th className="px-4 py-2.5 font-medium">{t("col_owner")}</th>}
<th className="px-4 py-2.5 text-right font-medium">{t("col_actions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{all.map((b, idx) => (
<BackupRow
key={b.id}
b={b}
isLatest={idx === 0}
now={now}
locale={locale}
showOwner={isAdmin}
serverName={name}
onReloadStatus={statusQ.reload}
/>
))}
</tbody>
</table>
</div>
</Card>
<Card className="border-dashed bg-transparent shadow-none">
<CardContent className="p-4 text-xs text-muted-foreground">
{t("history_note")}
</CardContent>
</Card>
</>
)}
</div>
)}
</>
);
}
+36 -9
View File
@@ -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 (
<>
<div className="flex flex-col lg:h-[calc(100vh-3.5rem)] lg:min-h-[35rem] gap-4 min-h-0">
<div>
<Link
to="/servers"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" /> {t("my_servers_breadcrumb")}
</Link>
</div>
{loading && !data ? (
<Loading />
@@ -200,7 +202,7 @@ export function ServerConsole() {
<ErrorState error={error} onRetry={reload} />
) : data ? (
<>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center justify-between gap-3 shrink-0">
<div className="flex items-center gap-3">
<Terminal className="h-6 w-6 text-primary" />
<div>
@@ -232,17 +234,22 @@ export function ServerConsole() {
</div>
</div>
<Card>
<CardHeader>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4 flex-1 lg:min-h-0 min-h-0">
{/* Left/Main column: Console */}
<div className="lg:col-span-3 flex flex-col lg:min-h-0 min-h-0 h-full">
<Card className="flex flex-col flex-1 lg:min-h-0 min-h-0">
<CardHeader className="py-3.5 shrink-0">
<CardTitle className="flex items-center gap-2 text-base">
<Terminal className="h-4 w-4" /> {t("console_card_title")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<CardContent className="space-y-3 flex-1 flex flex-col lg:min-h-0 min-h-0 pb-4">
{!streamable ? (
<div className="flex-1 flex flex-col justify-center">
<NotStreaming phase={data.phase} />
</div>
) : cfg ? (
<>
<div className="flex-1 flex flex-col lg:min-h-0 min-h-0 gap-3">
{/* 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. */}
@@ -250,13 +257,16 @@ export function ServerConsole() {
{data.phase === "Running" && (
<CommandInput name={name} />
)}
</>
</div>
) : (
<Loading />
)}
</CardContent>
</Card>
</div>
{/* Right/Sidebar column: Navigation */}
<div className="flex flex-col gap-4 lg:col-span-1 shrink-0">
{/* 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. */}
@@ -271,8 +281,25 @@ export function ServerConsole() {
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</Link>
{/* 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). */}
<Link
to={`/servers/${name}/backups`}
className="group flex items-center gap-3 rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent"
>
<Archive className="h-5 w-5 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("backups_link_title")}</p>
<p className="text-sm text-muted-foreground">{t("backups_link_desc")}</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</Link>
</div>
</div>
</>
) : null}
</>
</div>
);
}