import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ApiError, fetchBackupStatus, runBackup, setBackupConfig, testBackupTarget, type BackupStatus, type TargetCheck, } from "./api.js"; import { formatRelativeDateTime } from "./lib/format.js"; // Admin screen for the on-site encrypted DB backup. The admin picks the TARGET DIRECTORY here // (stored in site_config; a mounted USB/SATA/SMB/NFS path) — the encryption key stays a server // secret. Shows status + last-run outcome, a "Test target" probe, and a manual "Back up now". // Gated by backup:read (config/test by backup:update, run by backup:create). RESTORE is absent // by design — out-of-band on a fresh appliance. See wiki/concepts/backup-recovery.md. function formatBytes(n: number): string { if (n < 1024) return `${n} B`; const mb = n / 1048576; if (mb < 1024) return `${mb.toFixed(1)} MB`; return `${(mb / 1024).toFixed(2)} GB`; } /** Map a target-check result to a localized message. */ function checkMessage(c: TargetCheck, t: (k: string) => string): string { if (c.ok) return t("backup.testOk"); switch (c.reason) { case "empty": return t("backup.testEmpty"); case "not_a_dir": return t("backup.testNotDir"); case "not_writable": return t("backup.testNotWritable"); default: return t("backup.testMissing"); } } function StatusBadge({ status }: { status: BackupStatus }) { const { t } = useTranslation(); if (!status.configured) { return {t("backup.notConfigured")}; } if (status.running) { return {t("backup.running")}; } return {t("backup.configured")}; } export function BackupSettings() { const { t } = useTranslation(); const qc = useQueryClient(); const [toast, setToast] = useState<{ kind: "ok" | "err"; msg: string } | null>(null); const [target, setTarget] = useState(""); const [keepLast, setKeepLast] = useState(""); const [keepDaily, setKeepDaily] = useState(""); const [check, setCheck] = useState<{ kind: "ok" | "err"; msg: string } | null>(null); const q = useQuery({ queryKey: ["backup-status"], queryFn: fetchBackupStatus, refetchInterval: (query) => (query.state.data?.running ? 2000 : false), }); const status = q.data; // Seed the editable fields from the saved values once they load (and on server-side change). useEffect(() => { if (status) { setTarget(status.targetDir ?? ""); setKeepLast(String(status.keepLast)); setKeepDaily(String(status.keepDailyDays)); } }, [status?.targetDir, status?.keepLast, status?.keepDailyDays]); const save = useMutation({ mutationFn: () => setBackupConfig({ targetDir: target.trim() || null, keepLast: keepLast.trim() === "" ? null : Number(keepLast), keepDailyDays: keepDaily.trim() === "" ? null : Number(keepDaily), }), onSuccess: (next) => { setToast({ kind: "ok", msg: t("backup.saved") }); setCheck(null); qc.setQueryData(["backup-status"], next); }, onError: () => setToast({ kind: "err", msg: t("backup.runFailed") }), }); const test = useMutation({ mutationFn: () => testBackupTarget(target.trim()), onSuccess: (res) => setCheck({ kind: res.ok ? "ok" : "err", msg: checkMessage(res, t) }), }); const run = useMutation({ mutationFn: runBackup, onSuccess: () => { setToast({ kind: "ok", msg: t("backup.runSuccess") }); void qc.invalidateQueries({ queryKey: ["backup-status"] }); }, onError: (err: unknown) => { const code = err instanceof ApiError ? err.message : ""; setToast({ kind: "err", msg: code === "backup_not_configured" ? t("backup.notConfiguredError") : t("backup.runFailed"), }); void qc.invalidateQueries({ queryKey: ["backup-status"] }); }, }); const dirty = (status?.targetDir ?? "") !== target.trim() || String(status?.keepLast ?? "") !== keepLast.trim() || String(status?.keepDailyDays ?? "") !== keepDaily.trim(); return (
{t("backup.intro")}
{toast && ({t("backup.restoreNote")}