/** * Backup settings page * * One-click full backup download, scheduled backups to the site's storage * bucket with retention, the list of stored archives, and a pointer to * D1 Time Travel for point-in-time restore on Cloudflare. */ import { Button, Input, LinkButton, Loader, Switch, useKumoToastManager } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { Archive, ClockCounterClockwise, CloudArrowUp, DownloadSimple, Trash, WarningCircle, } from "@phosphor-icons/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; import { backupArchiveUrl, BACKUP_EXPORT_URL, createBackupArchive, deleteBackupArchive, fetchBackupOverview, updateBackupSettings, type BackupArchive, } from "../../lib/api/backups.js"; import { ConfirmDialog } from "../ConfirmDialog.js"; import { DialogError, getMutationError } from "../DialogError.js"; import { BackToSettingsLink } from "./BackToSettingsLink.js"; function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } export function BackupSettings() { const { t, i18n } = useLingui(); const toastManager = useKumoToastManager(); const queryClient = useQueryClient(); const [archiveToDelete, setArchiveToDelete] = React.useState(null); const { data: overview, isLoading, error: fetchError, } = useQuery({ queryKey: ["backup-overview"], queryFn: fetchBackupOverview, }); // Local form state seeded from the server once loaded const [enabled, setEnabled] = React.useState(false); const [retention, setRetention] = React.useState("7"); const seeded = React.useRef(false); React.useEffect(() => { if (overview && !seeded.current) { seeded.current = true; setEnabled(overview.settings.enabled); setRetention(String(overview.settings.retention)); } }, [overview]); const saveMutation = useMutation({ mutationFn: () => { // Clamp to the server's accepted range so out-of-range input saves // the nearest valid value instead of failing validation. const parsed = Number.parseInt(retention, 10); const clamped = Number.isNaN(parsed) ? 7 : Math.min(30, Math.max(1, parsed)); return updateBackupSettings({ enabled, retention: clamped }); }, onSuccess: (settings) => { setEnabled(settings.enabled); setRetention(String(settings.retention)); void queryClient.invalidateQueries({ queryKey: ["backup-overview"] }); toastManager.add({ title: t`Backup settings saved`, variant: "success", timeout: 4000 }); }, onError: (error) => { toastManager.add({ title: t`Failed to save backup settings`, description: getMutationError(error) || t`An error occurred`, variant: "error", timeout: 5000, }); }, }); const backupNowMutation = useMutation({ mutationFn: createBackupArchive, onSuccess: (archive) => { void queryClient.invalidateQueries({ queryKey: ["backup-overview"] }); toastManager.add({ title: t`Backup created: ${archive.name}`, variant: "success", timeout: 4000, }); }, onError: (error) => { toastManager.add({ title: t`Failed to create backup`, description: getMutationError(error) || t`An error occurred`, variant: "error", timeout: 5000, }); }, }); const deleteMutation = useMutation({ mutationFn: (name: string) => deleteBackupArchive(name), onSuccess: () => { setArchiveToDelete(null); void queryClient.invalidateQueries({ queryKey: ["backup-overview"] }); }, }); if (isLoading) { return (
); } if (fetchError) { return (

{t`Backups`}

); } const storageAvailable = overview?.storageAvailable ?? false; const archives = overview?.archives ?? []; return (
{/* Header */}

{t`Backups`}

{/* One-click download */}

{t`Download Backup`}

{t`Download a complete backup of your site: all content (including drafts and trash), collections, taxonomies, menus, widgets, media metadata, and site settings. User accounts and secrets are never included.`}

{t`Download backup`}
{/* Scheduled backups */}

{t`Automatic Backups`}

{storageAvailable ? (

{t`Store a daily backup in your site's storage bucket. Old backups are removed automatically.`}

setRetention(e.target.value)} />
) : (

{t`Automatic backups need a storage backend (R2, S3, or local storage). Configure storage in your EmDash config to enable them.`}

)}
{/* Stored archives */} {storageAvailable && archives.length > 0 && (

{t`Stored Backups`}

    {archives.map((archive) => (
  • {archive.name}
    {i18n.date(new Date(archive.lastModified), { dateStyle: "medium", timeStyle: "short", })}{" "} · {formatBytes(archive.size)}
  • ))}
)} {/* Time Travel hint */}

{t`Point-in-Time Restore`}

{t`Sites on Cloudflare D1 can additionally restore the database to any minute within the last 30 days using D1 Time Travel — always on, no setup required.`}{" "} {t`Learn more`}

setArchiveToDelete(null)} title={t`Delete backup?`} description={t`This permanently deletes ${archiveToDelete?.name ?? ""} from storage.`} confirmLabel={t`Delete`} pendingLabel={t`Deleting...`} isPending={deleteMutation.isPending} error={deleteMutation.error} onConfirm={() => { if (archiveToDelete) deleteMutation.mutate(archiveToDelete.name); }} />
); }