import { useEffect, useRef, useState } from "react"; import { api, get, patch, post } from "../../api"; import { confirmDialog, toast } from "../../dialogs"; import { useFetch } from "../../hooks"; import { fmtDate, useT } from "../../i18n"; import { formatSize } from "../FileFigure"; import { Icon } from "../Icon"; const MASK = "••••••••"; interface Overview { files: { name: string; size: number; modified: number }[]; dir: string; last?: { at: number; file: string; size: number; error?: string; s3?: string } | null; enabled: boolean; hour: number; keep: number; s3: boolean; } interface S3 { endpoint: string; region: string; bucket: string; prefix: string; access_key: string; secret_key: string; keep: number; } /** Admin: automatic daily backups (local + S3) and manual download. */ export function Backups() { const t = useT(); const { data, reload } = useFetch("/backups"); const [form, setForm] = useState<{ enabled: boolean; hour: number; keep: number; s3: S3 }>(); const [secret, setSecret] = useState(""); const [busy, setBusy] = useState(false); useEffect(() => { get<{ config: { backup?: { enabled?: boolean; hour?: number; keep?: number; s3?: Partial } } }>("/config") .then((r) => { const b = r.config.backup ?? {}; const s3 = b.s3 ?? {}; setForm({ enabled: b.enabled ?? true, hour: b.hour ?? 3, keep: b.keep ?? 30, s3: { endpoint: s3.endpoint ?? "", region: s3.region ?? "", bucket: s3.bucket ?? "", prefix: s3.prefix ?? "", access_key: s3.access_key ?? "", secret_key: s3.secret_key ?? "", keep: s3.keep ?? 0 }, }); }) .catch((e) => toast(e.message)); }, []); const save = async () => { if (!form) return; const patches = [ { path: ["backup", "enabled"], value: form.enabled }, { path: ["backup", "hour"], value: Math.min(23, Math.max(0, Number(form.hour) || 0)) }, { path: ["backup", "keep"], value: Math.max(1, Number(form.keep) || 30) }, { path: ["backup", "s3", "endpoint"], value: form.s3.endpoint.trim() }, { path: ["backup", "s3", "region"], value: form.s3.region.trim() }, { path: ["backup", "s3", "bucket"], value: form.s3.bucket.trim() }, { path: ["backup", "s3", "prefix"], value: form.s3.prefix.trim() }, { path: ["backup", "s3", "access_key"], value: form.s3.access_key.trim() }, { path: ["backup", "s3", "keep"], value: Math.max(0, Number(form.s3.keep) || 0) }, ]; // Empty field = keep the stored secret. if (secret) patches.push({ path: ["backup", "s3", "secret_key"], value: secret }); try { await patch("/config", { patches }); setSecret(""); toast(t("settings.saved"), "success"); reload(); } catch (e: any) { toast(e.message); } }; const runNow = async () => { setBusy(true); try { const r = await post<{ file: string; s3?: string }>("/backups"); if (r.s3 && r.s3 !== "ok") toast(`${t("backup.s3Failed")}: ${r.s3}`); else toast(t("backup.created", { file: r.file }), "success"); reload(); } catch (e: any) { toast(e.message); } finally { setBusy(false); } }; // Restore: the server stages the backup, restarts and applies it before opening the database. const fileInput = useRef(null); const [restoring, setRestoring] = useState(false); const restore = async (source: { name: string } | { file: File }) => { const label = "name" in source ? source.name : source.file.name; const ok = await confirmDialog({ title: t("backup.restoreTitle"), message: t("backup.restoreConfirm", { file: label }), confirmLabel: t("backup.restore"), danger: true }); if (!ok) return; setRestoring(true); try { if ("name" in source) await post(`/backups/${encodeURIComponent(source.name)}/restore`); else await api("/backups/restore", { method: "POST", body: source.file, headers: { "Content-Type": "application/zip" } }); toast(t("backup.restarting"), "info"); // Wait until the server is gone and back, then reload with the restored data. await new Promise((r) => setTimeout(r, 2500)); for (let i = 0; i < 120; i++) { if (await fetch("/api/health").then((r) => r.ok).catch(() => false)) return location.reload(); await new Promise((r) => setTimeout(r, 1000)); } toast(t("backup.notBack")); } catch (e: any) { toast(e.message); } finally { setRestoring(false); } }; const s3 = (k: keyof S3, label: string, placeholder = "", type = "text") => form && ( ); return (

{t("audit.backup")} {t("audit.download")} { const file = e.target.files?.[0]; e.target.value = ""; if (file) void restore({ file }); }} />

{t("audit.backupHint")}
{form && ( <>
{data &&
{data.dir}
}
{t("backup.s3")}
{t("backup.s3Hint")}
{s3("endpoint", t("backup.endpoint"), "https://s3.eu-central-1.amazonaws.com")} {s3("region", t("backup.region"), "eu-central-1")} {s3("bucket", t("backup.bucket"), "my-backups")} {s3("prefix", t("backup.prefix"), "codecraft/")} {s3("access_key", t("backup.accessKey"), "AKIA… or ${S3_ACCESS_KEY}")} {s3("keep", t("backup.s3Keep"), "0", "number")}
)} {data?.last && (
{t("backup.last")}: {fmtDate(data.last.at)} · {data.last.error ?? `${data.last.file} (${formatSize(data.last.size)})`} {data.last.s3 && ` · S3: ${data.last.s3 === "ok" ? t("backup.s3Ok") : data.last.s3}`}
)} {data && data.files.length > 0 && (
{data.files.map((f) => (
{f.name} {formatSize(f.size)}
))}
)}
); }