import { useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import {
AlertTriangle,
Archive,
CheckCircle2,
Clock,
HardDrive,
Loader2,
RotateCcw,
ShieldCheck,
ShieldX,
UserMinus,
XCircle,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { BackLink } from "@/components/BackLink";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import { MessageLine, InlineError } from "@/components/MessageLine";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { PhaseBadge } from "@/components/PhaseBadge";
import { Loading, ErrorState, EmptyState, NotYours } from "@/components/States";
import { PageHeader } from "@/components/PageHeader";
import { Pagination } from "@/components/Pagination";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
import { canManage, ownershipPending } from "@/lib/ownership";
import { formatBytes, formatRelative, formatAbsolute, isExpired } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { BackupView, ServerJob } from "@/lib/types";
const BACKUP_PAGE_SIZE = 20;
const CELL = "whitespace-nowrap md:px-4 md:py-3.5";
/** BackupRow is one backup in the table, with its own restore action. `isLatest`
* marks the row a restore with no pick recovers: the newest one that is not
* corrupt, which is what the backend's LatestBackup selects. Under the reason it
* shows what the reaper's read-back found — corrupt (restore refused), verified,
* or entries the archive could not hold. `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 === "released"
? t("reason_released")
: b.reason === "manual"
? t("reason_manual")
: b.reason === "pre_restore"
? t("reason_pre_restore")
: b.reason === "scheduled"
? t("reason_scheduled")
: t("reason_label", { reason: b.reason });
// Below md the row stops being a table row: the when/why block takes the full
// width, size and expiry follow on one line and the restore button sits at the
// right, so a phone never has to scroll sideways to reach it.
return (
);
}
/** IntegrityNote is what the reaper's read-back says about one archive: corrupt
* (it no longer matches what was written, so it cannot be restored), when it
* was last read back intact, and how many entries of the world it could not
* hold. A backup not read back yet shows nothing, as before. */
function IntegrityNote({ b, now, locale }: { b: BackupView; now: number; locale: string }) {
const { t } = useTranslation("backups");
const skipped = b.skipped_entries ?? 0;
if (!b.corrupt && !b.verified_at && skipped === 0) return null;
return (
);
}
/** JobStateBadge renders one async Job's state (backup/restore Job history). The
* state vocabulary is the API's ("running" | "succeeded" | "failed"); anything
* unknown is shown verbatim rather than hidden. */
function JobStateBadge({ state }: { state: string }) {
const { t } = useTranslation("backups");
if (state === "running") {
return (
{t("job_running")}
);
}
if (state === "succeeded") {
return (
{t("job_succeeded")}
);
}
if (state === "failed") {
return (
{t("job_failed")}
);
}
return {state};
}
/** ChainNote says what became of the restore behind a safety snapshot (a backup
* job carrying then_restore). A snapshot that failed already reads as a failed
* job with its error, so this only covers the chain itself. */
function ChainNote({ job }: { job: ServerJob }) {
const { t } = useTranslation("backups");
if (job.state === "failed") return null;
if (job.then_restore === "pending") {
return (
{job.state === "running" ? t("chain_pending") : t("chain_starting")}
);
}
if (job.then_restore === "started") {
return {t("chain_started")};
}
if (job.then_restore === "abandoned") {
// Known codes get their own wording; one this panel predates falls back to
// the backend's English.
const known = ["snapshot_failed", "not_configured", "server_gone", "server_started", "restore_busy"];
const reason = job.then_restore_reason;
const text =
reason && known.includes(reason)
? t(`chain_abandoned_${reason}`)
: job.message
? t("chain_abandoned_because", { reason: job.message })
: t("chain_abandoned");
return (
{text}
);
}
return null;
}
/** RestoreControls is the restore button on each backup row that can still be
* restored (the row renders a note in its place for a corrupt or expired one), and
* any of them may be picked, not only the newest. Restore overwrites the world, so
* the button opens a confirm dialog that states the full cost up front: the server is
* stopped (online players drop) and the current world is overwritten by THIS exact
* backup. It offers a safety snapshot, on by default: the backend first backs up the
* world as it is and restores only once that succeeded, which makes a wrong pick
* undoable. Turning it off brings back the irreversible wording. On confirm it 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 ~2 min 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 row shows a "restore started" note in place of
* the button, so a second restore Job can't race the first. */
const POLL_MS = 2500;
// ~2 min before we stop waiting for Stopped: with players online the operator warns
// them in game and holds the stop 30 s, then the pre-stop save and the pod's own
// shutdown save follow.
const MAX_POLLS = 48;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function RestoreControls({
serverName,
backup,
now,
locale,
onReloadStatus,
buttonVariant = "destructive",
}: {
serverName: string;
backup: BackupView;
now: number;
locale: string;
onReloadStatus: () => void;
buttonVariant?: "destructive" | "outline";
}) {
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);
// restore enqueued — terminal; "snapshot" when the backend backs the world up first
const [done, setDone] = useState<"snapshot" | "direct" | null>(null);
const [error, setError] = useState(null);
const [safety, setSafety] = useState(true);
if (done) {
return (
);
}
// Poll until the world volume is provably free. Returns true once Stopped is
// observed, false after the ceiling — restore MUST NOT proceed on a false.
async function waitForStopped(): Promise {
for (let i = 0; i < MAX_POLLS; i++) {
await sleep(POLL_MS);
const s = await api.status(serverName);
if (s.phase === "Stopped") return true;
}
return false;
}
// The whole irreversible chain behind the one confirm. Re-checks live status first
// (a public server may have autowoken), stops + waits only if needed, then restores.
async function confirmRestore() {
setSubmitting(true); // first + synchronous: disables the confirm before any await
setError(null);
try {
const s0 = await api.status(serverName);
if (s0.phase !== "Stopped") {
setStep("stopping");
await api.stop(serverName);
if (!(await waitForStopped())) {
setError(t("stop_timeout"));
return;
}
}
setStep("restoring");
const res = await api.restoreBackup(serverName, backup.id, safety);
setDone(res.safety_snapshot ? "snapshot" : "direct");
setOpen(false);
} catch (e) {
setError(humanizeError(e));
} finally {
setSubmitting(false);
setStep(null);
onReloadStatus(); // resync the header phase badge after stop/restore
}
}
const trigger = (
);
const dialogContent = (
{t("restore_btn")}
{t(safety ? "restore_confirm_safe" : "restore_confirm", {
relative: formatRelative(backup.created_at, now, locale),
absolute: formatAbsolute(backup.created_at, locale),
})}
{step && (
)}
setOpen(false)}
onConfirm={confirmRestore}
loading={submitting}
cancelLabel={t("cancel")}
confirmLabel={t(safety ? "restore_confirm_yes" : "restore_confirm_yes_unsafe")}
/>
);
return (
);
}
/** 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 any of them
* that has not expired. 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],
);
// Only this server's backups, a page at a time; the API sorts them newest first.
const [page, setPage] = useState(1);
useEffect(() => setPage(1), [name]);
const backupsQ = useAsync(
() => api.listBackups({ server: name, limit: BACKUP_PAGE_SIZE, offset: (page - 1) * BACKUP_PAGE_SIZE }),
[name, page],
{ keepPrevious: true },
);
// 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 pending = ownershipPending(tierLoading, isAdmin, mineQ.data, mineQ.error);
const owned = canManage(isAdmin, mineQ.data, name);
// The async world-operation history (the backup/restore Jobs behind every 202).
// Read only once the viewer is resolved as owner-or-admin (the route 403s
// otherwise); while anything is still running — or a safety snapshot still has
// its restore to start — it re-reads on an interval so the enqueue converges to
// succeeded/failed here instead of only in kubectl.
const jobsQ = useAsync(
() => (owned ? api.serverJobs(name) : Promise.resolve([])),
[name, owned],
);
useEffect(() => {
if (!(jobsQ.data ?? []).some((j) => j.state === "running" || j.then_restore === "pending")) return;
const id = setInterval(jobsQ.reload, 5000);
return () => clearInterval(id);
}, [jobsQ.data, jobsQ.reload]);
// A backup job that stops running has just added (or failed to add) a row, so
// the list is re-read then rather than only on the next visit.
const runningBackups = useRef>(new Set());
const reloadBackups = backupsQ.reload;
useEffect(() => {
const now = new Set(
(jobsQ.data ?? []).filter((j) => j.kind === "backup" && j.state === "running").map((j) => j.name),
);
const finished = [...runningBackups.current].some((n) => !now.has(n));
runningBackups.current = now;
if (finished) reloadBackups();
}, [jobsQ.data, reloadBackups]);
const [backingUp, setBackingUp] = useState(false);
const [backupMsg, setBackupMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
// Manual backup (POST /backup): the backend enforces the stopped gate, so the
// button only enables on Stopped and a raced 409 is surfaced in its own words.
async function handleBackupNow() {
if (backingUp) return;
setBackingUp(true);
setBackupMsg(null);
try {
await api.backupNow(name);
setBackupMsg({ kind: "success", text: t("backup_started") });
jobsQ.reload();
} catch (e: any) {
setBackupMsg({
kind: "error",
text: e && e.code === "not_stopped" ? t("backup_requires_stopped") : humanizeError(e),
});
} finally {
setBackingUp(false);
}
}
const back = (
);
if (statusQ.loading && !statusQ.data) {
return (
<>
{back}
>
);
}
if (statusQ.error) {
return (
<>
{back}
>
);
}
if (!statusQ.data) return back;
const now = Date.now();
const locale = i18n.language;
// This page of the server's backups. Already created_at-descending from the API,
// but re-sorted defensively; the newest backup that is not corrupt is the one a
// restore with no pick recovers, and it sits on the first page.
const all = [...(backupsQ.data?.backups ?? [])]
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
const total = backupsQ.data?.total ?? 0;
const latestID = page === 1 ? all.find((b) => !b.corrupt)?.id : undefined;
const header = (
{owned && (
)}
}
className="mb-6"
/>
);
return (
<>
{back}
{header}
{pending ? (
) : mineQ.error ? (
) : !owned ? (
) : (