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
+8 -6
View File
@@ -15,15 +15,17 @@ JWT_SECRET=
# them (keyId), so verifyChain still validates a chain that spans a key change.
EVENT_SIGNING_KEY=
# On-site encrypted DB backup (durability for the signed ledger). Disabled until both
# BACKUP_TARGET_DIR and BACKUP_KEY are set. A daily timer + an admin "back up now" button
# write a consistent, AES-256-GCM-encrypted copy to the target. RESTORE is an out-of-band
# runbook action, not a console call. See wiki/concepts/backup-recovery.md.
# BACKUP_TARGET_DIR=/mnt/backup # a mounted local/USB/SATA/SMB/NFS path
# On-site encrypted DB backup (durability for the signed ledger). A daily timer + an admin
# "back up now" button write a consistent, AES-256-GCM-encrypted copy to the target. The
# TARGET DIRECTORY is chosen by the admin in the UI (Setup → Backup) and stored in the DB —
# NOT here. Only the encryption KEY is an env secret. RESTORE is an out-of-band runbook action,
# not a console call. See wiki/concepts/backup-recovery.md.
#
# Dedicated backup-encryption key (>=16 chars), SEPARATE from EVENT_SIGNING_KEY so it can
# rotate without fracturing the signed chain. Generate with: openssl rand -hex 32
# Escrow it offsite (alongside EVENT_SIGNING_KEY) — recovery needs both, and neither is ever
# stored inside the backup it unlocks.
# stored inside the backup it unlocks. Backups stay a no-op until BOTH this key and an in-UI
# target directory are set.
# BACKUP_KEY=
# BACKUP_KEEP_LAST=7 # keep this many newest backups always
# BACKUP_KEEP_DAILY_DAYS=30 # plus one-per-day within this window
+68 -36
View File
@@ -1,23 +1,35 @@
import type { Db } from "@parking/db";
import { constants } from "node:fs";
import { access, stat } from "node:fs/promises";
import { resolve } from "node:path";
import { eq, siteConfig, type Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult } from "./backup.js";
// Thin coordinator around the backup engine (backup.ts): resolves config once, runs a backup
// (manual or scheduled), and remembers the last outcome so the route + UI can show last-success
// / last-error without re-deriving it. One instance is shared by the daily timer and the
// "back up now" route, so a concurrent manual+timer run can't overlap (a single in-flight guard).
// See wiki/concepts/backup-recovery.md.
// Thin coordinator around the backup engine (backup.ts). The TARGET DIRECTORY is admin-chosen
// and stored in site_config.backup_target_dir (read fresh each run, so changing it in the UI
// takes effect with no restart). The ENCRYPTION KEY stays an env/Komodo secret (BACKUP_KEY) —
// a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can
// show last-success / last-error, and serializes concurrent runs (manual + timer). See
// wiki/concepts/backup-recovery.md.
export interface BackupConfig {
/** Mounted directory backups are written to (local/USB/SATA/SMB/NFS). Empty = disabled. */
readonly targetDir: string;
/** Encryption key (BACKUP_KEY / park_buzi_backup_key). */
readonly key: string;
/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */
export function backupKeyFromEnv(): string {
return process.env.BACKUP_KEY ?? "";
}
export interface TargetCheck {
readonly ok: boolean;
/** Machine-readable reason when !ok: "empty" | "missing" | "not_a_dir" | "not_writable". */
readonly reason?: string;
}
export interface BackupStatus {
/** True once a target dir + key are configured (otherwise backups are a no-op). */
/** True once a target dir is set AND a usable key is present (else backups are a no-op). */
readonly configured: boolean;
/** The admin-chosen target dir (null if unset) — surfaced so the UI can show/edit it. */
readonly targetDir: string | null;
/** Whether the env key is present + long enough (the UI flags a missing key distinctly). */
readonly keyPresent: boolean;
readonly running: boolean;
readonly lastSuccessAt: string | null;
readonly lastResult: { path: string; bytes: number; prunedFiles: number } | null;
@@ -25,18 +37,28 @@ export interface BackupStatus {
readonly lastError: string | null;
}
/** Resolve backup config from env. (First cut: env-driven, like EVENT_SIGNING_KEY + snapshot
* retention; a future admin-UI knob can override the target dir.) */
export function backupConfigFromEnv(): BackupConfig {
return {
targetDir: (process.env.BACKUP_TARGET_DIR ?? "").trim(),
key: process.env.BACKUP_KEY ?? "",
};
/** Probe a candidate target path server-side: exists, is a directory, is writable. */
export async function checkTargetDir(dir: string): Promise<TargetCheck> {
const trimmed = dir.trim();
if (!trimmed) return { ok: false, reason: "empty" };
const path = resolve(trimmed);
let st: Awaited<ReturnType<typeof stat>>;
try {
st = await stat(path);
} catch {
return { ok: false, reason: "missing" };
}
if (!st.isDirectory()) return { ok: false, reason: "not_a_dir" };
try {
await access(path, constants.W_OK);
} catch {
return { ok: false, reason: "not_writable" };
}
return { ok: true };
}
export class BackupService {
readonly #db: Db;
readonly #config: BackupConfig;
readonly #logger?: FastifyBaseLogger;
#running = false;
@@ -45,19 +67,31 @@ export class BackupService {
#lastErrorAt: string | null = null;
#lastError: string | null = null;
constructor(db: Db, config: BackupConfig, logger?: FastifyBaseLogger) {
constructor(db: Db, logger?: FastifyBaseLogger) {
this.#db = db;
this.#config = config;
this.#logger = logger;
}
/** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */
targetDir(): string | null {
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const dir = row?.backupTargetDir?.trim();
return dir ? dir : null;
}
get keyPresent(): boolean {
return backupKeyFromEnv().length >= 16;
}
get configured(): boolean {
return this.#config.targetDir.length > 0 && this.#config.key.length >= 16;
return this.targetDir() !== null && this.keyPresent;
}
status(): BackupStatus {
return {
configured: this.configured,
targetDir: this.targetDir(),
keyPresent: this.keyPresent,
running: this.#running,
lastSuccessAt: this.#lastSuccessAt,
lastResult: this.#lastResult
@@ -69,26 +103,24 @@ export class BackupService {
}
/**
* Run one backup. `trigger` is just for the log line ("manual" | "scheduled"). Serialized:
* if one is already in flight, this resolves to that same promise rather than starting a
* second. Records last-success/last-error on the instance. Re-throws on failure so a manual
* caller (the route) can surface it; the scheduled timer wraps + swallows.
* Run one backup. `trigger` is just for the log line. Serialized: if one is already in
* flight, resolves to that same promise. Reads the target dir + key at run time. Records
* last-success/last-error. Re-throws on failure so a manual caller (the route) can surface
* it; the scheduled timer wraps + swallows.
*/
#inflight: Promise<BackupResult> | null = null;
async run(trigger: "manual" | "scheduled"): Promise<BackupResult> {
if (this.#inflight) return this.#inflight;
if (!this.configured) {
throw new Error("backup: not configured (set BACKUP_TARGET_DIR and BACKUP_KEY ≥16 chars)");
}
const targetDir = this.targetDir();
const key = backupKeyFromEnv();
if (!targetDir) throw new Error("backup: no target directory configured");
if (key.length < 16) throw new Error("backup: BACKUP_KEY missing or too short (need ≥16 chars)");
this.#running = true;
this.#inflight = (async () => {
try {
this.#logger?.info(`backup: starting (${trigger})`);
const res = await runBackup(
this.#db,
{ targetDir: this.#config.targetDir, key: this.#config.key, retention: DEFAULT_BACKUP_RETENTION },
this.#logger,
);
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
const res = await runBackup(this.#db, { targetDir, key, retention: DEFAULT_BACKUP_RETENTION }, this.#logger);
this.#lastResult = res;
this.#lastSuccessAt = new Date().toISOString();
this.#lastError = null;
@@ -49,6 +49,7 @@ describe("GET /api/backup/status", () => {
const body = res.json();
expect(body).toMatchObject({
configured: false,
targetDir: null,
running: false,
lastSuccessAt: null,
lastError: null,
@@ -56,6 +57,71 @@ describe("GET /api/backup/status", () => {
});
});
describe("PUT /api/backup/config — admin-chosen target", () => {
it("403 for a user lacking backup:update", async () => {
const { username, password } = await seedUser(db, {
username: "viewer", roleId: "viewer", permissions: ["backup:read"],
});
const { cookie, csrf } = await login(app, username, password);
const res = await app.inject({
method: "PUT", url: "/api/backup/config",
headers: { cookie, "x-csrf-token": csrf },
payload: { targetDir: "/tmp/x" },
});
expect(res.statusCode).toBe(403);
});
it("persists the target dir and reflects it in status", async () => {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const { cookie, csrf } = await login(app, username, password);
const put = await app.inject({
method: "PUT", url: "/api/backup/config",
headers: { cookie, "x-csrf-token": csrf },
payload: { targetDir: " /mnt/backup " }, // trimmed server-side
});
expect(put.statusCode).toBe(200);
expect(put.json()).toMatchObject({ targetDir: "/mnt/backup" });
const status = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } });
expect(status.json().targetDir).toBe("/mnt/backup");
});
it("clears the target dir when given empty/null", async () => {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const { cookie, csrf } = await login(app, username, password);
await app.inject({
method: "PUT", url: "/api/backup/config",
headers: { cookie, "x-csrf-token": csrf }, payload: { targetDir: "/mnt/backup" },
});
const clear = await app.inject({
method: "PUT", url: "/api/backup/config",
headers: { cookie, "x-csrf-token": csrf }, payload: { targetDir: "" },
});
expect(clear.json().targetDir).toBeNull();
});
});
describe("POST /api/backup/test — path probe", () => {
it("reports ok for a writable directory and a reason for a missing one", async () => {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
const { cookie, csrf } = await login(app, username, password);
const good = await app.inject({
method: "POST", url: "/api/backup/test",
headers: { cookie, "x-csrf-token": csrf },
payload: { targetDir: process.cwd() }, // an existing, writable dir
});
expect(good.json()).toMatchObject({ ok: true });
const bad = await app.inject({
method: "POST", url: "/api/backup/test",
headers: { cookie, "x-csrf-token": csrf },
payload: { targetDir: "/no/such/path/here-xyz" },
});
expect(bad.json()).toMatchObject({ ok: false, reason: "missing" });
});
});
describe("POST /api/backup/run", () => {
it("403 for a user lacking backup:create", async () => {
const { username, password } = await seedUser(db, {
+48 -6
View File
@@ -1,18 +1,60 @@
import type { FastifyInstance } from "fastify";
import { eq, siteConfig, type Db } from "@parking/db";
import { requirePermission } from "../auth.js";
import type { BackupService } from "../backup-service.js";
import { checkTargetDir, type BackupService } from "../backup-service.js";
// On-site encrypted DB backup — admin-driven. See wiki/concepts/backup-recovery.md.
// - GET /api/backup/status : config-present flag + last-run success/error. (backup:read)
// - POST /api/backup/run : trigger a manual "back up now". (backup:create)
// RESTORE is intentionally absent — it's an out-of-band runbook action on a fresh appliance
// (a restore replaces the live signed chain → operator-adversary surface), never a console call.
// - GET /api/backup/status : config + last-run success/error. (backup:read)
// - PUT /api/backup/config : set the admin-chosen target directory. (backup:update)
// - POST /api/backup/test : probe a candidate path (exists/dir/writable). (backup:update)
// - POST /api/backup/run : trigger a manual "back up now". (backup:create)
// The target dir lives in site_config (admin picks it from the UI); the encryption key stays an
// env secret. RESTORE is intentionally absent — out-of-band runbook on a fresh appliance.
export async function backupRoutes(app: FastifyInstance, backups: BackupService): Promise<void> {
interface ConfigBody {
targetDir?: string | null;
}
interface TestBody {
targetDir?: string;
}
export async function backupRoutes(app: FastifyInstance, db: Db, backups: BackupService): Promise<void> {
app.get("/api/backup/status", { preHandler: requirePermission("backup:read") }, async () =>
backups.status(),
);
// Set (or clear) the target directory. Empty/null clears it (backups become a no-op).
app.put<{ Body: ConfigBody }>(
"/api/backup/config",
{ preHandler: requirePermission("backup:update") },
async (req, reply) => {
const raw = req.body?.targetDir;
if (raw != null && typeof raw !== "string") {
return reply.code(400).send({ error: "targetDir must be a string or null" });
}
const next = raw == null ? null : raw.trim() || null;
const updatedAt = new Date().toISOString();
// Single-row site_config (id=1): upsert, since a fresh install may not have it yet.
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (existing) {
db.update(siteConfig).set({ backupTargetDir: next, updatedAt }).where(eq(siteConfig.id, 1)).run();
} else {
db.insert(siteConfig).values({ id: 1, backupTargetDir: next, updatedAt }).run();
}
return backups.status();
},
);
// Probe a candidate path before relying on it (the UI "Test target" button).
app.post<{ Body: TestBody }>(
"/api/backup/test",
{ preHandler: requirePermission("backup:update") },
async (req) => {
const dir = typeof req.body?.targetDir === "string" ? req.body.targetDir : "";
return checkTargetDir(dir);
},
);
app.post("/api/backup/run", { preHandler: requirePermission("backup:create") }, async (_req, reply) => {
if (!backups.configured) {
return reply.code(409).send({ error: "backup_not_configured" });
+7 -6
View File
@@ -21,7 +21,7 @@ import { DeviceMonitor } from "./device-monitor.js";
import { buildSigner, buildVerifier } from "./signer.js";
import { LogService, pinoDbStream } from "./log-service.js";
import { pruneSnapshots } from "./snapshot-retention.js";
import { BackupService, backupConfigFromEnv } from "./backup-service.js";
import { BackupService } from "./backup-service.js";
import { backupRoutes } from "./routes/backup.js";
import { logRoutes } from "./routes/logs.js";
import { VisionClient } from "./vision-client.js";
@@ -276,11 +276,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
await logRoutes(app, logService);
// On-site encrypted DB backup (durability for the signed ledger). Admin-driven: status +
// a manual "back up now"; the scheduled run is the daily timer below. A no-op until
// BACKUP_TARGET_DIR + BACKUP_KEY are set. See wiki/concepts/backup-recovery.md.
const backupService = new BackupService(db, backupConfigFromEnv(), app.log);
await backupRoutes(app, backupService);
// On-site encrypted DB backup (durability for the signed ledger). Admin-driven: the target
// directory is admin-chosen (site_config), the key is an env secret; status + a manual "back
// up now"; the scheduled run is the daily timer below. A no-op until a target dir is set AND
// BACKUP_KEY is present. See wiki/concepts/backup-recovery.md.
const backupService = new BackupService(db, app.log);
await backupRoutes(app, db, backupService);
// Periodic retention prune (age + row cap) so the log table stays bounded on the
// offline appliance. Runs hourly; unref'd so it never holds the process open.
+236
View File
@@ -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>
);
}
+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 {
+37 -1
View File
@@ -61,6 +61,7 @@ export const en: Catalog = {
reports: "Reports",
recycleBin: "Recycle bin",
logs: "Logs",
backup: "Backup",
profile: "Profile",
},
profile: {
@@ -307,7 +308,7 @@ export const en: Catalog = {
setup: {
title: "Setup",
intro:
"Add your barrier controllers first — set which relay is entry/exit and which terminal the entry button is wired to. Then add readers, cameras and printers and point each at the barrier it serves.",
"Add your barrier controllers first. Then add readers (QR/RF), cameras, printers",
catControllers: "Controllers (barriers + entry button)",
catReaders: "Readers (QR / RFID)",
catCameras: "Cameras (snapshot + plate)",
@@ -830,6 +831,41 @@ export const en: Catalog = {
path: "Path",
empty: "No logs.",
},
backup: {
title: "Backup",
intro:
"An encrypted copy of the database (the signed ledger) to an external disk. Runs automatically every day and from the button below.",
statusTitle: "Status",
configured: "Enabled",
notConfigured: "Not configured",
notConfiguredHint: "Set BACKUP_TARGET_DIR and BACKUP_KEY on the server to enable backups.",
running: "Running…",
idle: "Idle",
lastSuccess: "Last successful backup",
lastError: "Last error",
never: "Never",
lastFile: "File",
size: "Size",
pruned: "Pruned",
runNow: "Back up now",
runSuccess: "Backup complete.",
runFailed: "Backup failed.",
notConfiguredError: "Backup is not configured.",
restoreNote:
"Restore is not done here — it's an out-of-band step when provisioning a fresh appliance (needs the backup file + the keys you escrowed offsite).",
targetLabel: "Backup location",
targetPlaceholder: "e.g. /mnt/backup or /media/usb",
targetHint: "An absolute path to a mounted disk (USB/SATA) or a network share (SMB/NFS).",
save: "Save",
saved: "Saved.",
test: "Test target",
testOk: "The location is writable.",
testEmpty: "Enter a path.",
testMissing: "The location does not exist.",
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.",
},
pay: {
ticket: "Ticket",
entry: "Entry",
+38 -1
View File
@@ -63,6 +63,7 @@ export const sq = {
reports: "Raportet",
recycleBin: "Koshi",
logs: "Loget",
backup: "Kopje rezervë",
profile: "Profili",
},
profile: {
@@ -310,7 +311,7 @@ export const sq = {
setup: {
title: "Konfigurimi",
intro:
"Shto fillimisht kontrollerat e barrierave — cakto cili rele është hyrje/dalje dhe në cilin terminal është lidhur butoni i hyrjes. Pastaj shto lexues, kamera dhe printera dhe drejto secilin te barriera që shërben.",
"Shto fillimisht kontrollerat e barrierave — Pastaj shto lexues (QR/RF), kamera, printera.",
// Category titles + the singular noun used in buttons/modal titles.
catControllers: "Kontrollerat (barrierat + butoni i hyrjes)",
catReaders: "Lexuesit (QR / RFID)",
@@ -845,6 +846,42 @@ export const sq = {
path: "Rruga",
empty: "Asnjë regjistër.",
},
backup: {
title: "Kopje rezervë",
intro:
"Kopje e enkriptuar e bazës së të dhënave (regjistri i nënshkruar) në një disk të jashtëm. Bëhet automatikisht çdo ditë dhe me butonin më poshtë.",
statusTitle: "Gjendja",
configured: "Aktive",
notConfigured: "E pakonfiguruar",
notConfiguredHint:
"Cakto BACKUP_TARGET_DIR dhe BACKUP_KEY në server që të aktivizohet kopja rezervë.",
running: "Duke u kryer…",
idle: "Në pritje",
lastSuccess: "Kopja e fundit e suksesshme",
lastError: "Gabimi i fundit",
never: "Asnjëherë",
lastFile: "Skedari",
size: "Madhësia",
pruned: "Të hequra",
runNow: "Bëj kopje tani",
runSuccess: "Kopja rezervë u krye.",
runFailed: "Kopja rezervë dështoi.",
notConfiguredError: "Kopja rezervë nuk është e konfiguruar.",
restoreNote:
"Rikthimi nuk bëhet nga këtu — është veprim i jashtëm gjatë instalimit të një aparati të ri (kërkon skedarin e kopjes + çelësat e ruajtur jashtë).",
targetLabel: "Vendndodhja e kopjes",
targetPlaceholder: "p.sh. /mnt/backup ose /media/usb",
targetHint: "Rrugë absolute drejt një disku të lidhur (USB/SATA) ose një ndarjeje rrjeti (SMB/NFS).",
save: "Ruaj",
saved: "U ruajt.",
test: "Testo vendndodhjen",
testOk: "Vendndodhja është e shkruajtshme.",
testEmpty: "Shkruaj një rrugë.",
testMissing: "Vendndodhja nuk ekziston.",
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.",
},
pay: {
ticket: "Bileta",
entry: "Hyrja",
+12
View File
@@ -42,6 +42,7 @@ import { UsersManager } from "./UsersManager.js";
import { RolesManager } from "./RolesManager.js";
import { ShiftsHistory } from "./ShiftsHistory.js";
import { LogsViewer } from "./LogsViewer.js";
import { BackupSettings } from "./BackupSettings.js";
import { RecycleBin } from "./RecycleBin.js";
import { Profile } from "./Profile.js";
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
@@ -106,6 +107,7 @@ function SetupLayout() {
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
</nav>
<Outlet />
</div>
@@ -444,6 +446,7 @@ function RootLayout() {
show("user:read") ||
show("role:read") ||
show("recyclebin:read") ||
show("backup:read") ||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
</nav>
<div className="ml-auto flex items-center gap-3">
@@ -694,6 +697,14 @@ const logsRoute = createRoute({
component: LogsViewer,
});
// Encrypted DB backup — status + manual run. Gated by backup:read (run by backup:create).
const backupRoute = createRoute({
getParentRoute: () => setupRoute,
path: "backup",
beforeLoad: ({ context }) => requirePerm("backup:read")(context),
component: BackupSettings,
});
// My profile — self-service for ANY signed-in user (no permission gate). Edits only
// the caller's own name/email/password. See Profile.tsx and routes/auth.ts.
const profileRoute = createRoute({
@@ -726,6 +737,7 @@ const routeTree = rootRoute.addChildren([
rolesRoute,
recycleBinRoute,
logsRoute,
backupRoute,
]),
]);