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 (
{formatRelative(b.created_at, now, locale)} {isLatest && ( {t("latest_title")} )}
{reasonLabel}
{formatBytes(b.size_bytes)} {expired ? t("expired") : t("expires_in", { when: formatRelative(b.expires_at, now, locale) })} {showOwner && ( {b.former_owner ? ( {b.former_owner} ) : ( — )} )} {b.corrupt ? ( {t("corrupt_short")} ) : !expired ? ( ) : ( {t("expired")} )} ); } /** 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 (
{b.corrupt ? ( {t("corrupt_badge")} ) : ( b.verified_at && ( {t("verified_at", { when: formatRelative(b.verified_at, now, locale) })} ) )} {skipped > 0 && ( {t("skipped_entries", { count: skipped })} )}
); } /** 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 (
{t(done === "snapshot" ? "restore_snapshot_started_short" : "restore_started_short")}
); } // 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 && (
{step === "stopping" ? t("stopping") : t("restoring")}
)} setOpen(false)} onConfirm={confirmRestore} loading={submitting} cancelLabel={t("cancel")} confirmLabel={t(safety ? "restore_confirm_yes" : "restore_confirm_yes_unsafe")} />
); return ( { if (submitting) return; // locked while the stop→restore chain runs if (next) setError(null); // fresh each open setOpen(next); }} > {trigger} {dialogContent} ); } /** ServerBackups is the per-server backup surface (/servers/:name/backups): view the * world archives kept for this server and (B2) roll the world back to 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 ? ( ) : (
{backupMsg && }

{t("subtitle")}

{backupsQ.loading && !backupsQ.data ? ( ) : backupsQ.error ? ( ) : all.length === 0 ? ( ) : ( <>
{isAdmin && } {all.map((b) => ( { statusQ.reload(); jobsQ.reload(); }} /> ))}
{t("col_created")} {t("col_size")} {t("col_expires")}{t("col_owner")}{t("col_actions")}
{total > BACKUP_PAGE_SIZE && ( )} {t("history_note")} )} {/* Async world-operation history: every backup/restore 202 lands here, so a Job that later failed stays visible (with its message) without kubectl. */} {jobsQ.error ? ( ) : (
{t("jobs_title")}
{(jobsQ.data ?? []).length === 0 ? (

{t("jobs_empty")}

) : (
    {(jobsQ.data ?? []).map((j) => { const at = j.started_at || j.finished_at; return (
  • {j.kind === "restore" ? ( ) : ( )} {j.kind === "restore" ? t("job_restore") : j.kind === "backup" ? j.then_restore ? t("job_pre_restore") : j.scheduled ? t("job_scheduled") : t("job_backup") : j.kind} {at && ( {formatRelative(at, now, locale)} )}
    {j.state === "failed" && j.message && ( {j.message} )} {j.then_restore && }
  • ); })}
)}
)}
)} ); }