84f00db48b
Retention (keep-last / keep-daily-days) is operational policy the on-site admin should tune, not a server env var requiring a redeploy -- same reasoning that moved the target directory to the UI. - Migration 0017: site_config.backup_keep_last + backup_keep_daily_days (nullable; null = code default 7 / 30 per field). - BackupService reads retention fresh each run; status() exposes keepLast + keepDailyDays. DEFAULT_BACKUP_RETENTION is now a pure code default (env reads gone). - PUT /api/backup/config accepts keepLast / keepDailyDays (non-negative int, or null to reset to default; 400 on negative). - UI: two retention fields on the Backup config card; one Save covers target + retention. i18n sq + en. BACKUP_KEY wired into Komodo: - komodo/resources.toml: BACKUP_KEY=[[park_buzi_backup_key]] (per-booth secret, alongside JWT / signing keys). - komodo/.env.komodo.example: documents it as the ONLY backup env var -- escrow it offsite alongside EVENT_SIGNING_KEY (recovery needs both); target + retention are admin-chosen in the UI / DB, not env. Server .env.example trimmed to just BACKUP_KEY. Also carries the small in-progress setup-intro i18n copy trim. Tests: 218 server tests green, incl. retention persist / reset-to-default / reject- negative and the updated status shape. Migration applies cleanly (needed a statement-breakpoint between the two ALTERs). Wiki backup-recovery updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
281 lines
9.9 KiB
TypeScript
281 lines
9.9 KiB
TypeScript
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 <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 [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 (
|
|
<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>
|
|
)}
|
|
|
|
{/* Config — admin-chosen destination + retention policy. */}
|
|
<div className="card mb-3 p-4">
|
|
{/* Target directory + its Test probe. */}
|
|
<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>
|
|
</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>
|
|
|
|
{/* Retention — admin-tuned policy (how many backups to keep at the target). */}
|
|
<div className="mt-4 flex flex-wrap items-start gap-6">
|
|
<div className="field">
|
|
<span className="label">{t("backup.keepLastLabel")}</span>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
className="input w-28"
|
|
value={keepLast}
|
|
onChange={(e) => setKeepLast(e.target.value)}
|
|
/>
|
|
<span className="mt-1 text-[0.6875rem] text-term-muted">{t("backup.keepLastHint")}</span>
|
|
</div>
|
|
<div className="field">
|
|
<span className="label">{t("backup.keepDailyLabel")}</span>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
className="input w-28"
|
|
value={keepDaily}
|
|
onChange={(e) => setKeepDaily(e.target.value)}
|
|
/>
|
|
<span className="mt-1 text-[0.6875rem] text-term-muted">{t("backup.keepDailyHint")}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-sm"
|
|
disabled={save.isPending || !dirty}
|
|
onClick={() => {
|
|
setToast(null);
|
|
save.mutate();
|
|
}}
|
|
>
|
|
{t("backup.save")}
|
|
</button>
|
|
</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>
|
|
);
|
|
}
|