feat(backup): admin-tunable retention + BACKUP_KEY as a Komodo secret
Build desktop / desktop (push) Successful in 4m17s
Build & push images / images (push) Failing after 39s
CI / check (push) Successful in 39s

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
This commit is contained in:
2026-06-29 12:52:18 +02:00
parent d5e41500a8
commit 84f00db48b
16 changed files with 234 additions and 44 deletions
+62 -18
View File
@@ -5,7 +5,7 @@ import {
ApiError,
fetchBackupStatus,
runBackup,
setBackupTarget,
setBackupConfig,
testBackupTarget,
type BackupStatus,
type TargetCheck,
@@ -56,6 +56,8 @@ export function BackupSettings() {
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({
@@ -65,13 +67,22 @@ export function BackupSettings() {
});
const status = q.data;
// Seed the editable field from the saved value once it loads (and when it changes server-side).
// Seed the editable fields from the saved values once they load (and on server-side change).
useEffect(() => {
if (status) setTarget(status.targetDir ?? "");
}, [status?.targetDir]);
if (status) {
setTarget(status.targetDir ?? "");
setKeepLast(String(status.keepLast));
setKeepDaily(String(status.keepDailyDays));
}
}, [status?.targetDir, status?.keepLast, status?.keepDailyDays]);
const save = useMutation({
mutationFn: () => setBackupTarget(target.trim() || null),
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);
@@ -101,7 +112,10 @@ export function BackupSettings() {
},
});
const dirty = (status?.targetDir ?? "") !== target.trim();
const dirty =
(status?.targetDir ?? "") !== target.trim() ||
String(status?.keepLast ?? "") !== keepLast.trim() ||
String(status?.keepDailyDays ?? "") !== keepDaily.trim();
return (
<div className="">
@@ -134,8 +148,9 @@ export function BackupSettings() {
</div>
)}
{/* Target directory — the admin-chosen destination. */}
{/* 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">
@@ -156,17 +171,6 @@ export function BackupSettings() {
>
{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 && (
@@ -175,6 +179,46 @@ export function BackupSettings() {
</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">