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.title")}

{t("backup.intro")}

{toast && (
{toast.msg}
)} {/* Config — admin-chosen destination + retention policy. */}
{/* Target directory + its Test probe. */}
{t("backup.targetLabel")}
{ setTarget(e.target.value); setCheck(null); }} />
{t("backup.targetHint")} {check && ( {check.msg} )}
{/* Retention — admin-tuned policy (how many backups to keep at the target). */}
{t("backup.keepLastLabel")} setKeepLast(e.target.value)} /> {t("backup.keepLastHint")}
{t("backup.keepDailyLabel")} setKeepDaily(e.target.value)} /> {t("backup.keepDailyHint")}
{q.isLoading || !status ? (
{t("common.loading")}
) : (
{t("backup.statusTitle")}
{!status.keyPresent && ( <>
{t("backup.keyMissing")}
)}
{t("backup.lastSuccess")}
{status.lastSuccessAt ? formatRelativeDateTime(status.lastSuccessAt, t) : t("backup.never")}
{status.lastResult && ( <>
{t("backup.size")}
{formatBytes(status.lastResult.bytes)} {status.lastResult.prunedFiles > 0 && ( ({t("backup.pruned")}: {status.lastResult.prunedFiles}) )}
)} {status.lastError && ( <>
{t("backup.lastError")}
{status.lastError} {status.lastErrorAt && ( ({formatRelativeDateTime(status.lastErrorAt, t)}) )}
)}
)}

{t("backup.restoreNote")}

); }