diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 9b2dfdc62f65..cd863a86e8ef 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -912,8 +912,8 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `TAILSCALE_BIN` | _(auto-detect)_ | `src/lib/tailscaleTunnel.ts` | Explicit path to the `tailscale` binary. | | `TAILSCALED_BIN` | _(auto-detect)_ | `src/lib/tailscaleTunnel.ts` | Explicit path to the `tailscaled` daemon binary. | | `NGROK_AUTHTOKEN` | _(unset)_ | `src/lib/ngrokTunnel.ts` | Authenticates outbound ngrok tunnels. | -| `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts` | Maximum SQLite backup files retained on disk. | -| `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts` | Maximum age (days) of retained backups. `0` disables age-based pruning. | +| `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts` | Maximum SQLite backup files retained on disk. Overrides the value saved from Settings → Database backup retention. | +| `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts` | Maximum age (days) of retained backups. `0` disables age-based pruning. Overrides the value saved from Settings → Database backup retention. | | `OMNIROUTE_TLS_PROXY_URL` | _(unset)_ | `open-sse/services/chatgptTlsClient.ts` | Override the TLS sidecar URL for tests. Production should leave unset. | | `CONTAINER_HOST` | `docker` | `scripts/check-permissions.sh` | Container runtime hint for the entrypoint permission check. Set to `podman` under rootless Podman so the fix instructions use `podman unshare chown` instead of `sudo chown`. | | `QUOTA_STORE_DRIVER` | `sqlite` | `src/lib/quota/storeFactory.ts` | Quota-share consumption store backend: `sqlite` (default) or `redis`. | diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index 06161d5e5a62..cf6afb040fe9 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -3496,6 +3496,27 @@ paths: responses: "200": description: Backup created + patch: + tags: [System] + summary: Save database backup retention settings + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + keepLatest: + type: integer + minimum: 1 + maximum: 200 + retentionDays: + type: integer + minimum: 0 + maximum: 3650 + responses: + "200": + description: Backup retention settings saved /api/storage/health: get: diff --git a/src/app/(dashboard)/dashboard/settings/components/DatabaseBackupRetentionCard.tsx b/src/app/(dashboard)/dashboard/settings/components/DatabaseBackupRetentionCard.tsx new file mode 100644 index 000000000000..35900e0b3b9f --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/DatabaseBackupRetentionCard.tsx @@ -0,0 +1,162 @@ +"use client"; + +import type { Dispatch, SetStateAction } from "react"; +import { Badge, Button } from "@/shared/components"; + +export type BackupCleanupOptions = { + keepLatest: number; + retentionDays: number; +}; + +export type BackupRetentionStatus = { + type: string; + message: string; +}; + +export type StorageBackupHealth = { + backupCount?: number; + backupRetention: { + maxFiles: number; + days: number; + }; +}; + +type DatabaseBackupRetentionCardProps = { + title: string; + storageHealth: StorageBackupHealth; + backupCleanupOptions: BackupCleanupOptions; + setBackupCleanupOptions: Dispatch>; + saveBackupRetentionLoading: boolean; + backupRetentionStatus: BackupRetentionStatus; + setBackupRetentionStatus: Dispatch>; + cleanupBackupsLoading: boolean; + cleanupBackupsStatus: BackupRetentionStatus; + onSaveRetention: () => void; + onCleanupBackups: () => void; +}; + +function StatusAlert({ status }: { status: BackupRetentionStatus }) { + if (!status.message) return null; + + const isSuccess = status.type === "success"; + return ( +
+
+ + {status.message} +
+
+ ); +} + +export default function DatabaseBackupRetentionCard({ + title, + storageHealth, + backupCleanupOptions, + setBackupCleanupOptions, + saveBackupRetentionLoading, + backupRetentionStatus, + setBackupRetentionStatus, + cleanupBackupsLoading, + cleanupBackupsStatus, + onSaveRetention, + onCleanupBackups, +}: DatabaseBackupRetentionCardProps) { + return ( +
+
+
+

{title}

+

+ Automatic SQLite backups are stored in db_backups. Configure how many + snapshots to keep and optionally delete backups older than N days. +

+
+
+ + {storageHealth.backupCount || 0} backups + + + Max {storageHealth.backupRetention.maxFiles} + + + {storageHealth.backupRetention.days > 0 + ? `${storageHealth.backupRetention.days}d retention` + : "Age retention off"} + +
+
+
+ + + + +
+ + +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx b/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx index 305c9b1939e7..ba2d7e861f44 100644 --- a/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, Badge, Toggle } from "@/shared/components"; import { useLocale, useTranslations } from "next-intl"; +import DatabaseBackupRetentionCard from "./DatabaseBackupRetentionCard"; const rowCountFormatter = new Intl.NumberFormat("en-US"); @@ -30,6 +31,8 @@ export default function SystemStorageTab() { const [purgeLogsStatus, setPurgeLogsStatus] = useState({ type: "", message: "" }); const [cleanupBackupsLoading, setCleanupBackupsLoading] = useState(false); const [cleanupBackupsStatus, setCleanupBackupsStatus] = useState({ type: "", message: "" }); + const [saveBackupRetentionLoading, setSaveBackupRetentionLoading] = useState(false); + const [backupRetentionStatus, setBackupRetentionStatus] = useState({ type: "", message: "" }); const [purgeQuotaSnapshotsLoading, setPurgeQuotaSnapshotsLoading] = useState(false); const [purgeQuotaSnapshotsStatus, setPurgeQuotaSnapshotsStatus] = useState({ type: "", @@ -154,6 +157,35 @@ export default function SystemStorageTab() { } }; + const handleSaveBackupRetention = async () => { + setSaveBackupRetentionLoading(true); + setBackupRetentionStatus({ type: "", message: "" }); + try { + const res = await fetch("/api/db-backups", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(backupCleanupOptions), + }); + const data = await res.json(); + if (res.ok) { + setBackupRetentionStatus({ + type: "success", + message: "Backup retention saved.", + }); + await loadStorageHealth(); + } else { + setBackupRetentionStatus({ + type: "error", + message: data.error || "Failed to save backup retention", + }); + } + } catch { + setBackupRetentionStatus({ type: "error", message: t("errorOccurred") }); + } finally { + setSaveBackupRetentionLoading(false); + } + }; + const handleCleanupBackups = async () => { setCleanupBackupsLoading(true); setCleanupBackupsStatus({ type: "", message: "" }); @@ -663,96 +695,19 @@ export default function SystemStorageTab() { -
-
-
-

- {t("storageDatabaseBackupRetention")} -

-

- Automatic SQLite backups are stored in db_backups. Configure how many - snapshots to keep and optionally delete backups older than N days. -

-
-
- - {storageHealth.backupCount || 0} backups - - - Max {storageHealth.backupRetention.maxFiles} - - - {storageHealth.backupRetention.days > 0 - ? `${storageHealth.backupRetention.days}d retention` - : "Age retention off"} - -
-
-
- - - -
- {cleanupBackupsStatus.message && ( -
-
- - {cleanupBackupsStatus.message} -
-
- )} -
+ {/* Export / Import */}
diff --git a/src/app/api/db-backups/route.ts b/src/app/api/db-backups/route.ts index 135811bcc5f4..04661e7d3779 100644 --- a/src/app/api/db-backups/route.ts +++ b/src/app/api/db-backups/route.ts @@ -7,12 +7,36 @@ import { getDbBackupMaxFiles, setDbBackupMaxFiles, getDbBackupRetentionDays, + setDbBackupRetentionDays, } from "@/lib/localDb"; import { dbBackupCleanupSchema, dbBackupRestoreSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { isAuthenticated } from "@/shared/utils/apiAuth"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +async function readOptionalJsonBody(request: NextRequest | Request): Promise { + try { + const text = await request.text(); + return text.trim() ? JSON.parse(text) : {}; + } catch { + throw new Error("Invalid JSON body"); + } +} + +function persistDbBackupRetentionSettings(input: { keepLatest?: number; retentionDays?: number }) { + const keepLatest = input.keepLatest ?? getDbBackupMaxFiles(); + const retentionDays = input.retentionDays ?? getDbBackupRetentionDays(); + + if (input.keepLatest !== undefined) { + setDbBackupMaxFiles(input.keepLatest); + } + if (input.retentionDays !== undefined) { + setDbBackupRetentionDays(input.retentionDays); + } + + return { keepLatest, retentionDays }; +} + /** * PUT /api/db-backups — Trigger a manual backup snapshot. * Security: Requires admin authentication. @@ -93,18 +117,17 @@ export async function POST(request: NextRequest) { } /** - * DELETE /api/db-backups — Cleanup old database backups. + * PATCH /api/db-backups — Save database backup retention settings. * Body: { keepLatest?: number, retentionDays?: number } */ -export async function DELETE(request) { +export async function PATCH(request: NextRequest) { if (!(await isAuthenticated(request))) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - let rawBody = {}; + let rawBody: unknown = {}; try { - const text = await request.text(); - if (text.trim()) rawBody = JSON.parse(text); + rawBody = await readOptionalJsonBody(request); } catch { return NextResponse.json( { @@ -123,13 +146,47 @@ export async function DELETE(request) { return NextResponse.json({ error: validation.error }, { status: 400 }); } - const keepLatest = validation.data.keepLatest ?? getDbBackupMaxFiles(); - const retentionDays = validation.data.retentionDays ?? getDbBackupRetentionDays(); - // #3834: persist the operator's chosen retention so it survives the page refresh - // and the subsequent loadStorageHealth() refetch (it previously snapped back to 20). - if (validation.data.keepLatest !== undefined) { - setDbBackupMaxFiles(validation.data.keepLatest); + return NextResponse.json({ + saved: true, + ...persistDbBackupRetentionSettings(validation.data), + }); + } catch (error) { + console.error("[API] Error saving DB backup retention settings:", error); + return NextResponse.json({ error: sanitizeErrorMessage(error) }, { status: 500 }); + } +} + +/** + * DELETE /api/db-backups — Cleanup old database backups. + * Body: { keepLatest?: number, retentionDays?: number } + */ +export async function DELETE(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let rawBody: unknown = {}; + try { + rawBody = await readOptionalJsonBody(request); + } catch { + return NextResponse.json( + { + error: { + message: "Invalid request", + details: [{ field: "body", message: "Invalid JSON body" }], + }, + }, + { status: 400 } + ); + } + + try { + const validation = validateBody(dbBackupCleanupSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); } + + const { keepLatest, retentionDays } = persistDbBackupRetentionSettings(validation.data); const result = cleanupDbBackups({ maxFiles: keepLatest, retentionDays }); return NextResponse.json({ cleaned: true, diff --git a/src/lib/db/backup.ts b/src/lib/db/backup.ts index 87421ca0c3b6..a4ecb10b34d8 100644 --- a/src/lib/db/backup.ts +++ b/src/lib/db/backup.ts @@ -45,28 +45,35 @@ function parseNonNegativeInt(value: string | undefined, fallback: number) { // historical default of 20 until an operator explicitly changes it here. const DB_BACKUP_SETTINGS_NAMESPACE = "dbBackup"; const DB_BACKUP_MAX_FILES_KEY = "maxFiles"; +const DB_BACKUP_RETENTION_DAYS_KEY = "retentionDays"; -function getStoredDbBackupMaxFiles(): number | undefined { +function getStoredDbBackupInteger(key: string, options: { min: number }): number | undefined { try { const db = getDbInstance(); const row = db .prepare("SELECT value FROM key_value WHERE namespace = ? AND key = ?") - .get(DB_BACKUP_SETTINGS_NAMESPACE, DB_BACKUP_MAX_FILES_KEY) as { value?: string } | undefined; + .get(DB_BACKUP_SETTINGS_NAMESPACE, key) as { value?: string } | undefined; if (!row?.value) return undefined; - const parsed = Number.parseInt(JSON.parse(row.value), 10); - return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; + const parsed = JSON.parse(row.value); + return Number.isInteger(parsed) && parsed >= options.min ? parsed : undefined; } catch { return undefined; } } +function setStoredDbBackupInteger(key: string, value: number, options: { min: number }): void { + if (!Number.isInteger(value) || value < options.min) return; + const db = getDbInstance(); + db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run( + DB_BACKUP_SETTINGS_NAMESPACE, + key, + JSON.stringify(value) + ); +} + /** Persist the operator-chosen "keep latest backups" retention count (#3834). */ export function setDbBackupMaxFiles(value: number): void { - if (!Number.isInteger(value) || value <= 0) return; - const db = getDbInstance(); - db.prepare( - "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)" - ).run(DB_BACKUP_SETTINGS_NAMESPACE, DB_BACKUP_MAX_FILES_KEY, JSON.stringify(value)); + setStoredDbBackupInteger(DB_BACKUP_MAX_FILES_KEY, value, { min: 1 }); } export function getDbBackupMaxFiles() { @@ -74,12 +81,24 @@ export function getDbBackupMaxFiles() { if (process.env.DB_BACKUP_MAX_FILES) { return parsePositiveInt(process.env.DB_BACKUP_MAX_FILES, MAX_DB_BACKUPS); } - return getStoredDbBackupMaxFiles() ?? MAX_DB_BACKUPS; + return getStoredDbBackupInteger(DB_BACKUP_MAX_FILES_KEY, { min: 1 }) ?? MAX_DB_BACKUPS; +} + +/** Persist the operator-chosen age-based backup retention window. */ +export function setDbBackupRetentionDays(value: number): void { + setStoredDbBackupInteger(DB_BACKUP_RETENTION_DAYS_KEY, value, { min: 0 }); } export function getDbBackupRetentionDays() { - return parseNonNegativeInt( - process.env.DB_BACKUP_RETENTION_DAYS, + // Precedence: DB_BACKUP_RETENTION_DAYS env override (ops) → persisted UI value → default. + if (process.env.DB_BACKUP_RETENTION_DAYS) { + return parseNonNegativeInt( + process.env.DB_BACKUP_RETENTION_DAYS, + DEFAULT_DB_BACKUP_RETENTION_DAYS + ); + } + return ( + getStoredDbBackupInteger(DB_BACKUP_RETENTION_DAYS_KEY, { min: 0 }) ?? DEFAULT_DB_BACKUP_RETENTION_DAYS ); } @@ -561,9 +580,9 @@ export function exportAllSummaryRows(): ExportAllRows { export function getTableNamesFromAdapter(adapter: { prepare: (sql: string) => { all: () => unknown[] }; }): string[] { - const rows = adapter - .prepare("SELECT name FROM sqlite_master WHERE type='table'") - .all() as Array<{ name: string }>; + const rows = adapter.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ + name: string; + }>; return rows.map((r) => r.name); } @@ -582,9 +601,7 @@ export function countImportedRows(): { (db.prepare("SELECT COUNT(*) as cnt FROM provider_connections").get() as any)?.cnt || 0; const nodeCount = (db.prepare("SELECT COUNT(*) as cnt FROM provider_nodes").get() as any)?.cnt || 0; - const comboCount = - (db.prepare("SELECT COUNT(*) as cnt FROM combos").get() as any)?.cnt || 0; - const keyCount = - (db.prepare("SELECT COUNT(*) as cnt FROM api_keys").get() as any)?.cnt || 0; + const comboCount = (db.prepare("SELECT COUNT(*) as cnt FROM combos").get() as any)?.cnt || 0; + const keyCount = (db.prepare("SELECT COUNT(*) as cnt FROM api_keys").get() as any)?.cnt || 0; return { connCount, nodeCount, comboCount, keyCount }; } diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 9901dd7b849b..7a6911a71b8b 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -202,6 +202,7 @@ export { getDbBackupMaxFiles, setDbBackupMaxFiles, getDbBackupRetentionDays, + setDbBackupRetentionDays, listDbBackups, restoreDbBackup, // Export-All / Import helpers (#3500 slice 5) diff --git a/tests/unit/db-backup-extended.test.ts b/tests/unit/db-backup-extended.test.ts index a0b6acb935b5..50661b2b1166 100644 --- a/tests/unit/db-backup-extended.test.ts +++ b/tests/unit/db-backup-extended.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import type { NextRequest } from "next/server"; const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-backup-")); const isWindows = process.platform === "win32"; @@ -10,6 +11,7 @@ process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); const backupDb = await import("../../src/lib/db/backup.ts"); +const dbBackupsRoute = await import("../../src/app/api/db-backups/route.ts"); async function resetStorage() { core.resetDbInstance(); @@ -49,6 +51,14 @@ async function waitForFile(filePath) { throw new Error(`Timed out waiting for file: ${filePath}`); } +function makeDbBackupsJsonRequest(method: string, body: unknown): NextRequest { + return new Request("http://localhost/api/db-backups", { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) as unknown as NextRequest; +} + test.beforeEach(async () => { await resetStorage(); }); @@ -118,7 +128,7 @@ test("restoreDbBackup restores SQLite contents and returns entity counts", async const row = core .getDbInstance() .prepare("SELECT COUNT(*) AS cnt FROM provider_connections WHERE id = ?") - .get("backup-conn-0"); + .get("backup-conn-0") as { cnt: number }; assert.equal(restored.restored, true); assert.equal(restored.backupId, backupId); @@ -126,7 +136,7 @@ test("restoreDbBackup restores SQLite contents and returns entity counts", async assert.equal(restored.nodeCount, 0); assert.equal(restored.comboCount, 0); assert.equal(restored.apiKeyCount, 0); - assert.equal((row as any).cnt, 1); + assert.equal(row.cnt, 1); }); test("cleanupDbBackups removes overflow families and orphaned sidecars", async () => { @@ -222,3 +232,80 @@ test("DB_BACKUP_MAX_FILES env override wins over the persisted value (#3834)", ( delete process.env.DB_BACKUP_MAX_FILES; } }); + +test("getDbBackupRetentionDays defaults to 0 when nothing is stored", () => { + delete process.env.DB_BACKUP_RETENTION_DAYS; + core.getDbInstance(); + assert.equal(backupDb.getDbBackupRetentionDays(), 0); +}); + +test("setDbBackupRetentionDays persists zero and positive values", () => { + delete process.env.DB_BACKUP_RETENTION_DAYS; + core.getDbInstance(); + backupDb.setDbBackupRetentionDays(0); + assert.equal(backupDb.getDbBackupRetentionDays(), 0); + + backupDb.setDbBackupRetentionDays(14); + assert.equal(backupDb.getDbBackupRetentionDays(), 14); +}); + +test("stored backup retention values must be JSON integers", () => { + delete process.env.DB_BACKUP_RETENTION_DAYS; + core + .getDbInstance() + .prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)") + .run("dbBackup", "retentionDays", JSON.stringify([14, 2])); + + assert.equal(backupDb.getDbBackupRetentionDays(), 0); +}); + +test("DB_BACKUP_RETENTION_DAYS env override wins over the persisted value", () => { + core.getDbInstance(); + backupDb.setDbBackupRetentionDays(14); + process.env.DB_BACKUP_RETENTION_DAYS = "3"; + try { + assert.equal(backupDb.getDbBackupRetentionDays(), 3); + } finally { + delete process.env.DB_BACKUP_RETENTION_DAYS; + } +}); + +test("PATCH /api/db-backups persists retention controls without cleanup", async () => { + delete process.env.DB_BACKUP_MAX_FILES; + delete process.env.DB_BACKUP_RETENTION_DAYS; + fs.mkdirSync(core.DB_BACKUPS_DIR, { recursive: true }); + + const oldBackup = path.join(core.DB_BACKUPS_DIR, "db_2026-04-01T00-00-00-000Z_manual.sqlite"); + fs.writeFileSync(oldBackup, "old"); + const oldTime = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); + fs.utimesSync(oldBackup, oldTime, oldTime); + + const response = await dbBackupsRoute.PATCH( + makeDbBackupsJsonRequest("PATCH", { keepLatest: 9, retentionDays: 5 }) + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.saved, true); + assert.equal(body.keepLatest, 9); + assert.equal(body.retentionDays, 5); + assert.equal(backupDb.getDbBackupMaxFiles(), 9); + assert.equal(backupDb.getDbBackupRetentionDays(), 5); + assert.equal(fs.existsSync(oldBackup), true); +}); + +test("DELETE /api/db-backups persists both retention controls", async () => { + delete process.env.DB_BACKUP_MAX_FILES; + delete process.env.DB_BACKUP_RETENTION_DAYS; + + const response = await dbBackupsRoute.DELETE( + makeDbBackupsJsonRequest("DELETE", { keepLatest: 11, retentionDays: 17 }) + ); + const body = await response.json(); + + assert.equal(response.status, 200); + assert.equal(body.keepLatest, 11); + assert.equal(body.retentionDays, 17); + assert.equal(backupDb.getDbBackupMaxFiles(), 11); + assert.equal(backupDb.getDbBackupRetentionDays(), 17); +});