feat(backup): admin UI with admin-chosen target directory
Build desktop / desktop (push) Successful in 4m42s
Build & push images / images (push) Successful in 2m53s
CI / check (push) Successful in 39s

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:
2026-06-29 12:21:26 +02:00
parent 0c218179c4
commit d5e41500a8
16 changed files with 630 additions and 74 deletions
+48
View File
@@ -227,6 +227,54 @@ export function fetchLogs(params: {
return apiFetch(`/api/logs${qs ? `?${qs}` : ""}`);
}
// --- Backup ---------------------------------------------------------------
// On-site encrypted DB backup. See wiki/concepts/backup-recovery.md.
export interface BackupStatus {
configured: boolean;
/** Admin-chosen target directory (null = not set). */
targetDir: string | null;
/** Whether the env encryption key is present (a missing key is flagged distinctly). */
keyPresent: boolean;
running: boolean;
lastSuccessAt: string | null;
lastResult: { path: string; bytes: number; prunedFiles: number } | null;
lastErrorAt: string | null;
lastError: string | null;
}
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 TargetCheck {
ok: boolean;
/** "empty" | "missing" | "not_a_dir" | "not_writable" when !ok. */
reason?: string;
}
/** Probe a candidate target path server-side (exists / is a dir / is writable). */
export async function testBackupTarget(targetDir: string): Promise<TargetCheck> {
return apiFetch("/api/backup/test", { method: "POST", body: JSON.stringify({ targetDir }) });
}
export interface BackupRunResult {
ok: true;
path: string;
bytes: number;
prunedFiles: number;
}
/** Trigger a manual "back up now". Throws on 409 (not configured) / 500 (run failed). */
export async function runBackup(): Promise<BackupRunResult> {
return apiFetch("/api/backup/run", { method: "POST" });
}
// --- Device setup ---------------------------------------------------------
export interface ConfigField {