Files
minecraft/dashboard/src/app/(panel)/backups/page.tsx
T
Aryan SinghandClaude Fable 5 279d137ce5 Minecraft 26.2 Fabric server stack with CraftDeck management panel
- docker-compose: itzg/minecraft-server (Fabric, offline mode, whitelist
  enforced, Modrinth-managed mods), itzg/mc-backup sidecar, custom dashboard
- Gated auth for offline mode: approve username -> whitelisted with a short
  registration window -> EasyAuth /register + /login; auto-revoke on expiry
- CraftDeck panel (Next.js 16, zero native deps): live console (SSE), players
  & stats, lag diagnostics (server TPS vs player ping via spark), performance
  graphs, Modrinth mod search/manager, backups & restore, sandboxed file
  browser/editor, .env config editor with compose apply, cron schedules,
  Discord alerts, scrypt-hashed panel login
- scripts/setup.sh generates secrets; docs/ covers auth, mods, updating,
  backups, networking (Tailscale), troubleshooting

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:07:07 -05:00

177 lines
6.5 KiB
TypeScript

"use client";
import { useState } from "react";
import { Archive, Download, Trash2, History, PlayCircle } from "lucide-react";
import { api, useApi, toast } from "@/lib/api";
import { Card, Button, Input, EmptyState, Modal } from "@/components/ui";
import { fmtBytes, fmtDateTime, timeAgo } from "@/lib/format";
type BackupsData = {
backups: Array<{ name: string; size: number; mtime: number }>;
totalSize: number;
interval: string;
retentionDays: string;
};
export default function BackupsPage() {
const { data, refresh } = useApi<BackupsData>("/api/backups", 30000);
const [creating, setCreating] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const [restoreTarget, setRestoreTarget] = useState<string | null>(null);
const [restoreConfirm, setRestoreConfirm] = useState("");
const [restoring, setRestoring] = useState(false);
const backupNow = async () => {
setCreating(true);
toast("Backup started — this can take a minute on a big world", "info");
try {
const res = await api<{ created: string }>("/api/backups", { method: "POST" });
toast(`Backup created: ${res.created}`, "success");
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setCreating(false);
}
};
const remove = async (name: string) => {
setDeleting(name);
try {
await api(`/api/backups/file?name=${encodeURIComponent(name)}`, { method: "DELETE" });
toast(`${name} deleted`, "warn");
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setDeleting(null);
}
};
const restore = async () => {
if (!restoreTarget) return;
setRestoring(true);
try {
await api("/api/backups/restore", { body: { name: restoreTarget, confirm: restoreConfirm } });
toast("Restore complete — the server is starting with the restored world", "success");
setRestoreTarget(null);
setRestoreConfirm("");
} catch (err) {
toast((err as Error).message, "error");
} finally {
setRestoring(false);
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold">Backups</h1>
<p className="mt-0.5 text-xs text-ink2">
Automatic every {data?.interval ?? "24h"}, kept {data?.retentionDays ?? "7"} days ·{" "}
{fmtBytes(data?.totalSize)} total — change in{" "}
<a href="/config" className="text-accent underline">
Configuration
</a>
</p>
</div>
<Button variant="primary" onClick={backupNow} busy={creating}>
<Archive size={14} /> Back up now
</Button>
</div>
<Card pad={false}>
{!data || data.backups.length === 0 ? (
<EmptyState
icon={<Archive size={28} />}
title="No backups yet"
hint='The backup sidecar creates the first one automatically shortly after the server starts, or press "Back up now".'
/>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-muted">
<th className="px-4 py-2 font-medium">Backup</th>
<th className="px-4 py-2 font-medium">Created</th>
<th className="px-4 py-2 font-medium">Size</th>
<th className="px-4 py-2 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{data.backups.map((b) => (
<tr key={b.name} className="border-b border-border/50 last:border-0">
<td className="px-4 py-2.5 font-mono text-xs font-medium">{b.name}</td>
<td className="px-4 py-2.5 text-ink2" title={fmtDateTime(b.mtime)}>
{timeAgo(b.mtime)}
</td>
<td className="px-4 py-2.5 text-ink2">{fmtBytes(b.size)}</td>
<td className="px-4 py-2.5">
<div className="flex justify-end gap-1.5">
<a href={`/api/backups/file?name=${encodeURIComponent(b.name)}`}>
<Button size="sm" title="Download">
<Download size={12} />
</Button>
</a>
<Button size="sm" variant="warn" onClick={() => setRestoreTarget(b.name)} title="Restore">
<History size={12} /> Restore
</Button>
<Button
size="sm"
variant="danger"
busy={deleting === b.name}
onClick={() => void remove(b.name)}
title="Delete"
>
<Trash2 size={12} />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
<Modal
open={restoreTarget !== null}
onClose={() => {
setRestoreTarget(null);
setRestoreConfirm("");
}}
title="Restore backup"
>
<div className="space-y-3 text-sm text-ink2">
<p>
Restoring <code className="text-ink">{restoreTarget}</code> will:
</p>
<ol className="list-decimal space-y-1 pl-5 text-xs">
<li>Stop the server (players are disconnected)</li>
<li>
Overwrite the current world and settings with the backup —{" "}
<span className="text-warn">everything since the backup is lost</span>
</li>
<li>Start the server again</li>
</ol>
<p className="text-xs">
Type <code className="text-warn">restore</code> to confirm:
</p>
<Input
value={restoreConfirm}
onChange={(e) => setRestoreConfirm(e.target.value)}
placeholder="restore"
className="w-full font-mono"
/>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button onClick={() => setRestoreTarget(null)}>Cancel</Button>
<Button variant="danger" disabled={restoreConfirm !== "restore"} busy={restoring} onClick={restore}>
<PlayCircle size={13} /> Restore this backup
</Button>
</div>
</Modal>
</div>
);
}