feat(backup): admin-tunable retention + BACKUP_KEY as a Komodo secret
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:
@@ -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">
|
||||
|
||||
+13
-3
@@ -234,6 +234,9 @@ export interface BackupStatus {
|
||||
configured: boolean;
|
||||
/** Admin-chosen target directory (null = not set). */
|
||||
targetDir: string | null;
|
||||
/** Admin-tuned retention (resolved value: DB or code default). */
|
||||
keepLast: number;
|
||||
keepDailyDays: number;
|
||||
/** Whether the env encryption key is present (a missing key is flagged distinctly). */
|
||||
keyPresent: boolean;
|
||||
running: boolean;
|
||||
@@ -247,9 +250,16 @@ export async function fetchBackupStatus(): Promise<BackupStatus> {
|
||||
return apiFetch("/api/backup/status");
|
||||
}
|
||||
|
||||
/** Set (or clear, with "") the admin-chosen target directory. Returns the new status. */
|
||||
export async function setBackupTarget(targetDir: string | null): Promise<BackupStatus> {
|
||||
return apiFetch("/api/backup/config", { method: "PUT", body: JSON.stringify({ targetDir }) });
|
||||
export interface BackupConfigPatch {
|
||||
/** "" clears the target. Omit a field to leave it unchanged; null resets retention to default. */
|
||||
targetDir?: string | null;
|
||||
keepLast?: number | null;
|
||||
keepDailyDays?: number | null;
|
||||
}
|
||||
|
||||
/** Update backup config (target dir and/or retention). Returns the new status. */
|
||||
export async function setBackupConfig(patch: BackupConfigPatch): Promise<BackupStatus> {
|
||||
return apiFetch("/api/backup/config", { method: "PUT", body: JSON.stringify(patch) });
|
||||
}
|
||||
|
||||
export interface TargetCheck {
|
||||
|
||||
@@ -865,6 +865,10 @@ export const en: Catalog = {
|
||||
testNotDir: "The path is not a directory.",
|
||||
testNotWritable: "The directory is not writable.",
|
||||
keyMissing: "The encryption key (BACKUP_KEY) is missing on the server — set it to enable backups.",
|
||||
keepLastLabel: "Keep last",
|
||||
keepLastHint: "How many of the newest backups to always keep.",
|
||||
keepDailyLabel: "Keep daily (days)",
|
||||
keepDailyHint: "Beyond those, keep one backup per day for this many days.",
|
||||
},
|
||||
pay: {
|
||||
ticket: "Ticket",
|
||||
|
||||
@@ -881,6 +881,10 @@ export const sq = {
|
||||
testNotDir: "Rruga nuk është një dosje.",
|
||||
testNotWritable: "Dosja nuk është e shkruajtshme.",
|
||||
keyMissing: "Çelësi i enkriptimit (BACKUP_KEY) mungon në server — caktoje që kopja të aktivizohet.",
|
||||
keepLastLabel: "Mbaj kopjet e fundit",
|
||||
keepLastHint: "Numri i kopjeve më të reja që mbahen gjithmonë.",
|
||||
keepDailyLabel: "Mbaj ditore (ditë)",
|
||||
keepDailyHint: "Përtej atyre, mbaj një kopje për ditë për kaq ditë.",
|
||||
},
|
||||
pay: {
|
||||
ticket: "Bileta",
|
||||
|
||||
Reference in New Issue
Block a user