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