feat(backup): admin UI with admin-chosen target directory
The backup destination is now chosen by the on-site admin in the UI (Setup -> Backup), not a server env var. An env-pinned target defeats the purpose: the admin can't point backups at a freshly-plugged USB or a NAS mount without editing .env and restarting. The encryption key stays a server secret. Target storage: - New site_config.backup_target_dir (migration 0016, nullable; null = not configured). BackupService reads it fresh each run, so a UI change takes effect with no restart. Only BACKUP_KEY stays env -- a key must never live in the DB it backs up. Routes: - PUT /api/backup/config -- set/clear the target (backup:update; upserts id=1). - POST /api/backup/test -- probe a candidate path server-side (exists / is a directory / writable) so the admin gets feedback before relying on it. - status() now exposes targetDir + keyPresent, so the UI distinguishes 'no target set' from 'BACKUP_KEY missing'. UI (apps/web/src/BackupSettings.tsx): - A Setup -> Backup tab (gated backup:read): an editable target-path field with a Test-target probe (localized ok/missing/not-a-dir/not-writable), Save, the status panel (config state, last-run size/pruned/error, a distinct amber missing-key warning), a Back up now button, and the restore-is-out-of-band note. Full i18n (sq + en); nav.backup. - API client: fetchBackupStatus / setBackupTarget / testBackupTarget / runBackup. Also includes a small in-progress copy trim to the setup-intro i18n strings. Verified live with Playwright: typed a path -> Test reported writable -> Save persisted it -> status reflected it and showed the key-missing warning. Whole monorepo build/lint/test green. Wiki backup-recovery + open-question #5 updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ApiError,
|
||||
fetchBackupStatus,
|
||||
runBackup,
|
||||
setBackupTarget,
|
||||
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 <span className="text-[0.75rem] font-semibold text-term-muted">{t("backup.notConfigured")}</span>;
|
||||
}
|
||||
if (status.running) {
|
||||
return <span className="text-[0.75rem] font-semibold text-term-amber">{t("backup.running")}</span>;
|
||||
}
|
||||
return <span className="text-[0.75rem] font-semibold text-term-green">{t("backup.configured")}</span>;
|
||||
}
|
||||
|
||||
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 [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 field from the saved value once it loads (and when it changes server-side).
|
||||
useEffect(() => {
|
||||
if (status) setTarget(status.targetDir ?? "");
|
||||
}, [status?.targetDir]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => setBackupTarget(target.trim() || null),
|
||||
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();
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("backup.title")}</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!status?.configured || status?.running || run.isPending || dirty}
|
||||
onClick={() => {
|
||||
setToast(null);
|
||||
run.mutate();
|
||||
}}
|
||||
>
|
||||
{status?.running || run.isPending ? t("backup.running") : t("backup.runNow")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mb-3 max-w-2xl text-[0.75rem] text-term-muted">{t("backup.intro")}</p>
|
||||
|
||||
{toast && (
|
||||
<div
|
||||
className={`mb-3 rounded-term border px-3 py-2 text-[0.75rem] ${
|
||||
toast.kind === "ok"
|
||||
? "border-term-green/40 bg-term-green/5 text-term-green"
|
||||
: "border-term-red/40 bg-term-red/5 text-term-red"
|
||||
}`}
|
||||
>
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Target directory — the admin-chosen destination. */}
|
||||
<div className="card mb-3 p-4">
|
||||
<div className="field">
|
||||
<span className="label">{t("backup.targetLabel")}</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="input w-96 max-w-full"
|
||||
value={target}
|
||||
placeholder={t("backup.targetPlaceholder")}
|
||||
onChange={(e) => {
|
||||
setTarget(e.target.value);
|
||||
setCheck(null);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={test.isPending || !target.trim()}
|
||||
onClick={() => test.mutate()}
|
||||
>
|
||||
{t("backup.test")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={save.isPending || !dirty}
|
||||
onClick={() => {
|
||||
setToast(null);
|
||||
save.mutate();
|
||||
}}
|
||||
>
|
||||
{t("backup.save")}
|
||||
</button>
|
||||
</div>
|
||||
<span className="mt-1 text-[0.6875rem] text-term-muted">{t("backup.targetHint")}</span>
|
||||
{check && (
|
||||
<span className={`mt-1 text-[0.75rem] ${check.kind === "ok" ? "text-term-green" : "text-term-red"}`}>
|
||||
{check.msg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
{q.isLoading || !status ? (
|
||||
<div className="text-[0.75rem] text-term-muted">{t("common.loading")}</div>
|
||||
) : (
|
||||
<dl className="grid grid-cols-[10rem_1fr] gap-x-4 gap-y-2 text-[0.8125rem]">
|
||||
<dt className="text-term-muted">{t("backup.statusTitle")}</dt>
|
||||
<dd>
|
||||
<StatusBadge status={status} />
|
||||
</dd>
|
||||
|
||||
{!status.keyPresent && (
|
||||
<>
|
||||
<dt className="text-term-muted" />
|
||||
<dd className="text-[0.75rem] text-term-amber">{t("backup.keyMissing")}</dd>
|
||||
</>
|
||||
)}
|
||||
|
||||
<dt className="text-term-muted">{t("backup.lastSuccess")}</dt>
|
||||
<dd className="text-term-text">
|
||||
{status.lastSuccessAt ? formatRelativeDateTime(status.lastSuccessAt, t) : t("backup.never")}
|
||||
</dd>
|
||||
|
||||
{status.lastResult && (
|
||||
<>
|
||||
<dt className="text-term-muted">{t("backup.size")}</dt>
|
||||
<dd className="text-term-text tabular-nums">
|
||||
{formatBytes(status.lastResult.bytes)}
|
||||
{status.lastResult.prunedFiles > 0 && (
|
||||
<span className="ml-2 text-term-muted">
|
||||
({t("backup.pruned")}: {status.lastResult.prunedFiles})
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status.lastError && (
|
||||
<>
|
||||
<dt className="text-term-muted">{t("backup.lastError")}</dt>
|
||||
<dd className="text-term-red">
|
||||
{status.lastError}
|
||||
{status.lastErrorAt && (
|
||||
<span className="ml-2 text-term-muted">
|
||||
({formatRelativeDateTime(status.lastErrorAt, t)})
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 max-w-2xl text-[0.6875rem] text-term-muted">{t("backup.restoreNote")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user