From 74697f20afce716963ccfa9fe1f027421f58057b Mon Sep 17 00:00:00 2001 From: Manu Date: Tue, 30 Jun 2026 19:25:03 +0200 Subject: [PATCH 01/15] Fix missing backup alert false positives Use the latest successful backup execution as the primary signal for missing-backup alerts, with snapshot count-change history only as a fallback when no executions exist. This prevents false alerts when retention keeps file counts flat even though backups continue to run. Updated the changelog under vNEXT with the bug-fix entry and Docker tags. --- docs/changelog.md | 15 +++++ src/services/storage/storage-alert-service.ts | 65 ++++++++++++------- 2 files changed, 58 insertions(+), 22 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index e18eb084..e2b9f5fb 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,21 @@ All notable changes to DBackup are documented here. +## vNEXT +*Release: In Progress* + +### ๐Ÿ› Bug Fixes + +- **storage alerts**: Fixed "Missing Backup" alert firing incorrectly when a retention policy keeps the file count stable, making the alert appear even though backups were completing successfully. + +### ๐Ÿณ Docker + +- **Image**: `skyfay/dbackup:vNEXT` +- **Also tagged as**: `latest`, `vNEXT` +- **CI Image**: `skyfay/dbackup:ci` +- **Platforms**: linux/amd64, linux/arm64 + + ## v2.8.0 - Notification Templates, Per-Job Event Filters, and Multiple Bug Fixes *Released: June 28, 2026* diff --git a/src/services/storage/storage-alert-service.ts b/src/services/storage/storage-alert-service.ts index b2f1e590..66e59fbc 100644 --- a/src/services/storage/storage-alert-service.ts +++ b/src/services/storage/storage-alert-service.ts @@ -366,7 +366,12 @@ async function checkStorageLimit( } /** - * Check if too much time has passed since the last backup count increase. + * Check if too much time has passed since the last successful backup to this destination. + * + * Primary source: Execution table - reliable regardless of retention policy behavior. + * When retention deletes old files as new ones arrive, the net file count stays constant + * between hourly snapshots, making count-change detection unreliable as the sole signal. + * Fallback: snapshot count-change history for destinations with no execution records. */ async function checkMissingBackup( entry: StorageVolumeEntry, @@ -376,35 +381,51 @@ async function checkMissingBackup( ): Promise { if (config.missingBackupHours <= 0) return; - // Find the most recent snapshot where count was different (a backup was added) - const snapshots = await prisma.storageSnapshot.findMany({ - where: { adapterConfigId: entry.configId! }, - orderBy: { createdAt: "desc" }, - take: 100, - select: { count: true, createdAt: true }, + let lastBackupAt: Date | null = null; + + // Primary: most recent successful Backup execution for any job targeting this destination. + const latestExecution = await prisma.execution.findFirst({ + where: { + type: "Backup", + status: { in: ["Success", "Partial"] }, + job: { + destinations: { some: { configId: entry.configId! } }, + }, + }, + orderBy: { startedAt: "desc" }, + select: { startedAt: true, endedAt: true }, }); - if (snapshots.length < 2) return; + if (latestExecution) { + lastBackupAt = latestExecution.endedAt ?? latestExecution.startedAt; + } else { + // Fallback: snapshot count-change history (covers externally-created backups not tracked in Execution). + const snapshots = await prisma.storageSnapshot.findMany({ + where: { adapterConfigId: entry.configId! }, + orderBy: { createdAt: "desc" }, + take: 100, + select: { count: true, createdAt: true }, + }); - // Find the first snapshot with a count change (i.e. the last time a new backup appeared) - const currentCount = snapshots[0].count; - let lastChangeAt: Date | null = null; + if (snapshots.length < 2) return; - for (let i = 1; i < snapshots.length; i++) { - if (snapshots[i].count !== currentCount) { - // The change happened between snapshot i and i-1 - lastChangeAt = snapshots[i - 1].createdAt; - break; + const currentCount = snapshots[0].count; + for (let i = 1; i < snapshots.length; i++) { + if (snapshots[i].count !== currentCount) { + lastBackupAt = snapshots[i - 1].createdAt; + break; + } } - } - // If no count change found in history, use the oldest snapshot as reference - if (!lastChangeAt) { - lastChangeAt = snapshots[snapshots.length - 1].createdAt; + if (!lastBackupAt) { + lastBackupAt = snapshots[snapshots.length - 1].createdAt; + } } + if (!lastBackupAt) return; + const hoursSinceLastBackup = - (Date.now() - lastChangeAt.getTime()) / (1000 * 60 * 60); + (Date.now() - lastBackupAt.getTime()) / (1000 * 60 * 60); if (hoursSinceLastBackup >= config.missingBackupHours) { if (shouldNotify(states.missingBackup, cooldownMs)) { @@ -418,7 +439,7 @@ async function checkMissingBackup( eventType: NOTIFICATION_EVENTS.STORAGE_MISSING_BACKUP, data: { storageName: entry.name, - lastBackupAt: lastChangeAt.toISOString(), + lastBackupAt: lastBackupAt.toISOString(), thresholdHours: config.missingBackupHours, hoursSinceLastBackup: Math.round(hoursSinceLastBackup), timestamp: new Date().toISOString(), From 2df78f251e1e2eab2e78c32f214e31aa4bf40bb4 Mon Sep 17 00:00:00 2001 From: Manu Date: Tue, 30 Jun 2026 20:01:21 +0200 Subject: [PATCH 02/15] Add Valkey as a supported database source Adds Valkey (Redis-compatible fork) as a first-class database adapter. The ValkeyAdapter reuses the Redis implementation with a distinct id/name. Includes: adapter registration, icon, form/UI wiring, backup extension, credential requirements, adapter permissions, version detection (prefers valkey_version over redis_version), test infrastructure (docker-compose + integration test config), seed script, and documentation (changelog, user guide, valkey.md). --- README.md | 19 +++++---- docker-compose.test.yml | 17 ++++++++ docs/changelog.md | 8 ++++ docs/user-guide/sources/index.md | 21 +++++----- docs/user-guide/sources/redis.md | 6 ++- docs/user-guide/sources/valkey.md | 42 +++++++++++++++++++ scripts/seed-test-sources.ts | 1 + src/app/api/storage/[id]/analyze/route.ts | 2 +- .../storage/restore/restore-client.tsx | 4 +- src/components/adapter/adapter-manager.tsx | 1 + src/components/adapter/form-constants.ts | 1 + src/components/adapter/form-sections.tsx | 6 +-- src/components/adapter/utils.ts | 8 ++++ .../explorer/database-table-data.tsx | 3 +- src/components/dashboard/jobs/job-form.tsx | 2 +- .../dashboard/setup/steps/job-step.tsx | 2 +- src/lib/adapters/database/redis/connection.ts | 12 +++--- src/lib/adapters/database/valkey/index.ts | 10 +++++ src/lib/adapters/definitions/index.ts | 1 + src/lib/adapters/index.ts | 2 + src/lib/auth/adapter-permissions.ts | 1 + src/lib/backup-extensions.ts | 2 + src/lib/core/credential-requirements.ts | 1 + tests/integration/test-configs.ts | 11 +++++ 24 files changed, 149 insertions(+), 34 deletions(-) create mode 100644 docs/user-guide/sources/valkey.md create mode 100644 src/lib/adapters/database/valkey/index.ts diff --git a/README.md b/README.md index e4ccca97..a6cbf8c5 100644 --- a/README.md +++ b/README.md @@ -159,15 +159,16 @@ Open [https://localhost:3000](https://localhost:3000) and create your admin acco ## ๐Ÿ—„๏ธ Supported Databases -| Database | Versions | Connection Modes | -| :--- | :--- | :--- | -| PostgreSQL | 12, 13, 14, 15, 16, 17, 18 | Direct, SSH | -| MySQL | 5.7, 8.x, 9.x | Direct, SSH | -| MariaDB | 10.x, 11.x | Direct, SSH | -| MongoDB | 4.x, 5.x, 6.x, 7.x, 8.x | Direct, SSH | -| Redis | 6.x, 7.x, 8.x | Direct, SSH | -| SQLite | 3.x | Local, SSH | -| Microsoft SQL Server | 2017, 2019, 2022, Azure SQL Edge | Direct (+ SSH for file transfer) | +| Database | Versions | Connection Modes | Restore | +| :--- | :--- | :--- | :--- | +| PostgreSQL | 12, 13, 14, 15, 16, 17, 18 | Direct, SSH | Yes | +| MySQL | 5.7, 8.x, 9.x | Direct, SSH | Yes | +| MariaDB | 10.x, 11.x | Direct, SSH | Yes | +| MongoDB | 4.x, 5.x, 6.x, 7.x, 8.x | Direct, SSH | Yes | +| Redis | 2.8+ | Direct, SSH | Manual | +| Valkey | 7.2+ | Direct, SSH | Manual | +| SQLite | 3.x | Local, SSH | Yes | +| Microsoft SQL Server | 2017, 2019, 2022, Azure SQL Edge | Direct (+ SSH for file transfer) | Yes | ## โ˜๏ธ Supported Destinations diff --git a/docker-compose.test.yml b/docker-compose.test.yml index bdee8f04..c495ff73 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -384,3 +384,20 @@ services: interval: 5s timeout: 5s retries: 5 + + # ========================================== + # Valkey Family + # ========================================== + + # Valkey 8 (Current Stable) + valkey-8: + image: valkey/valkey:8-alpine + container_name: dbm-test-valkey-8 + command: valkey-server --requirepass testpassword + ports: + - "63780:6379" + healthcheck: + test: ["CMD", "valkey-cli", "-a", "testpassword", "ping"] + interval: 5s + timeout: 5s + retries: 5 diff --git a/docs/changelog.md b/docs/changelog.md index e2b9f5fb..11706dd5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,6 +5,14 @@ All notable changes to DBackup are documented here. ## vNEXT *Release: In Progress* +### โœจ Features + +- **Valkey**: Added Valkey as a supported database source. Uses the same RDB backup mechanism as Redis with correct version display. + +### ๐Ÿ“ Documentation + +- **Redis**: Updated supported version documentation from `6.x` to `2.8+` to reflect actual compatibility. + ### ๐Ÿ› Bug Fixes - **storage alerts**: Fixed "Missing Backup" alert firing incorrectly when a retention policy keeps the file count stable, making the alert appear even though backups were completing successfully. diff --git a/docs/user-guide/sources/index.md b/docs/user-guide/sources/index.md index c715d118..0510a797 100644 --- a/docs/user-guide/sources/index.md +++ b/docs/user-guide/sources/index.md @@ -4,15 +4,16 @@ DBackup supports a wide variety of database engines. ## Supported Databases -| Database | Supported Versions | Backup Method | -| :--- | :--- | :--- | -| [MySQL](/user-guide/sources/mysql) | 5.7, 8.x, 9.x | `mysqldump` | -| [MariaDB](/user-guide/sources/mysql) | 10.x, 11.x | `mariadb-dump` | -| [PostgreSQL](/user-guide/sources/postgresql) | 12 โ€“ 18 | `pg_dump` | -| [MongoDB](/user-guide/sources/mongodb) | 4.x โ€“ 8.x | `mongodump` | -| [Redis](/user-guide/sources/redis) | 6.x, 7.x, 8.x | `redis-cli --rdb` | -| [SQLite](/user-guide/sources/sqlite) | 3.x | `.dump` command | -| [MSSQL](/user-guide/sources/mssql) | 2017, 2019, 2022 | `BACKUP DATABASE` | +| Database | Supported Versions | Backup Method | Restore | +| :--- | :--- | :--- | :--- | +| [MySQL](/user-guide/sources/mysql) | 5.7, 8.x, 9.x | `mysqldump` | โœ… | +| [MariaDB](/user-guide/sources/mysql) | 10.x, 11.x | `mariadb-dump` | โœ… | +| [PostgreSQL](/user-guide/sources/postgresql) | 12 โ€“ 18 | `pg_dump` | โœ… | +| [MongoDB](/user-guide/sources/mongodb) | 4.x โ€“ 8.x | `mongodump` | โœ… | +| [Redis](/user-guide/sources/redis) | 2.8+ | `redis-cli --rdb` | Manual | +| [Valkey](/user-guide/sources/valkey) | 7.2+ | `redis-cli --rdb` | Manual | +| [SQLite](/user-guide/sources/sqlite) | 3.x | `.dump` command | โœ… | +| [MSSQL](/user-guide/sources/mssql) | 2017, 2019, 2022 | `BACKUP DATABASE` | โœ… | ## Database Explorer @@ -41,7 +42,7 @@ DBackup supports two connection modes for most database types: In SSH mode, DBackup connects to the remote server via SSH and executes database CLI tools (e.g., `mysqldump`, `pg_dump`) **directly on that server**. The backup output is streamed back to DBackup over the SSH connection. This is **not** an SSH tunnel - the database tools run remotely. -**Supported adapters:** MySQL, MariaDB, PostgreSQL, MongoDB, Redis, SQLite +**Supported adapters:** MySQL, MariaDB, PostgreSQL, MongoDB, Redis, Valkey, SQLite ::: warning Required: Database CLI Tools on Remote Host When using SSH mode, the required database client tools **must be installed on the remote SSH server**. DBackup does not transfer or install any tools - it only executes them. See the individual adapter pages for the specific tools required. diff --git a/docs/user-guide/sources/redis.md b/docs/user-guide/sources/redis.md index 96e6ac24..8858e045 100644 --- a/docs/user-guide/sources/redis.md +++ b/docs/user-guide/sources/redis.md @@ -6,7 +6,11 @@ Redis is an in-memory data structure store used as a database, cache, message br | Versions | | :--- | -| 6.x, 7.x, 8.x | +| 2.8+ | + +::: info Valkey +[Valkey](https://valkey.io) is a Redis-compatible fork supported from version 7.2+. Use the dedicated **Valkey** source type for correct version display in DBackup. Existing Redis sources also connect to Valkey servers without changes. +::: ## Connection Modes diff --git a/docs/user-guide/sources/valkey.md b/docs/user-guide/sources/valkey.md new file mode 100644 index 00000000..f5217f95 --- /dev/null +++ b/docs/user-guide/sources/valkey.md @@ -0,0 +1,42 @@ +# Valkey + +Valkey is an open-source, Redis-compatible in-memory data store maintained by the Linux Foundation. DBackup supports Valkey using the same RDB snapshot mechanism as Redis. + +## Supported Versions + +| Versions | +| :--- | +| 7.2+ | + +## Configuration + +Valkey uses the same configuration fields as Redis. See the [Redis source guide](/user-guide/sources/redis) for the complete field reference - all settings, connection modes, and SSH options apply identically. + +::: info Same adapter, different label +Valkey is protocol-compatible with Redis. The Valkey source type exists so version tracking shows the correct Valkey version (e.g., "Valkey 8.1.x") instead of the Redis compatibility alias (e.g., "Redis 7.2.x") that Valkey reports for backward compatibility. +::: + +## Connection Modes + +| Mode | Description | +| :--- | :--- | +| **Direct** | DBackup connects via TCP and runs `redis-cli` locally | +| **SSH** | DBackup connects via SSH and runs `redis-cli` on the remote host | + +## How It Works + +DBackup uses `redis-cli --rdb` to download a consistent RDB snapshot from the Valkey server. The backup includes all configured databases in a single file and works with both standalone and Sentinel deployments. + +## Migrating from Redis Sources + +If you previously configured a Redis source pointing to a Valkey server, it will continue to work without changes. Create a new Valkey source to get accurate version labels and version history tracking. + +## Required CLI Tools + +`redis-cli` must be available (Valkey also ships `valkey-cli`, but `redis-cli` from the `redis-tools` package works with Valkey servers). See the [Redis guide](/user-guide/sources/redis#required-cli-tools) for installation instructions per platform. + +## Next Steps + +- [Redis source guide](/user-guide/sources/redis) - Full configuration reference +- [Encryption](/user-guide/security/encryption) - Encrypting your backups +- [Retention Policies](/user-guide/features/retention) - Managing backup storage diff --git a/scripts/seed-test-sources.ts b/scripts/seed-test-sources.ts index 05130ff9..f2b3571b 100644 --- a/scripts/seed-test-sources.ts +++ b/scripts/seed-test-sources.ts @@ -68,6 +68,7 @@ const CREDENTIAL_PROFILE_FOR: Record = { mongodb: 'Test MongoDB Credentials', mssql: 'Test MSSQL Credentials', redis: 'Test Redis Credentials', + valkey: 'Test Redis Credentials', }; // Inline config fields that the credential profile now owns diff --git a/src/app/api/storage/[id]/analyze/route.ts b/src/app/api/storage/[id]/analyze/route.ts index 33289faa..4bbb392e 100644 --- a/src/app/api/storage/[id]/analyze/route.ts +++ b/src/app/api/storage/[id]/analyze/route.ts @@ -83,7 +83,7 @@ export async function POST(req: NextRequest, props: { params: Promise<{ id: stri } // For server-based adapters (not sqlite) with empty names, // use the source type to signal the frontend that this is a DB restore - const serverAdapters = ['mysql', 'mariadb', 'postgres', 'mongodb', 'mssql', 'redis']; + const serverAdapters = ['mysql', 'mariadb', 'postgres', 'mongodb', 'mssql', 'redis', 'valkey']; if (meta.sourceType && serverAdapters.includes(meta.sourceType.toLowerCase())) { return NextResponse.json({ databases: [], sourceType: meta.sourceType }); } diff --git a/src/app/dashboard/storage/restore/restore-client.tsx b/src/app/dashboard/storage/restore/restore-client.tsx index f5b16b1b..c78a2388 100644 --- a/src/app/dashboard/storage/restore/restore-client.tsx +++ b/src/app/dashboard/storage/restore/restore-client.tsx @@ -100,7 +100,7 @@ export function RestoreClient() { const isSystemConfig = file?.sourceType === 'SYSTEM'; - const SERVER_ADAPTERS = ['mysql', 'mariadb', 'postgres', 'mongodb', 'mssql', 'redis']; + const SERVER_ADAPTERS = ['mysql', 'mariadb', 'postgres', 'mongodb', 'mssql', 'redis', 'valkey']; const resolvedSourceType = backupSourceType || file?.sourceType || ''; const isServerAdapter = SERVER_ADAPTERS.includes(resolvedSourceType.toLowerCase()); @@ -350,7 +350,7 @@ export function RestoreClient() { ); } - const isRedisBackup = file.sourceType?.toLowerCase() === 'redis'; + const isRedisBackup = ['redis', 'valkey'].includes(file.sourceType?.toLowerCase() ?? ''); // Redis backups use a specialized step-by-step wizard if (isRedisBackup) { diff --git a/src/components/adapter/adapter-manager.tsx b/src/components/adapter/adapter-manager.tsx index 9b87e268..8c799480 100644 --- a/src/components/adapter/adapter-manager.tsx +++ b/src/components/adapter/adapter-manager.tsx @@ -145,6 +145,7 @@ export function AdapterManager({ type, title, description, canManage = true, per case 'mongodb': return {config.user}@{config.host}:{config.port}; case 'redis': + case 'valkey': return {config.host}:{config.port} (DB {config.database ?? 0}); case 'local-filesystem': return {config.basePath}; diff --git a/src/components/adapter/form-constants.ts b/src/components/adapter/form-constants.ts index e214f5f9..4981b446 100644 --- a/src/components/adapter/form-constants.ts +++ b/src/components/adapter/form-constants.ts @@ -38,6 +38,7 @@ export const PLACEHOLDERS: Record = { "mongodb.port": "27017", "mssql.port": "1433", "redis.port": "6379", + "valkey.port": "6379", "email.port": "587", "mongodb.uri": "mongodb://user:password@localhost:27017/db?authSource=admin", diff --git a/src/components/adapter/form-sections.tsx b/src/components/adapter/form-sections.tsx index 19119961..17498448 100644 --- a/src/components/adapter/form-sections.tsx +++ b/src/components/adapter/form-sections.tsx @@ -415,7 +415,7 @@ export function DatabaseFormContent({ )} - {adapter.id === 'redis' && ( + {(adapter.id === 'redis' || adapter.id === 'valkey') && ( )} - {adapter.id === 'redis' && } + {(adapter.id === 'redis' || adapter.id === 'valkey') && } - {adapter.id === 'redis' && } + {(adapter.id === 'redis' || adapter.id === 'valkey') && } ', + width: 512, + height: 512, +}; + // - Material Design Icons (protocol, storage & generic icons) - import harddiskIcon from "@iconify-icons/mdi/harddisk"; import sshIcon from "@iconify-icons/mdi/ssh"; @@ -56,6 +63,7 @@ const ADAPTER_ICON_MAP: Record = { "mongodb": mongodbIcon, "sqlite": sqliteIcon, "redis": redisIcon, + "valkey": valkeyIcon, "mssql": mssqlIcon, // Storage - Local diff --git a/src/components/dashboard/explorer/database-table-data.tsx b/src/components/dashboard/explorer/database-table-data.tsx index ab540960..a322ba4c 100644 --- a/src/components/dashboard/explorer/database-table-data.tsx +++ b/src/components/dashboard/explorer/database-table-data.tsx @@ -73,6 +73,7 @@ interface DatabaseTableDataProps { } const REDIS_ADAPTER = "redis"; +const VALKEY_ADAPTER = "valkey"; const MONGO_ADAPTER = "mongodb"; const TYPE_COLORS: Record = { @@ -170,7 +171,7 @@ export function DatabaseTableData({ sourceId, database, table, adapterId }: Data return String(value); }; - const isRedis = adapterId === REDIS_ADAPTER; + const isRedis = adapterId === REDIS_ADAPTER || adapterId === VALKEY_ADAPTER; const isMongo = adapterId === MONGO_ADAPTER; const visibleCols = columns.filter(col => !hiddenColumns.has(col.name)); diff --git a/src/components/dashboard/jobs/job-form.tsx b/src/components/dashboard/jobs/job-form.tsx index a2a600b0..e690b59d 100644 --- a/src/components/dashboard/jobs/job-form.tsx +++ b/src/components/dashboard/jobs/job-form.tsx @@ -302,7 +302,7 @@ export function JobForm({ sources, destinations, notifications: _notifications, // Determine whether to show database picker based on selected source adapter const selectedSourceId = form.watch("sourceId"); const selectedSource = sources.find(s => s.id === selectedSourceId); - const showDatabasePicker = selectedSource && !["sqlite", "redis"].includes(selectedSource.adapterId); + const showDatabasePicker = selectedSource && !["sqlite", "redis", "valkey"].includes(selectedSource.adapterId); const isPgSource = selectedSource?.adapterId === "postgres"; const pgMajorVersion = isPgSource ? parsePgMajorVersion(selectedSource?.metadata) : null; diff --git a/src/components/dashboard/setup/steps/job-step.tsx b/src/components/dashboard/setup/steps/job-step.tsx index 9f295083..024eec98 100644 --- a/src/components/dashboard/setup/steps/job-step.tsx +++ b/src/components/dashboard/setup/steps/job-step.tsx @@ -72,7 +72,7 @@ export function JobStep({ wizardData, onUpdate, onNext, onPrev }: JobStepProps) const [isDbListOpen, setIsDbListOpen] = useState(false); const showDatabasePicker = wizardData.sourceAdapterId - && !["sqlite", "redis"].includes(wizardData.sourceAdapterId); + && !["sqlite", "redis", "valkey"].includes(wizardData.sourceAdapterId); const fetchDatabases = useCallback(async () => { if (!wizardData.sourceId) return; diff --git a/src/lib/adapters/database/redis/connection.ts b/src/lib/adapters/database/redis/connection.ts index ac49a20a..2ca8fa59 100644 --- a/src/lib/adapters/database/redis/connection.ts +++ b/src/lib/adapters/database/redis/connection.ts @@ -73,8 +73,9 @@ export async function test(config: RedisConfig): Promise<{ success: boolean; mes const infoResult = await ssh.exec(`${redisBin} ${args.join(" ")} INFO server`); let version: string | undefined; if (infoResult.code === 0) { - const versionMatch = infoResult.stdout.match(/redis_version:([^\r\n]+)/); - version = versionMatch ? versionMatch[1].trim() : undefined; + const valkeyMatch = infoResult.stdout.match(/valkey_version:([^\r\n]+)/); + const redisMatch = infoResult.stdout.match(/redis_version:([^\r\n]+)/); + version = (valkeyMatch ?? redisMatch)?.[1]?.trim(); } return { success: true, message: "Connection successful (via SSH)", version }; @@ -101,9 +102,10 @@ export async function test(config: RedisConfig): Promise<{ success: boolean; mes const infoArgs = [...args, "INFO", "server"]; const { stdout: infoResult } = await execFileAsync("redis-cli", infoArgs); - // Parse redis_version from INFO output - const versionMatch = infoResult.match(/redis_version:([^\r\n]+)/); - const version = versionMatch ? versionMatch[1].trim() : undefined; + // Prefer valkey_version when present (Valkey servers), fall back to redis_version + const valkeyMatch = infoResult.match(/valkey_version:([^\r\n]+)/); + const redisMatch = infoResult.match(/redis_version:([^\r\n]+)/); + const version = (valkeyMatch ?? redisMatch)?.[1]?.trim(); return { success: true, diff --git a/src/lib/adapters/database/valkey/index.ts b/src/lib/adapters/database/valkey/index.ts new file mode 100644 index 00000000..d9a8522c --- /dev/null +++ b/src/lib/adapters/database/valkey/index.ts @@ -0,0 +1,10 @@ +import type { DatabaseAdapter } from "@/lib/core/interfaces"; +import { RedisSchema } from "@/lib/adapters/definitions"; +import { RedisAdapter } from "../redis"; + +export const ValkeyAdapter: DatabaseAdapter = { + ...RedisAdapter, + id: "valkey", + name: "Valkey", + configSchema: RedisSchema, +}; diff --git a/src/lib/adapters/definitions/index.ts b/src/lib/adapters/definitions/index.ts index 22a82605..01db8c3e 100644 --- a/src/lib/adapters/definitions/index.ts +++ b/src/lib/adapters/definitions/index.ts @@ -28,6 +28,7 @@ export const ADAPTER_DEFINITIONS: AdapterDefinition[] = [ { id: "sqlite", type: "database", name: "SQLite", configSchema: SQLiteSchema }, { id: "mssql", type: "database", name: "Microsoft SQL Server", configSchema: MSSQLSchema }, { id: "redis", type: "database", name: "Redis", configSchema: RedisSchema }, + { id: "valkey", type: "database", name: "Valkey", configSchema: RedisSchema }, { id: "local-filesystem", type: "storage", group: "Local", name: "Local Filesystem", configSchema: LocalStorageSchema }, { id: "s3-aws", type: "storage", group: "Cloud Storage (S3)", name: "Amazon S3", configSchema: S3AWSSchema }, diff --git a/src/lib/adapters/index.ts b/src/lib/adapters/index.ts index 56e3874e..a92eda77 100644 --- a/src/lib/adapters/index.ts +++ b/src/lib/adapters/index.ts @@ -6,6 +6,7 @@ import { MongoDBAdapter } from "./database/mongodb"; import { SQLiteAdapter } from "./database/sqlite"; import { MSSQLAdapter } from "./database/mssql"; import { RedisAdapter } from "./database/redis"; +import { ValkeyAdapter } from "./database/valkey"; import { LocalFileSystemAdapter } from "./storage/local"; import { S3GenericAdapter, S3AWSAdapter, S3R2Adapter, S3HetznerAdapter } from "./storage/s3"; import { SFTPAdapter } from "./storage/sftp"; @@ -46,6 +47,7 @@ export function registerAdapters() { registry.register(SQLiteAdapter); registry.register(MSSQLAdapter); registry.register(RedisAdapter); + registry.register(ValkeyAdapter); registry.register(LocalFileSystemAdapter); registry.register(S3GenericAdapter); diff --git a/src/lib/auth/adapter-permissions.ts b/src/lib/auth/adapter-permissions.ts index 320b9d2c..ee9fa2e7 100644 --- a/src/lib/auth/adapter-permissions.ts +++ b/src/lib/auth/adapter-permissions.ts @@ -8,6 +8,7 @@ const ADAPTER_PERMISSIONS: Record = { sqlite: PERMISSIONS.SOURCES.VIEW, mssql: PERMISSIONS.SOURCES.VIEW, redis: PERMISSIONS.SOURCES.VIEW, + valkey: PERMISSIONS.SOURCES.VIEW, "local-filesystem": PERMISSIONS.DESTINATIONS.READ, "s3-generic": PERMISSIONS.DESTINATIONS.READ, diff --git a/src/lib/backup-extensions.ts b/src/lib/backup-extensions.ts index 97b1f614..b7322fe0 100644 --- a/src/lib/backup-extensions.ts +++ b/src/lib/backup-extensions.ts @@ -23,6 +23,7 @@ export function getBackupFileExtension(adapterId: string): string { // NoSQL and special formats mongodb: "archive", // mongodump --archive format redis: "rdb", // Redis RDB snapshot format + valkey: "rdb", // Valkey RDB snapshot format (same as Redis) sqlite: "db", // SQLite database file copy }; @@ -45,6 +46,7 @@ export function getBackupFormatDescription(adapterId: string): string { mssql: "SQL Server Native Backup", mongodb: "MongoDB Archive", redis: "Redis RDB Snapshot", + valkey: "Valkey RDB Snapshot", sqlite: "SQLite Database Copy", }; diff --git a/src/lib/core/credential-requirements.ts b/src/lib/core/credential-requirements.ts index 2d43b962..b58b52da 100644 --- a/src/lib/core/credential-requirements.ts +++ b/src/lib/core/credential-requirements.ts @@ -22,6 +22,7 @@ export const ADAPTER_CREDENTIAL_REQUIREMENTS: Record< mongodb: { primary: "USERNAME_PASSWORD", ssh: "SSH_KEY" }, mssql: { primary: "USERNAME_PASSWORD", ssh: "SSH_KEY" }, redis: { primary: "USERNAME_PASSWORD", ssh: "SSH_KEY" }, + valkey: { primary: "USERNAME_PASSWORD", ssh: "SSH_KEY" }, sqlite: { ssh: "SSH_KEY" }, // local mode has no primary; SSH mode uses the slot // SSH-native storage (key-or-password auth as the primary identity) diff --git a/tests/integration/test-configs.ts b/tests/integration/test-configs.ts index 57605a7b..d41ebcd4 100644 --- a/tests/integration/test-configs.ts +++ b/tests/integration/test-configs.ts @@ -148,6 +148,17 @@ export const testDatabases = [ password: 'testpassword', database: 0 } + }, + // --- Valkey --- + { + name: 'Test Valkey 8', + config: { + type: 'valkey', + host: TEST_HOST, + port: 63780, + password: 'testpassword', + database: 0 + } } ]; From 5a3e004ea4234bd4ad8907b97bde25f6471bed54 Mon Sep 17 00:00:00 2001 From: Manu Date: Tue, 30 Jun 2026 20:33:15 +0200 Subject: [PATCH 03/15] Update docs for Valkey and restore matrix Adds Valkey to the docs navigation and homepage support lists, updates database support tables to include connection modes plus restore capabilities, and aligns Redis compatibility to 2.8+ with Guided restore. The changelog was also updated with the Redis docs correction and an adapter form UX improvement entry. --- README.md | 4 ++-- docs/.vitepress/config.mts | 1 + docs/changelog.md | 12 ++++++++---- docs/index.md | 21 +++++++++++---------- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index a6cbf8c5..22302373 100644 --- a/README.md +++ b/README.md @@ -165,8 +165,8 @@ Open [https://localhost:3000](https://localhost:3000) and create your admin acco | MySQL | 5.7, 8.x, 9.x | Direct, SSH | Yes | | MariaDB | 10.x, 11.x | Direct, SSH | Yes | | MongoDB | 4.x, 5.x, 6.x, 7.x, 8.x | Direct, SSH | Yes | -| Redis | 2.8+ | Direct, SSH | Manual | -| Valkey | 7.2+ | Direct, SSH | Manual | +| Redis | 2.8+ | Direct, SSH | Guided | +| Valkey | 7.2+ | Direct, SSH | Guided | | SQLite | 3.x | Local, SSH | Yes | | Microsoft SQL Server | 2017, 2019, 2022, Azure SQL Edge | Direct (+ SSH for file transfer) | Yes | diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index c5b53681..e90e44fd 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -106,6 +106,7 @@ export default defineConfig({ { text: 'PostgreSQL', link: '/user-guide/sources/postgresql' }, { text: 'MongoDB', link: '/user-guide/sources/mongodb' }, { text: 'Redis', link: '/user-guide/sources/redis' }, + { text: 'Valkey', link: '/user-guide/sources/valkey' }, { text: 'SQLite', link: '/user-guide/sources/sqlite' }, { text: 'Microsoft SQL Server', link: '/user-guide/sources/mssql' } ] diff --git a/docs/changelog.md b/docs/changelog.md index 11706dd5..8079dffd 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -9,14 +9,18 @@ All notable changes to DBackup are documented here. - **Valkey**: Added Valkey as a supported database source. Uses the same RDB backup mechanism as Redis with correct version display. -### ๐Ÿ“ Documentation - -- **Redis**: Updated supported version documentation from `6.x` to `2.8+` to reflect actual compatibility. - ### ๐Ÿ› Bug Fixes - **storage alerts**: Fixed "Missing Backup" alert firing incorrectly when a retention policy keeps the file count stable, making the alert appear even though backups were completing successfully. +### ๐ŸŽจ Improvements + +- **adapter form**: Configuration tab content is now wrapped in a scroll area, allowing users to scroll through long configuration fields without the content being cut off. + +### ๐Ÿ“ Documentation + +- **Redis**: Updated supported version documentation from `6.x` to `2.8+` to reflect actual compatibility. + ### ๐Ÿณ Docker - **Image**: `skyfay/dbackup:vNEXT` diff --git a/docs/index.md b/docs/index.md index 3396d3ee..a494c0a9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,7 +19,7 @@ hero: features: - icon: ๐Ÿ—„๏ธ title: Multi-Database Support - details: Supports MySQL, MariaDB, PostgreSQL, MongoDB, SQLite, Redis, and Microsoft SQL Server. + details: Supports MySQL, MariaDB, PostgreSQL, MongoDB, SQLite, Redis, Valkey, and Microsoft SQL Server. - icon: ๐Ÿ”’ title: Backup Encryption details: AES-256-GCM encryption for backup files with managed Encryption Profiles, key rotation, and offline Recovery Kits for manual decryption without DBackup. @@ -119,15 +119,16 @@ Then open [https://localhost:3000](https://localhost:3000) and create your first :::tabs == ๐Ÿ—„๏ธ Databases -| Database | Versions | Modes | -| :--- | :--- | :--- | -| **PostgreSQL** | 12, 13, 14, 15, 16, 17, 18 | Direct, SSH | -| **MySQL** | 5.7, 8.x, 9.x | Direct, SSH | -| **MariaDB** | 10.x, 11.x | Direct, SSH | -| **MongoDB** | 4.x, 5.x, 6.x, 7.x, 8.x | Direct, SSH | -| **Redis** | 6.x, 7.x, 8.x | Direct, SSH | -| **SQLite** | 3.x | Local, SSH | -| **Microsoft SQL Server** | 2017, 2019, 2022, Azure SQL Edge | Direct (+ SSH file transfer) | +| Database | Versions | Connection Modes | Restore | +| :--- | :--- | :--- | :--- | +| **PostgreSQL** | 12, 13, 14, 15, 16, 17, 18 | Direct, SSH | Yes | +| **MySQL** | 5.7, 8.x, 9.x | Direct, SSH | Yes | +| **MariaDB** | 10.x, 11.x | Direct, SSH | Yes | +| **MongoDB** | 4.x, 5.x, 6.x, 7.x, 8.x | Direct, SSH | Yes | +| **Redis** | 2.8+ | Direct, SSH | Guided | +| **Valkey** | 7.2+ | Direct, SSH | Guided | +| **SQLite** | 3.x | Local, SSH | Yes | +| **Microsoft SQL Server** | 2017, 2019, 2022, Azure SQL Edge | Direct (+ SSH file transfer) | Yes | == โ˜๏ธ Storage From 187e038acfc46ed2e2264b9a0b3b0e2955a04ce5 Mon Sep 17 00:00:00 2001 From: Manu Date: Wed, 1 Jul 2026 21:23:25 +0200 Subject: [PATCH 04/15] Fix adapter dialog scrolling and footer Moved scrolling from the dialog wrapper into AdapterForm so the form body scrolls while actions stay fixed at the bottom. The dialog header was adjusted to remain stable, nested ScrollArea usage was removed, and form section indentation was cleaned up for consistency. --- docs/changelog.md | 2 +- src/components/adapter/adapter-form.tsx | 12 ++++++--- src/components/adapter/adapter-manager.tsx | 23 ++++++++--------- src/components/adapter/form-sections.tsx | 30 +++++++++++----------- 4 files changed, 35 insertions(+), 32 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 8079dffd..c79ffa70 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -15,7 +15,7 @@ All notable changes to DBackup are documented here. ### ๐ŸŽจ Improvements -- **adapter form**: Configuration tab content is now wrapped in a scroll area, allowing users to scroll through long configuration fields without the content being cut off. +- **adapter form**: The add/edit dialog is now scrollable when configuration fields exceed the available screen height, with action buttons always pinned at the bottom. ### ๐Ÿ“ Documentation diff --git a/src/components/adapter/adapter-form.tsx b/src/components/adapter/adapter-form.tsx index 0e6489ee..9f3d86f1 100644 --- a/src/components/adapter/adapter-form.tsx +++ b/src/components/adapter/adapter-form.tsx @@ -8,6 +8,7 @@ import { z } from "zod"; import { toast } from "sonner"; import { ChevronsUpDown, AlertCircle } from "lucide-react"; import { cn } from "@/lib/utils"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { AdapterIcon } from "@/components/adapter/adapter-icon"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -276,7 +277,9 @@ export function AdapterForm({ type, adapters, onSuccess, initialData, onBack }: <>
- + + +
{/* Header: Name and Type */}
)} - {/* Dialog Footer Actions */} -
+
+ +
+
{onBack && !initialData && (
+
diff --git a/src/components/adapter/adapter-manager.tsx b/src/components/adapter/adapter-manager.tsx index 8c799480..a9befbc9 100644 --- a/src/components/adapter/adapter-manager.tsx +++ b/src/components/adapter/adapter-manager.tsx @@ -6,7 +6,6 @@ import { Button } from "@/components/ui/button"; import { Plus } from "lucide-react"; import { toast } from "sonner"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from "@/components/ui/alert-dialog"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { AlertTriangle } from "lucide-react"; @@ -420,20 +419,18 @@ export function AdapterManager({ type, title, description, canManage = true, per {/* Step 2: Adapter Form */} - + {editingId ? "Edit Configuration" : (type === 'notification' ? "Add New Notification" : (type === 'database' ? "Add New Source" : (type === 'storage' ? "Add New Destination" : "Add New Configuration")))} - - {isDialogOpen && ( - { setIsDialogOpen(false); setSelectedAdapterForNew(null); fetchConfigs(); }} - initialData={editingId ? configs.find(c => c.id === editingId) : undefined} - onBack={!editingId ? () => { setIsDialogOpen(false); setSelectedAdapterForNew(null); setIsPickerOpen(true); } : undefined} - /> - )} - + {isDialogOpen && ( + { setIsDialogOpen(false); setSelectedAdapterForNew(null); fetchConfigs(); }} + initialData={editingId ? configs.find(c => c.id === editingId) : undefined} + onBack={!editingId ? () => { setIsDialogOpen(false); setSelectedAdapterForNew(null); setIsPickerOpen(true); } : undefined} + /> + )} diff --git a/src/components/adapter/form-sections.tsx b/src/components/adapter/form-sections.tsx index 17498448..f86b79ab 100644 --- a/src/components/adapter/form-sections.tsx +++ b/src/components/adapter/form-sections.tsx @@ -310,23 +310,23 @@ export function DatabaseFormContent({ -
+
-
- {onHealthNotificationsDisabledChange && ( - - )} - {onIsRestoreExcludedChange && ( - - )} +
+ {onHealthNotificationsDisabledChange && ( + + )} + {onIsRestoreExcludedChange && ( + + )}
)} From 08d1111ea877de86c5dc9b27943fcd49f5adeaa1 Mon Sep 17 00:00:00 2001 From: Manu Date: Wed, 1 Jul 2026 21:33:18 +0200 Subject: [PATCH 05/15] Support Valkey labels in Redis restore flow Updated the Redis restore UX and adapter messaging to handle both Redis and Valkey consistently. The restore client now derives an engine name from backup metadata and passes it into the wizard, which now renders engine-specific step titles, warnings, service/container names, paths, config filenames, and CLI commands. Backend restore preparation now detects Valkey via INFO server, uses engine-aware defaults and manual command output, and updates connection error text to Redis/Valkey. --- .../storage/restore/restore-client.tsx | 8 +- .../storage/redis-restore-wizard.tsx | 74 ++++++++++--------- src/lib/adapters/database/redis/restore.ts | 42 +++++++---- 3 files changed, 71 insertions(+), 53 deletions(-) diff --git a/src/app/dashboard/storage/restore/restore-client.tsx b/src/app/dashboard/storage/restore/restore-client.tsx index c78a2388..6ea18d7b 100644 --- a/src/app/dashboard/storage/restore/restore-client.tsx +++ b/src/app/dashboard/storage/restore/restore-client.tsx @@ -351,8 +351,9 @@ export function RestoreClient() { } const isRedisBackup = ['redis', 'valkey'].includes(file.sourceType?.toLowerCase() ?? ''); + const redisEngineName = file.sourceType?.toLowerCase() === 'valkey' ? 'Valkey' : 'Redis'; - // Redis backups use a specialized step-by-step wizard + // Redis/Valkey backups use a specialized step-by-step wizard if (isRedisBackup) { return (
@@ -364,7 +365,7 @@ export function RestoreClient() {

Restore Backup

-

Redis restore requires manual steps - follow the wizard below.

+

{redisEngineName} restore requires manual steps - follow the wizard below.

@@ -387,7 +388,7 @@ export function RestoreClient() { - Redis {file.engineVersion || ""} + {redisEngineName} {file.engineVersion || ""} {file.compression && ( {file.compression} @@ -405,6 +406,7 @@ export function RestoreClient() { file={file} destinationId={destinationId} onCancel={handleCancel} + engineName={redisEngineName} />
); diff --git a/src/components/dashboard/storage/redis-restore-wizard.tsx b/src/components/dashboard/storage/redis-restore-wizard.tsx index 29fbe3fa..963404f1 100644 --- a/src/components/dashboard/storage/redis-restore-wizard.tsx +++ b/src/components/dashboard/storage/redis-restore-wizard.tsx @@ -24,18 +24,21 @@ interface RedisRestoreWizardProps { file: FileInfo; destinationId: string; onCancel: () => void; + engineName?: "Redis" | "Valkey"; } type WizardStep = "intro" | "download" | "stop" | "replace" | "start" | "verify"; -const STEPS: { id: WizardStep; title: string; description: string }[] = [ - { id: "intro", title: "Overview", description: "Understand the restore process" }, - { id: "download", title: "Download Backup", description: "Get the RDB file" }, - { id: "stop", title: "Stop Redis", description: "Safely shut down the server" }, - { id: "replace", title: "Replace RDB", description: "Copy the backup file" }, - { id: "start", title: "Start Redis", description: "Restart the server" }, - { id: "verify", title: "Verify", description: "Confirm data restored" }, -]; +function buildSteps(engineName: string): { id: WizardStep; title: string; description: string }[] { + return [ + { id: "intro", title: "Overview", description: "Understand the restore process" }, + { id: "download", title: "Download Backup", description: "Get the RDB file" }, + { id: "stop", title: `Stop ${engineName}`, description: "Safely shut down the server" }, + { id: "replace", title: "Replace RDB", description: "Copy the backup file" }, + { id: "start", title: `Start ${engineName}`, description: "Restart the server" }, + { id: "verify", title: "Verify", description: "Confirm data restored" }, + ]; +} function CommandBlock({ command, label }: { command: string; label?: string }) { const copyToClipboard = () => { @@ -63,7 +66,10 @@ function CommandBlock({ command, label }: { command: string; label?: string }) { ); } -export function RedisRestoreWizard({ file, destinationId, onCancel }: RedisRestoreWizardProps) { +export function RedisRestoreWizard({ file, destinationId, onCancel, engineName = "Redis" }: RedisRestoreWizardProps) { + const engineLower = engineName.toLowerCase(); + const cliBin = `${engineLower}-cli`; + const STEPS = buildSteps(engineName); const [currentStep, setCurrentStep] = useState("intro"); const [completedSteps, setCompletedSteps] = useState>(new Set()); const [downloadUrl, setDownloadUrl] = useState(null); @@ -126,9 +132,9 @@ export function RedisRestoreWizard({ file, destinationId, onCancel }: RedisResto
-

Redis Restore Wizard

+

{engineName} Restore Wizard

- Redis requires manual steps to restore. Follow this wizard carefully. + {engineName} requires manual steps to restore. Follow this wizard carefully.

@@ -182,10 +188,10 @@ export function RedisRestoreWizard({ file, destinationId, onCancel }: RedisResto
- Why is Redis restore different? + Why is {engineName} restore different? - Unlike SQL databases, Redis cannot load RDB files remotely via network commands. - The RDB file must be physically placed on the Redis server and the server restarted. + Unlike SQL databases, {engineName} cannot load RDB files remotely via network commands. + The RDB file must be physically placed on the {engineName} server and the server restarted. @@ -198,7 +204,7 @@ export function RedisRestoreWizard({ file, destinationId, onCancel }: RedisResto
  • - Safely stopping your Redis server + Safely stopping your {engineName} server
  • @@ -206,7 +212,7 @@ export function RedisRestoreWizard({ file, destinationId, onCancel }: RedisResto
  • - Restarting Redis to load the data + Restarting {engineName} to load the data
  • @@ -215,7 +221,7 @@ export function RedisRestoreWizard({ file, destinationId, onCancel }: RedisResto Data Loss Warning - This will completely replace all data in your Redis server. + This will completely replace all data in your {engineName} server. The current data will be permanently lost. @@ -272,22 +278,22 @@ export function RedisRestoreWizard({ file, destinationId, onCancel }: RedisResto {currentStep === "stop" && (
    -

    Step 2: Stop the Redis Server

    +

    Step 2: Stop the {engineName} Server

    - Before replacing the RDB file, you must stop the Redis server to prevent data corruption. + Before replacing the RDB file, you must stop the {engineName} server to prevent data corruption.

    - - - + + `} /> + SHUTDOWN SAVE`} />
    - Make sure Redis is completely stopped before proceeding. - You can verify by running: redis-cli ping (should fail) + Make sure {engineName} is completely stopped before proceeding. + You can verify by running: {cliBin} ping (should fail)
    @@ -297,13 +303,13 @@ export function RedisRestoreWizard({ file, destinationId, onCancel }: RedisResto

    Step 3: Replace the RDB File

    - Copy the downloaded backup file to your Redis data directory, replacing the existing dump.rdb. + Copy the downloaded backup file to your {engineName} data directory, replacing the existing dump.rdb.

    Important: The file must be named exactly dump.rdb - (or whatever is configured in your redis.conf as dbfilename). + (or whatever is configured in your {engineLower}.conf as dbfilename).
    @@ -323,19 +329,19 @@ export function RedisRestoreWizard({ file, destinationId, onCancel }: RedisResto {currentStep === "start" && (
    -

    Step 4: Start the Redis Server

    +

    Step 4: Start the {engineName} Server

    - Start Redis again. It will automatically load the new RDB file on startup. + Start {engineName} again. It will automatically load the new RDB file on startup.

    - - + + `} />

    - Check the Redis logs to ensure no errors occurred during startup and RDB loading. + Check the {engineName} logs to ensure no errors occurred during startup and RDB loading.

    @@ -351,11 +357,11 @@ export function RedisRestoreWizard({ file, destinationId, onCancel }: RedisResto
    INFO keyspace`} /> KEYS "*" | head -20`} />
    diff --git a/src/lib/adapters/database/redis/restore.ts b/src/lib/adapters/database/redis/restore.ts index 2251844a..92f260ce 100644 --- a/src/lib/adapters/database/redis/restore.ts +++ b/src/lib/adapters/database/redis/restore.ts @@ -39,7 +39,7 @@ export async function prepareRestore(config: RedisRestoreConfig, _databases: str await execFileAsync("redis-cli", [...args, "PING"]); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); - throw new Error(`Cannot connect to Redis: ${message}`); + throw new Error(`Cannot connect to Redis/Valkey: ${message}`); } // Check if we have admin permissions (needed for potential FLUSHALL) @@ -93,19 +93,29 @@ export async function restore( }; try { - log("Starting Redis restore preparation...", "info"); + // Detect whether the target server is Redis or Valkey (both share this restore flow) + const args = buildConnectionArgs(config); + let engineName = "Redis"; + try { + const { stdout: serverInfo } = await execFileAsync("redis-cli", [...args, "INFO", "server"]); + if (/valkey_version:/.test(serverInfo)) engineName = "Valkey"; + } catch { + // Fall back to "Redis" if INFO server can't be queried + } + const engineLower = engineName.toLowerCase(); + + log(`Starting ${engineName} restore preparation...`, "info"); // Verify the backup file exists const fs = await import("fs/promises"); const stats = await fs.stat(sourcePath); log(`Backup file size: ${stats.size} bytes`, "info"); - // Get Redis server info to provide instructions - const args = buildConnectionArgs(config); + // Get server info to provide instructions const { stdout: infoResult } = await execFileAsync("redis-cli", [...args, "CONFIG", "GET", "dir"]); const lines = infoResult.trim().split("\n"); - const dataDir = lines[1] || "/var/lib/redis"; + const dataDir = lines[1] || `/var/lib/${engineLower}`; const { stdout: dbFilename } = await execFileAsync("redis-cli", [...args, "CONFIG", "GET", "dbfilename"]); const dbLines = dbFilename.trim().split("\n"); @@ -113,31 +123,31 @@ export async function restore( log("", "info"); log("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•", "info"); - log("โš ๏ธ REDIS RESTORE REQUIRES MANUAL STEPS", "warning"); + log(`โš ๏ธ ${engineName.toUpperCase()} RESTORE REQUIRES MANUAL STEPS`, "warning"); log("โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•", "info"); log("", "info"); - log("Redis does not support remote RDB restore.", "info"); + log(`${engineName} does not support remote RDB restore.`, "info"); log("To complete the restore, follow these steps:", "info"); log("", "info"); - log(`1. Stop the Redis server`, "info"); + log(`1. Stop the ${engineName} server`, "info"); log(`2. Copy the backup file to: ${dataDir}/${rdbFilename}`, "info"); - log(`3. Ensure correct file permissions (redis:redis)`, "info"); - log(`4. Start the Redis server`, "info"); + log(`3. Ensure correct file permissions (${engineLower}:${engineLower})`, "info"); + log(`4. Start the ${engineName} server`, "info"); log("", "info"); // Format manual commands as collapsible details const systemdCommands = [ - `sudo systemctl stop redis`, + `sudo systemctl stop ${engineLower}`, `sudo cp "${sourcePath}" ${dataDir}/${rdbFilename}`, - `sudo chown redis:redis ${dataDir}/${rdbFilename}`, - `sudo systemctl start redis`, + `sudo chown ${engineLower}:${engineLower} ${dataDir}/${rdbFilename}`, + `sudo systemctl start ${engineLower}`, ].join("\n"); log("Systemd commands", "info", "command", systemdCommands); const dockerCommands = [ - `docker stop `, - `docker cp "${sourcePath}" :/data/${rdbFilename}`, - `docker start `, + `docker stop <${engineLower}-container>`, + `docker cp "${sourcePath}" <${engineLower}-container>:/data/${rdbFilename}`, + `docker start <${engineLower}-container>`, ].join("\n"); log("Docker commands", "info", "command", dockerCommands); From e6d01de3132a24f8db0512aa766292340dd3ea66 Mon Sep 17 00:00:00 2001 From: Manu Date: Wed, 1 Jul 2026 21:51:49 +0200 Subject: [PATCH 06/15] Update Redis restore tests for version detection Add INFO server mocks to Redis restore tests. The restore function now detects the Redis version before performing configuration steps, so all test cases need to mock this initial INFO server call to match the updated implementation. --- .../adapters/database/redis/restore.test.ts | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/unit/adapters/database/redis/restore.test.ts b/tests/unit/adapters/database/redis/restore.test.ts index 47ae5e19..2a31c15e 100644 --- a/tests/unit/adapters/database/redis/restore.test.ts +++ b/tests/unit/adapters/database/redis/restore.test.ts @@ -179,6 +179,11 @@ describe("restore()", () => { it("returns success with manual-steps metadata when all steps succeed", async () => { mockExecFileCb + .mockImplementationOnce((...args: unknown[]) => { + // INFO server (engine detection) + const cb = args[args.length - 1] as (err: null, r: { stdout: string; stderr: string }) => void; + cb(null, { stdout: "redis_version:7.0.0\n", stderr: "" }); + }) .mockImplementationOnce((...args: unknown[]) => { // CONFIG GET dir const cb = args[args.length - 1] as (err: null, r: { stdout: string; stderr: string }) => void; @@ -201,6 +206,11 @@ describe("restore()", () => { it("uses fallback dataDir and rdbFilename when CONFIG GET returns only the key line", async () => { // Simulates "dir\n" with no second line (lines[1] is empty -> fallback). mockExecFileCb + .mockImplementationOnce((...args: unknown[]) => { + // INFO server (engine detection) + const cb = args[args.length - 1] as (err: null, r: { stdout: string; stderr: string }) => void; + cb(null, { stdout: "redis_version:7.0.0\n", stderr: "" }); + }) .mockImplementationOnce((...args: unknown[]) => { const cb = args[args.length - 1] as (err: null, r: { stdout: string; stderr: string }) => void; cb(null, { stdout: "dir\n", stderr: "" }); @@ -245,10 +255,16 @@ describe("restore()", () => { }); it("returns failure when CONFIG GET dir command fails", async () => { - mockExecFileCb.mockImplementationOnce((...args: unknown[]) => { - const cb = args[args.length - 1] as (err: Error) => void; - cb(new Error("ERR CONFIG disabled")); - }); + mockExecFileCb + .mockImplementationOnce((...args: unknown[]) => { + // INFO server (engine detection) + const cb = args[args.length - 1] as (err: null, r: { stdout: string; stderr: string }) => void; + cb(null, { stdout: "redis_version:7.0.0\n", stderr: "" }); + }) + .mockImplementationOnce((...args: unknown[]) => { + const cb = args[args.length - 1] as (err: Error) => void; + cb(new Error("ERR CONFIG disabled")); + }); const result = await restore(buildConfig(), "/tmp/backup.rdb"); From 474ff7bb56588a6298e5ee8bd41e953bd492b6cc Mon Sep 17 00:00:00 2001 From: Manu Date: Wed, 1 Jul 2026 22:04:24 +0200 Subject: [PATCH 07/15] Move health check cleanup to system task Health check log retention now runs through the daily CLEAN_OLD_LOGS task using the healthcheck.logRetentionDays setting. This removes the per-run cleanup from the health check service, drops the redundant storage snapshot cleanup from dashboard stats refresh, and updates docs and tests accordingly. --- docs/changelog.md | 2 ++ docs/developer-guide/advanced/healthcheck.md | 4 +-- src/services/dashboard-service.ts | 8 ----- src/services/system/healthcheck-service.ts | 30 ++++++++----------- src/services/system/system-task-service.ts | 14 +++++++++ .../unit/services/healthcheck-service.test.ts | 14 ++++----- .../unit/services/system-task-service.test.ts | 16 +++++++++- 7 files changed, 53 insertions(+), 35 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index c79ffa70..9d305086 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -16,6 +16,8 @@ All notable changes to DBackup are documented here. ### ๐ŸŽจ Improvements - **adapter form**: The add/edit dialog is now scrollable when configuration fields exceed the available screen height, with action buttons always pinned at the bottom. +- **system tasks**: Health check log cleanup now runs once a day via the "Clean Old Data" task instead of on every health check run, reducing unnecessary database load. +- **system tasks**: Removed a redundant hourly storage snapshot cleanup that duplicated the daily "Clean Old Data" cleanup. ### ๐Ÿ“ Documentation diff --git a/docs/developer-guide/advanced/healthcheck.md b/docs/developer-guide/advanced/healthcheck.md index af176037..9279dfcf 100644 --- a/docs/developer-guide/advanced/healthcheck.md +++ b/docs/developer-guide/advanced/healthcheck.md @@ -106,7 +106,7 @@ if (success) { ### Retention -The service automatically deletes logs older than 48 hours to control database size. +Logs are automatically deleted once a day by the `CLEAN_OLD_LOGS` system task, not on every health check run. Default retention is 2 days, configurable via the `healthcheck.logRetentionDays` system setting. ## System Task Integration @@ -237,5 +237,5 @@ const HEALTHCHECK_CRON = '*/1 * * * *'; For high-availability setups, consider: - Reducing interval to 30 seconds -- Increasing retention beyond 48 hours +- Increasing `healthcheck.logRetentionDays` beyond the default 2 days - Adding external monitoring integration diff --git a/src/services/dashboard-service.ts b/src/services/dashboard-service.ts index a34632a0..2b61341e 100644 --- a/src/services/dashboard-service.ts +++ b/src/services/dashboard-service.ts @@ -468,14 +468,6 @@ export async function refreshStorageStatsCache(): Promise // Save historical snapshots for storage usage over time charts await saveStorageSnapshots(results); - // Clean up old snapshots based on configured retention period - const snapshotSetting = await prisma.systemSetting.findUnique({ where: { key: "storage.snapshotRetentionDays" } }); - const snapshotRetentionDays = snapshotSetting ? parseInt(snapshotSetting.value) : 90; - const cleaned = await cleanupOldSnapshots(snapshotRetentionDays); - if (cleaned > 0) { - log.info("Cleaned up old storage snapshots", { deleted: cleaned }); - } - log.info("Storage statistics cache refreshed", { destinations: results.length, totalSize: results.reduce((sum, r) => sum + r.size, 0), diff --git a/src/services/system/healthcheck-service.ts b/src/services/system/healthcheck-service.ts index a8e65448..43d19b9b 100644 --- a/src/services/system/healthcheck-service.ts +++ b/src/services/system/healthcheck-service.ts @@ -116,26 +116,22 @@ export class HealthCheckService { } } - // Retention Policy: Delete logs older than 48 hours - try { - const retentionDate = new Date(); - retentionDate.setHours(retentionDate.getHours() - 48); + log.debug("Health check cycle completed"); + } - const deleted = await prisma.healthCheckLog.deleteMany({ - where: { - createdAt: { - lt: retentionDate - } + /** Delete health check logs older than the given retention period (in days). */ + async cleanOldLogs(retentionDays: number) { + const retentionDate = new Date(); + retentionDate.setDate(retentionDate.getDate() - retentionDays); + + const deleted = await prisma.healthCheckLog.deleteMany({ + where: { + createdAt: { + lt: retentionDate } - }); - if (deleted.count > 0) { - log.info("Cleaned up old health check logs", { deletedCount: deleted.count }); } - } catch (e) { - log.error("Failed to run log retention", {}, wrapError(e)); - } - - log.debug("Health check cycle completed"); + }); + return deleted.count; } /** diff --git a/src/services/system/system-task-service.ts b/src/services/system/system-task-service.ts index 56e13682..a2e5120e 100644 --- a/src/services/system/system-task-service.ts +++ b/src/services/system/system-task-service.ts @@ -347,6 +347,20 @@ export class SystemTaskService { log.error("Failed to clean storage snapshots", {}, wrapError(error)); } + // Clean old health check logs + try { + const healthCheckSetting = await prisma.systemSetting.findUnique({ where: { key: "healthcheck.logRetentionDays" } }); + const healthCheckRetentionDays = healthCheckSetting ? parseInt(healthCheckSetting.value) : 2; + + log.info("Cleaning old health check logs", { retentionDays: healthCheckRetentionDays }); + const healthCheckDeleted = await healthCheckService.cleanOldLogs(healthCheckRetentionDays); + if (healthCheckDeleted > 0) { + log.info("Health check log cleanup completed", { deletedCount: healthCheckDeleted }); + } + } catch (error: unknown) { + log.error("Failed to clean health check logs", {}, wrapError(error)); + } + // Clean old notification logs try { const notifSetting = await prisma.systemSetting.findUnique({ where: { key: "notification.logRetentionDays" } }); diff --git a/tests/unit/services/healthcheck-service.test.ts b/tests/unit/services/healthcheck-service.test.ts index c2e68c5e..32a2c0d1 100644 --- a/tests/unit/services/healthcheck-service.test.ts +++ b/tests/unit/services/healthcheck-service.test.ts @@ -158,16 +158,18 @@ describe('HealthCheckService', () => { })); }); - it('should clean up old logs', async () => { + it('should clean up old logs older than the given retention period', async () => { // Arrange - prismaMock.adapterConfig.findMany.mockResolvedValue([]); prismaMock.healthCheckLog.deleteMany.mockResolvedValue({ count: 10 } as any); // Act - await service.performHealthCheck(); + const deletedCount = await service.cleanOldLogs(2); // Assert - expect(prismaMock.healthCheckLog.deleteMany).toHaveBeenCalled(); + expect(prismaMock.healthCheckLog.deleteMany).toHaveBeenCalledWith({ + where: { createdAt: { lt: expect.any(Date) } }, + }); + expect(deletedCount).toBe(10); }); it('should handle adapter not found by setting status to OFFLINE via catch', async () => { @@ -280,10 +282,8 @@ describe('HealthCheckService', () => { }); prismaMock.adapterConfig.findMany.mockResolvedValue([]); - await service.performHealthCheck(); - // No assertion on behavior - just verify no crash when custom config is loaded - expect(prismaMock.healthCheckLog.deleteMany).toHaveBeenCalled(); + await expect(service.performHealthCheck()).resolves.toBeUndefined(); }); it('should fall back to default cooldown when getNotificationConfig throws', async () => { diff --git a/tests/unit/services/system-task-service.test.ts b/tests/unit/services/system-task-service.test.ts index 67b5eae0..d4bc7809 100644 --- a/tests/unit/services/system-task-service.test.ts +++ b/tests/unit/services/system-task-service.test.ts @@ -14,7 +14,10 @@ vi.mock('@/services/system/update-service', () => ({ updateService: { checkForUpdates: vi.fn().mockResolvedValue({ updateAvailable: false }) }, })); vi.mock('@/services/system/healthcheck-service', () => ({ - healthCheckService: { performHealthCheck: vi.fn().mockResolvedValue(undefined) }, + healthCheckService: { + performHealthCheck: vi.fn().mockResolvedValue(undefined), + cleanOldLogs: vi.fn().mockResolvedValue(0), + }, })); vi.mock('@/services/audit-service', () => ({ auditService: { cleanOldLogs: vi.fn().mockResolvedValue({ count: 0 }) }, @@ -232,6 +235,7 @@ describe('SystemTaskService', () => { prismaMock.systemSetting.findUnique .mockResolvedValueOnce(null) // audit retention .mockResolvedValueOnce(null) // snapshot retention + .mockResolvedValueOnce(null) // healthcheck retention .mockResolvedValueOnce({ key: 'notification.logRetentionDays', value: '30' } as any); prismaMock.notificationLog.deleteMany.mockResolvedValue({ count: 5 }); @@ -242,6 +246,16 @@ describe('SystemTaskService', () => { ); }); + it('calls healthCheckService.cleanOldLogs for CLEAN_OLD_LOGS', async () => { + const { healthCheckService } = await import('@/services/system/healthcheck-service'); + prismaMock.systemSetting.findUnique.mockResolvedValue(null); + prismaMock.notificationLog.deleteMany.mockResolvedValue({ count: 0 }); + + await service.runTask(SYSTEM_TASKS.CLEAN_OLD_LOGS); + + expect(healthCheckService.cleanOldLogs).toHaveBeenCalledWith(2); + }); + it('calls updateService.checkForUpdates for CHECK_FOR_UPDATES', async () => { const { updateService } = await import('@/services/system/update-service'); From be1ac07526bea9cf6160306e15f8d1539a409f41 Mon Sep 17 00:00:00 2001 From: Manu Date: Wed, 1 Jul 2026 22:08:22 +0200 Subject: [PATCH 08/15] Add Valkey badge to README Adds a Valkey badge to the technology list in the README. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 22302373..6302220a 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ MongoDB SQLite Redis + Valkey MSSQL
    License From bdcadb8f265fc5ce4cd10d073cf99fdca7c065d4 Mon Sep 17 00:00:00 2001 From: Manu Date: Sat, 4 Jul 2026 11:16:28 +0200 Subject: [PATCH 09/15] Use adapter icons in storage widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the generic hard drive icon in the dashboard storage status and storage volume chart with the adapter-specific brand icon. This keeps storage widgets aligned with the Destinations page and makes each destination easier to เคชเคนเคšเคพเคจ. --- docs/changelog.md | 1 + src/components/dashboard/widgets/storage-status.tsx | 4 ++-- src/components/dashboard/widgets/storage-volume-chart.tsx | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 9d305086..c25dd21d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -15,6 +15,7 @@ All notable changes to DBackup are documented here. ### ๐ŸŽจ Improvements +- **dashboard**: The Storage Usage widget now shows the adapter-specific brand icon for each destination (matching the Destinations page) instead of a generic hard drive icon. - **adapter form**: The add/edit dialog is now scrollable when configuration fields exceed the available screen height, with action buttons always pinned at the bottom. - **system tasks**: Health check log cleanup now runs once a day via the "Clean Old Data" task instead of on every health check run, reducing unnecessary database load. - **system tasks**: Removed a redundant hourly storage snapshot cleanup that duplicated the daily "Clean Old Data" cleanup. diff --git a/src/components/dashboard/widgets/storage-status.tsx b/src/components/dashboard/widgets/storage-status.tsx index 41cf2a36..9e6067a9 100644 --- a/src/components/dashboard/widgets/storage-status.tsx +++ b/src/components/dashboard/widgets/storage-status.tsx @@ -1,7 +1,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import prisma from "@/lib/prisma"; import { formatBytes } from "@/lib/utils"; -import { HardDrive } from "lucide-react"; +import { AdapterIcon } from "@/components/adapter/adapter-icon"; export async function StorageStatus() { // 1. Get all configured storage adapters @@ -61,7 +61,7 @@ export async function StorageStatus() {
    - +
    {adapter.name} diff --git a/src/components/dashboard/widgets/storage-volume-chart.tsx b/src/components/dashboard/widgets/storage-volume-chart.tsx index 22688f51..479d8420 100644 --- a/src/components/dashboard/widgets/storage-volume-chart.tsx +++ b/src/components/dashboard/widgets/storage-volume-chart.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import type { StorageVolumeEntry } from "@/services/dashboard-service"; import { formatBytes } from "@/lib/utils"; -import { HardDrive } from "lucide-react"; +import { AdapterIcon } from "@/components/adapter/adapter-icon"; import { DateDisplay } from "@/components/utils/date-display"; import { Tooltip, @@ -82,7 +82,7 @@ export function StorageVolumeChart({ data, cacheUpdatedAt }: StorageVolumeChartP >
    - +
    {entry.name} From b56929bddda1d2a18cc07f7f87fefb4b1a0375be Mon Sep 17 00:00:00 2001 From: Manu Date: Sat, 4 Jul 2026 12:05:26 +0200 Subject: [PATCH 10/15] Enable WAL mode for Prisma SQLite DB Harden the internal SQLite setup by forcing a single Prisma connection, enabling WAL mode, and applying a busy timeout to reduce "database is locked" errors during concurrent access. Also ignore SQLite WAL companion files and document their presence and backup requirements in the installation guide and changelog. --- .gitignore | 2 ++ docs/changelog.md | 2 ++ docs/user-guide/installation.md | 4 ++++ src/lib/prisma.ts | 35 ++++++++++++++++++++++++++++++++- 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b56a72e9..07f1e151 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,8 @@ next-env.d.ts # db prisma/dev.db prisma/dev.db-journal +prisma/dev.db-shm +prisma/dev.db-wal db-data/ # backups diff --git a/docs/changelog.md b/docs/changelog.md index c25dd21d..31aa2683 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -19,10 +19,12 @@ All notable changes to DBackup are documented here. - **adapter form**: The add/edit dialog is now scrollable when configuration fields exceed the available screen height, with action buttons always pinned at the bottom. - **system tasks**: Health check log cleanup now runs once a day via the "Clean Old Data" task instead of on every health check run, reducing unnecessary database load. - **system tasks**: Removed a redundant hourly storage snapshot cleanup that duplicated the daily "Clean Old Data" cleanup. +- **database**: The internal SQLite database now runs in WAL mode with a busy timeout, so readers no longer block writers and concurrent writes wait briefly instead of failing instantly with "database is locked". ### ๐Ÿ“ Documentation - **Redis**: Updated supported version documentation from `6.x` to `2.8+` to reflect actual compatibility. +- **installation**: Documented the `-wal`/`-shm` companion files created by the internal database's WAL mode. ### ๐Ÿณ Docker diff --git a/docs/user-guide/installation.md b/docs/user-guide/installation.md index 5222756b..5a4dacfe 100644 --- a/docs/user-guide/installation.md +++ b/docs/user-guide/installation.md @@ -196,6 +196,10 @@ secrets: | `/data` | โœ… | All persistent data (database, uploads, certificates) | | `/backups` | โŒ | Optional: used for local backups | +::: info SQLite WAL mode +DBackup runs its internal SQLite database in [WAL (Write-Ahead Logging)](https://www.sqlite.org/wal.html) mode for better read/write concurrency. This creates two extra files next to the database, `dbackup.db-wal` and `dbackup.db-shm`, inside `/data/db`. They are normal and required while the app is running - do not delete them manually. Back up or copy the whole `db` folder together (not just the `.db` file) to avoid losing uncommitted writes. +::: + ## Health Check DBackup includes a built-in Docker health check that verifies both the application and database are running: diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index fc76af8c..149109cc 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -1,4 +1,8 @@ import { PrismaClient } from '@prisma/client' +import { logger } from '@/lib/logging/logger' +import { wrapError } from '@/lib/logging/errors' + +const log = logger.child({ module: 'Prisma' }) // Add BigInt serialization support for JSON // This prevents "TypeError: Do not know how to serialize a BigInt" when passing data to client components @@ -7,8 +11,37 @@ BigInt.prototype.toJSON = function () { return this.toString() } +// โ”€โ”€ SQLite Hardening โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// Forces a single physical connection (connection_limit=1) so PRAGMA settings +// (WAL, busy_timeout) reliably apply to every query and writers never compete +// against each other over separate file handles. Applied here so users don't +// need to change their DATABASE_URL - the hardening is transparent to the setup. +const SQLITE_BUSY_TIMEOUT_MS = 5000 + +function withSqliteHardening(url: string): string { + const [base, query = ''] = url.split('?') + const params = new URLSearchParams(query) + if (!params.has('connection_limit')) params.set('connection_limit', '1') + return `${base}?${params.toString()}` +} + const prismaClientSingleton = () => { - const baseClient = new PrismaClient() + const databaseUrl = withSqliteHardening(process.env.DATABASE_URL ?? 'file:./prisma/dev.db') + const baseClient = new PrismaClient({ datasources: { db: { url: databaseUrl } } }) + + // Enable WAL mode and a busy timeout on the single shared connection. + // WAL lets readers and the writer operate concurrently without blocking each + // other, and busy_timeout makes writers wait briefly instead of failing + // instantly with "database is locked" during rare contention (e.g. `prisma + // migrate deploy` or the `sqlite3` CLI touching the file at the same time). + // Both PRAGMAs return a result row, so SQLite rejects them via $executeRawUnsafe + // ("Execute returned results, which is not allowed in SQLite") - use $queryRawUnsafe instead. + baseClient.$queryRawUnsafe('PRAGMA journal_mode = WAL;') + .then(() => log.info('SQLite WAL mode enabled')) + .catch((err) => log.warn('Failed to enable SQLite WAL mode', {}, wrapError(err))) + + baseClient.$queryRawUnsafe(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS};`) + .catch((err) => log.warn('Failed to set SQLite busy_timeout', {}, wrapError(err))) // โ”€โ”€ Transparent SSO Secret Decryption โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // SSO clientId/clientSecret and oidcConfig are stored encrypted in the DB. From f33e0646480354c6a0db40eb12d043304ab579b2 Mon Sep 17 00:00:00 2001 From: Manu Date: Sat, 4 Jul 2026 14:39:12 +0200 Subject: [PATCH 11/15] Add SQLITE_WAL_MODE toggle for SQLite Introduces a new `SQLITE_WAL_MODE` environment variable (default `true`) to control whether Prisma enables SQLite WAL mode at startup. When set to `false`, WAL is skipped and a clear log message is emitted, while busy timeout hardening remains in place. Updated env validation, unit tests, changelog, and installation/developer docs to document the new behavior and when disabling WAL is useful (for filesystems without WAL shared-memory locking support). --- docs/changelog.md | 2 +- docs/developer-guide/reference/environment.md | 2 ++ docs/user-guide/installation.md | 3 ++- src/lib/prisma.ts | 23 ++++++++++++++++--- src/lib/server/env-validation.ts | 4 ++++ tests/unit/lib/env-validation.test.ts | 2 ++ 6 files changed, 31 insertions(+), 5 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 31aa2683..d857ab4f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -19,7 +19,7 @@ All notable changes to DBackup are documented here. - **adapter form**: The add/edit dialog is now scrollable when configuration fields exceed the available screen height, with action buttons always pinned at the bottom. - **system tasks**: Health check log cleanup now runs once a day via the "Clean Old Data" task instead of on every health check run, reducing unnecessary database load. - **system tasks**: Removed a redundant hourly storage snapshot cleanup that duplicated the daily "Clean Old Data" cleanup. -- **database**: The internal SQLite database now runs in WAL mode with a busy timeout, so readers no longer block writers and concurrent writes wait briefly instead of failing instantly with "database is locked". +- **database**: The internal SQLite database now runs in WAL mode with a busy timeout, so readers no longer block writers and concurrent writes wait briefly instead of failing instantly with "database is locked". Configurable via the new `SQLITE_WAL_MODE` environment variable (default: `true`). ### ๐Ÿ“ Documentation diff --git a/docs/developer-guide/reference/environment.md b/docs/developer-guide/reference/environment.md index 4d3901ab..a3cea17c 100644 --- a/docs/developer-guide/reference/environment.md +++ b/docs/developer-guide/reference/environment.md @@ -18,6 +18,7 @@ Complete reference for all environment variables in DBackup. | :--- | :--- | :--- | | `TRUSTED_ORIGINS` | Additional URLs for accessing DBackup (comma-separated) | - | | `DATABASE_URL` | SQLite database file path | `file:/data/db/dbackup.db` | +| `SQLITE_WAL_MODE` | Set to `false` to disable SQLite WAL mode | `true` | | `PORT` | Internal port the server listens on | `3000` | | `TZ` | Server timezone (for logs and cron scheduling) | `UTC` | | `TMPDIR` | Temporary directory for backup processing | `/tmp` | @@ -38,6 +39,7 @@ Complete reference for all environment variables in DBackup. ``` - **PORT** changes the internal port. When using custom ports, set both `PORT` and update your port mapping accordingly - **DATABASE_URL** has a sensible default and typically doesn't need to be set +- **SQLITE_WAL_MODE** enables [WAL (Write-Ahead Logging)](https://www.sqlite.org/wal.html) mode by default so readers and the writer don't block each other. Set to `false` only if `/data` is on a filesystem that doesn't support WAL's shared-memory locking (e.g. some network shares/NFS mounts) - this falls back to SQLite's default rollback journal - **TMPDIR** is useful for mounting larger storage for temporary backup files (e.g., NFS) - **TZ** only affects server-side logs. User-facing dates use the timezone from user profile settings - **PUID/PGID** control which UID/GID the application process runs as. Set these to match your host user (e.g., `PUID=1000 PGID=1000`) to avoid volume permission issues. The entrypoint adjusts the internal user at startup diff --git a/docs/user-guide/installation.md b/docs/user-guide/installation.md index 5a4dacfe..e3eeca30 100644 --- a/docs/user-guide/installation.md +++ b/docs/user-guide/installation.md @@ -88,6 +88,7 @@ Access the application at [https://localhost:3000](https://localhost:3000) (acce | `TRUSTED_ORIGINS` | โŒ | Additional access URLs, comma-separated (see below). | | `PORT` | โŒ | Internal server port. Default: `3000` | | `DATABASE_URL` | โŒ | SQLite path. Default: `file:/data/db/dbackup.db` | +| `SQLITE_WAL_MODE` | โŒ | Set to `false` to disable WAL mode. Default: `true` | | `TZ` | โŒ | Server timezone for logs. Default: `UTC` | | `TMPDIR` | โŒ | Temp directory for large backups. Default: `/tmp` | | `LOG_LEVEL` | โŒ | Logging verbosity: `debug`, `info`, `warn`, `error`. Default: `info` | @@ -197,7 +198,7 @@ secrets: | `/backups` | โŒ | Optional: used for local backups | ::: info SQLite WAL mode -DBackup runs its internal SQLite database in [WAL (Write-Ahead Logging)](https://www.sqlite.org/wal.html) mode for better read/write concurrency. This creates two extra files next to the database, `dbackup.db-wal` and `dbackup.db-shm`, inside `/data/db`. They are normal and required while the app is running - do not delete them manually. Back up or copy the whole `db` folder together (not just the `.db` file) to avoid losing uncommitted writes. +DBackup runs its internal SQLite database in [WAL (Write-Ahead Logging)](https://www.sqlite.org/wal.html) mode by default for better read/write concurrency. This creates two extra files next to the database, `dbackup.db-wal` and `dbackup.db-shm`, inside `/data/db`. They are normal and required while the app is running - do not delete them manually. Back up or copy the whole `db` folder together (not just the `.db` file) to avoid losing uncommitted writes. Set `SQLITE_WAL_MODE=false` to disable WAL mode if your storage backend doesn't support it (e.g. some network shares/NFS mounts). ::: ## Health Check diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 149109cc..af3d8ace 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -18,6 +18,11 @@ BigInt.prototype.toJSON = function () { // need to change their DATABASE_URL - the hardening is transparent to the setup. const SQLITE_BUSY_TIMEOUT_MS = 5000 +// WAL mode is on by default (recommended). Set SQLITE_WAL_MODE=false to opt out, +// e.g. when the /data volume lives on a filesystem that doesn't support WAL's +// shared-memory locking (some network shares/NFS mounts). +const isWalModeEnabled = process.env.SQLITE_WAL_MODE !== 'false' + function withSqliteHardening(url: string): string { const [base, query = ''] = url.split('?') const params = new URLSearchParams(query) @@ -36,9 +41,21 @@ const prismaClientSingleton = () => { // migrate deploy` or the `sqlite3` CLI touching the file at the same time). // Both PRAGMAs return a result row, so SQLite rejects them via $executeRawUnsafe // ("Execute returned results, which is not allowed in SQLite") - use $queryRawUnsafe instead. - baseClient.$queryRawUnsafe('PRAGMA journal_mode = WAL;') - .then(() => log.info('SQLite WAL mode enabled')) - .catch((err) => log.warn('Failed to enable SQLite WAL mode', {}, wrapError(err))) + // + // journal_mode is persisted inside the database file itself, not just for the + // current connection. Once a file has been switched to WAL it stays in WAL + // forever until something explicitly switches it back - so the "false" branch + // must actively set journal_mode=DELETE, otherwise the -wal/-shm files keep + // reappearing from a previous run even with SQLITE_WAL_MODE=false. + if (isWalModeEnabled) { + baseClient.$queryRawUnsafe('PRAGMA journal_mode = WAL;') + .then(() => log.info('SQLite WAL mode enabled')) + .catch((err) => log.warn('Failed to enable SQLite WAL mode', {}, wrapError(err))) + } else { + baseClient.$queryRawUnsafe('PRAGMA journal_mode = DELETE;') + .then(() => log.info('SQLite WAL mode disabled via SQLITE_WAL_MODE=false')) + .catch((err) => log.warn('Failed to disable SQLite WAL mode', {}, wrapError(err))) + } baseClient.$queryRawUnsafe(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS};`) .catch((err) => log.warn('Failed to set SQLite busy_timeout', {}, wrapError(err))) diff --git a/src/lib/server/env-validation.ts b/src/lib/server/env-validation.ts index 29cdc0b8..ac139048 100644 --- a/src/lib/server/env-validation.ts +++ b/src/lib/server/env-validation.ts @@ -22,6 +22,10 @@ const envSchema = z.object({ .string() .default("file:./prisma/dev.db"), + SQLITE_WAL_MODE: z + .enum(["true", "false"]) + .default("true"), + BETTER_AUTH_URL: z .string() .url("BETTER_AUTH_URL must be a valid URL (e.g. http://localhost:3000)") diff --git a/tests/unit/lib/env-validation.test.ts b/tests/unit/lib/env-validation.test.ts index a0109b05..5cb4d7d9 100644 --- a/tests/unit/lib/env-validation.test.ts +++ b/tests/unit/lib/env-validation.test.ts @@ -19,6 +19,7 @@ const MANAGED_ENV_VARS = [ 'BETTER_AUTH_SECRET', 'ENCRYPTION_KEY', 'BETTER_AUTH_URL', 'PORT', 'LOG_LEVEL', 'TZ', 'DATABASE_URL', 'TMPDIR', 'DISABLE_HTTPS', 'CERTS_DIR', 'DATA_DIR', 'TRUSTED_ORIGINS', + 'SQLITE_WAL_MODE', ]; describe('validateEnvironment', () => { @@ -51,6 +52,7 @@ describe('validateEnvironment', () => { expect(env.TZ).toBe('UTC'); expect(env.DATABASE_URL).toBe('file:./prisma/dev.db'); expect(env.DISABLE_HTTPS).toBe('false'); + expect(env.SQLITE_WAL_MODE).toBe('true'); }); it('respects overridden optional values', () => { From 18a5811c6d91d91bb785901af27b48bbd1b20edf Mon Sep 17 00:00:00 2001 From: Manu Date: Sat, 4 Jul 2026 16:23:29 +0200 Subject: [PATCH 12/15] Update README for Valkey support Document Valkey as a supported database engine in the README and update the engine count from 7 to 8 so the feature list matches current support. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6302220a..90d04170 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Whether you're running a single MySQL database or managing multiple PostgreSQL, ### ๐Ÿ—„๏ธ Database Backup -- **7 Database Engines** - MySQL, MariaDB, PostgreSQL, MongoDB, SQLite, Redis, and Microsoft SQL Server +- **8 Database Engines** - MySQL, MariaDB, PostgreSQL, MongoDB, SQLite, Redis, Valkey, and Microsoft SQL Server - **Selective Database Backup** - Choose exactly which databases to back up per job instead of creating separate sources for each database - **Multi-Database Jobs** - Back up multiple databases from a single source in one job with a unified TAR archive format - **AES-256-GCM Encryption** - Encrypt backups with managed Encryption Profiles, key rotation, and downloadable Recovery Kits for offline decryption From b53a7d9ddd0b14fe9d4a5ed5164b64e9083e53f4 Mon Sep 17 00:00:00 2001 From: Manu Date: Sat, 4 Jul 2026 16:43:20 +0200 Subject: [PATCH 13/15] Refine Copilot and workflow instruction docs Updated assistant guidance to better reflect current architecture and process expectations. The changes expand adapter and notification documentation, add clearer server action and UI/logging rules, tighten changelog entry conventions, and include workflow instructions in CLAUDE.md so agents consistently apply release-note requirements. --- .github/copilot-instructions.md | 62 ++++++++++++++----- .../instructions/changelog.instructions.md | 5 +- .github/instructions/ui.instructions.md | 12 +++- CLAUDE.md | 1 + 4 files changed, 61 insertions(+), 19 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6d6a0ca8..885ca654 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,7 +5,9 @@ applyTo: "**/*" # Database Backup Manager - AI Assistant Guidelines ## Project Overview -Self-hosted web app for automating database backups (MySQL, PostgreSQL, MongoDB, MariaDB, SQLite, MSSQL, Redis) with encryption, compression, and retention policies. Built with **Next.js 16 (App Router)**, **TypeScript**, **Prisma** (SQLite), and **Shadcn UI**. +Self-hosted web app for automating database backups (MySQL, PostgreSQL, MongoDB, MariaDB, SQLite, MSSQL, Redis, Valkey, and more) with encryption, compression, and retention policies. Built with **Next.js 16 (App Router)**, **TypeScript**, **Prisma** (SQLite), and **Shadcn UI**. + +Database, storage, and notification adapter lists grow frequently. Do not treat any hardcoded adapter list in these instructions as exhaustive - run `ls src/lib/adapters/{database,storage,notification}/` to get the current, authoritative list before making claims about supported adapters. ## Language & Commands - **Code/Comments**: English @@ -19,6 +21,19 @@ Self-hosted web app for automating database backups (MySQL, PostgreSQL, MongoDB, - `page.tsx`: Fetch via Services โ†’ pass to Client Components - `actions/*.ts`: Server Actions - thin wrappers (Auth โ†’ Zod Validation โ†’ Service call โ†’ Revalidate) +```typescript +// src/app/actions/jobs.ts - canonical Server Action shape +"use server"; + +export async function updateJob(id: string, input: UpdateJobInput) { + await checkPermission(PERMISSIONS.JOBS.WRITE); // 1. Auth + const data = UpdateJobSchema.parse(input); // 2. Zod validation + const job = await jobService.update(id, data); // 3. Service call (all logic lives there) + revalidatePath("/jobs"); // 4. Revalidate + return { success: true, data: job }; +} +``` + ### 2. Service Layer (`src/services`) โญ CORE All business logic lives here, organized by domain: ``` @@ -31,8 +46,8 @@ src/services/ storage/ โ†’ storage-service.ts, verification-service.ts, storage-alert-service.ts notifications/ โ†’ notification-log-service.ts, system-notification-service.ts system/ โ†’ healthcheck-service.ts, system-task-service.ts, update-service.ts, db-version-service.ts, certificate-service.ts - config/ โ†’ config-service.ts, export.ts, import.ts - templates/ โ†’ naming-template-service.ts, retention-policy-service.ts, schedule-preset-service.ts + config/ โ†’ config-service.ts, export.ts, import.ts, parse.ts, restore-pipeline.ts + templates/ โ†’ naming-template-service.ts, notification-template-service.ts, retention-policy-service.ts, schedule-preset-service.ts user/ โ†’ user-service.ts dashboard-service.ts (flat, no subdirectory) audit-service.ts (flat, no subdirectory) @@ -51,7 +66,18 @@ registry.register(MySQLAdapter); registry.get("mysql") // Retrieve by ID ``` -**Adding a new adapter**: Create folder in `src/lib/adapters/{database|storage|notification}/`, implement interface, register in `src/lib/adapters/index.ts`. +**Adding a new adapter** - a full adapter touches ~8-11 files, not just the adapter class. Missing any of these is the most common source of "half-registered adapter" bugs: + +1. `src/lib/adapters/{database|storage|notification}/.ts` (or `/index.ts` if the adapter needs sub-modules) - implement the interface (`dump`/`restore`/`test` for database, `upload`/`download`/`list`/`delete` for storage, `send` for notification) +2. `src/lib/adapters/definitions/{database|storage|notification}.ts` - add the Zod config schema (`NewAdapterSchema`) +3. `src/lib/adapters/definitions/index.ts` - add an entry to `ADAPTER_DEFINITIONS` (`id`, `type`, `name`, `configSchema`, `group` for storage) +4. `src/lib/adapters/index.ts` - import the adapter class and call `registry.register(...)` in `registerAdapters()` +5. `src/lib/core/credential-requirements.ts` - if the adapter uses a credential profile, add an entry to `ADAPTER_CREDENTIAL_REQUIREMENTS[id]` +6. `src/components/adapter/utils.ts` - add the adapter to `ADAPTER_ICON_MAP` (and `ADAPTER_COLOR_MAP` if applicable), otherwise it renders with a generic fallback icon in the UI +7. `src/components/adapter/form-constants.ts` - add field keys to the relevant `*_CONNECTION_KEYS`/`*_CONFIG_KEYS` and `PLACEHOLDERS` if the adapter needs custom form field grouping/placeholders +8. Database adapters only: `src/lib/backup-extensions.ts` - add the dump file extension and description +9. Tests (if the adapter is testable in CI): `docker-compose.test.yml` (service definition) and `tests/integration/test-configs.ts` (`testDatabases`/`CLI_REQUIREMENTS`) +10. Docs (per `docs.instructions.md` template): a new page under `docs/user-guide/{sources|destinations|notifications}/.md`, plus a row in the relevant `docs/developer-guide/adapters/*.md` table **Adapter connectivity methods:** - `test()` โ€“ full write/delete verification (~15 s timeout). Used for manual connection tests. @@ -334,18 +360,21 @@ Reusable named credential sets encrypted with the system `ENCRYPTION_KEY`: ## Notification Events -16 configurable event types (enable/disable per event, set reminder interval, target recipient): +Two distinct event scopes, both defined in `src/lib/notifications/` (`types.ts` for the `NOTIFICATION_EVENTS` map, `events.ts` for `EVENT_DEFINITIONS`): + +- **Global events** (`EVENT_DEFINITIONS` in `events.ts`, ~14 entries) - configurable system-wide (enable/disable, reminder interval, target recipient) via Settings > Notifications: + + | Category | Events | + |----------|--------| + | Auth | `USER_LOGIN`, `USER_CREATED` | + | Restore | `RESTORE_COMPLETE`, `RESTORE_FAILURE` | + | System | `CONFIG_BACKUP`, `SYSTEM_ERROR` | + | Storage | `STORAGE_USAGE_SPIKE`, `STORAGE_LIMIT_WARNING`, `STORAGE_MISSING_BACKUP` | + | Updates | `UPDATE_AVAILABLE` | + | Backup | `INTEGRITY_CHECK_FAILURE` | + | Health | `CONNECTION_OFFLINE`, `CONNECTION_ONLINE`, `DB_VERSION_CHANGED` | -| Category | Events | -|----------|--------| -| Auth | `USER_LOGIN`, `USER_CREATED` | -| Backup | `BACKUP_SUCCESS`, `BACKUP_FAILURE` | -| Restore | `RESTORE_COMPLETE`, `RESTORE_FAILURE` | -| System | `CONFIG_BACKUP`, `SYSTEM_ERROR`, `UPDATE_AVAILABLE` | -| Storage | `STORAGE_USAGE_SPIKE`, `STORAGE_LIMIT_WARNING`, `STORAGE_MISSING_BACKUP` | -| Connectivity | `CONNECTION_OFFLINE`, `CONNECTION_ONLINE` | -| Database | `DB_VERSION_CHANGED` | -| Integrity | `INTEGRITY_CHECK_FAILURE` | +- **Per-job backup events** (`BACKUP_SUCCESS`, `BACKUP_PARTIAL`, `BACKUP_FAILURE`) are intentionally **not** in `EVENT_DEFINITIONS` - they are configured per-job (Job โ†’ Notify tab), not globally. Their message templates live in `src/lib/notifications/templates.ts` and are triggered by the runner pipeline (`04-completion.ts`). Notification log: `src/services/notifications/notification-log-service.ts`. @@ -390,8 +419,9 @@ catch (e: unknown) { - `PermissionError`, `AuthenticationError` - `BackupError`, `RestoreError`, `EncryptionError`, `QueueError` -### Environment Variable +### Environment Variables - `LOG_LEVEL`: `debug` | `info` (default) | `warn` | `error` +- `SQLITE_WAL_MODE`: `true` (default) | `false`. WAL mode is on by default for the Prisma SQLite DB. Set to `false` to opt out. See `src/lib/prisma.ts`. ## Quick Reference diff --git a/.github/instructions/changelog.instructions.md b/.github/instructions/changelog.instructions.md index 2b7d77ab..07983b70 100644 --- a/.github/instructions/changelog.instructions.md +++ b/.github/instructions/changelog.instructions.md @@ -12,9 +12,12 @@ Every changelog entry uses a **bold component prefix** followed by a description - **component**: Description of the change (1-2 sentences max) ([#N](url)) ``` -- **component**: Short, lowercase area/adapter name (e.g., `auth`, `MSSQL`, `dashboard`, `ui`, `backup`, `storage`, `SSO`, `Redis`). Must be a **name**, never a sentence or description. +- **component**: Short, lowercase area/adapter name (e.g., `auth`, `MSSQL`, `dashboard`, `ui`, `backup`, `storage`, `SSO`, `Redis`). Must be a **single name or adapter**, never a sentence, never two areas joined together. + - โœ… `**storage**: ...` / โŒ `**storage alerts**: ...` (pick the more specific single area - `storage` or the alert subsystem's own tag, not a two-word compound) + - โœ… `**Valkey**: ...` / โŒ `**new Valkey adapter**: ...` (the "what happened" belongs in the description, not the component) - **Description**: One sentence - as short as possible while still making sense. Two sentences only if absolutely necessary. Write **what** was done, not why or how. - **Issue links**: Always at the **end** of the entry in the format `([#N](url))`. Never embed issue numbers in the component name. +- **One entry per user-visible change** - if a single PR touches many files to deliver one behavior change, that is still **one** changelog line, not one per file/commit. Conversely, don't cram two unrelated changes into one bullet just because they landed in the same PR. ## Section Headings diff --git a/.github/instructions/ui.instructions.md b/.github/instructions/ui.instructions.md index ae9b9ee7..fc4e171e 100644 --- a/.github/instructions/ui.instructions.md +++ b/.github/instructions/ui.instructions.md @@ -13,20 +13,28 @@ applyTo: "src/components/**/*.tsx, src/app/**/*.tsx" - - Avoid inline styles. Use Tailwind utility classes. + - Avoid inline styles (`style={{...}}`). Use Tailwind utility classes. Exception: dynamically computed values (chart colors, progress percentages) may need `style` - keep these isolated to the specific element, never as a substitute for static styling. - **Tailwind Best Practices**: Prefer standard utility classes (e.g., `h-px`, `w-4`) over arbitrary values (e.g., `h-[1px]`, `w-[1rem]`) whenever possible. - **Feedback**: Use `toast` (Sonner) for success/error notifications. Never use `alert()`. - **Dates**: - - โŒ Forbidden: `new Date().toLocaleDateString()` or any direct locale formatting. + - โŒ Forbidden: `.toLocaleDateString()`, `.toLocaleTimeString()`, `.toLocaleString()` on a `Date`, or any direct locale formatting - not just the `Date` variant. All three sneak in easily in chart tooltips, table cells, and preview components. - โœ… Use the `useDateFormatter` hook from `src/hooks/use-date-formatter.ts` instead. - This ensures user timezone and format preferences are respected. + - Numbers (not dates) may use `.toLocaleString()` for thousands separators if there is genuinely no timezone/format concern - but check `formatBytes`/`formatDuration` in `src/lib/utils.ts` first, they likely already cover the case. + + - **Never** use raw `console.log`/`console.error`/`console.warn`, including inside `.catch()` handlers. Import `logger` from `@/lib/logging/logger` instead - it has no Node-only dependencies, so it is safe in both Server and Client Components, and gives structured, level-filtered output. + - Never log full context/session/auth objects, even via `logger` - log specific fields (`{ userId }`, not the whole user/session object) to avoid leaking sensitive data into the browser console. + - User-facing errors still go through `toast`, not just the logger - the logger is for diagnostics, the toast is for the user. + + - **Separation**: Decouple data fetching (Server Actions) from presentation. - **Props**: Validate all props with strict TypeScript Interfaces. + - **Server Components by default**: `page.tsx` should rarely need `"use client"` itself. Fetch data in the (Server Component) page, then pass it as props to a child Client Component that owns the interactive parts (forms, client-side sort/filter, real-time widgets). Before adding `"use client"` to a page, check whether only a sub-tree actually needs it. diff --git a/CLAUDE.md b/CLAUDE.md index b21d16db..66d258c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,2 @@ @.github/copilot-instructions.md +@.github/instructions/workflow.instructions.md From 91a927e9117c254226cba21e078ac45e5d0f5c53 Mon Sep 17 00:00:00 2001 From: Manu Date: Sat, 4 Jul 2026 16:46:41 +0200 Subject: [PATCH 14/15] Release v2.9.0 across app and docs Finalize the 2.9.0 release by bumping version metadata in the main package, docs package, and both OpenAPI specs. Update the changelog from vNEXT to a dated v2.9.0 release entry, including Docker image tags for the new version. --- api-docs/openapi.yaml | 2 +- docs/changelog.md | 8 ++++---- docs/package.json | 2 +- package.json | 2 +- public/openapi.yaml | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/api-docs/openapi.yaml b/api-docs/openapi.yaml index 8b0090f3..596b5e6f 100644 --- a/api-docs/openapi.yaml +++ b/api-docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: DBackup API - version: 2.8.0 + version: 2.9.0 description: | REST API for DBackup - a self-hosted database backup automation platform with encryption, compression, and smart retention. diff --git a/docs/changelog.md b/docs/changelog.md index d857ab4f..77fefc6c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,8 +2,8 @@ All notable changes to DBackup are documented here. -## vNEXT -*Release: In Progress* +## v2.9.0 - Valkey Support, Storage Alert Fix, and Multiple Improvements +*Released: July 4, 2026* ### โœจ Features @@ -28,8 +28,8 @@ All notable changes to DBackup are documented here. ### ๐Ÿณ Docker -- **Image**: `skyfay/dbackup:vNEXT` -- **Also tagged as**: `latest`, `vNEXT` +- **Image**: `skyfay/dbackup:v2.9.0` +- **Also tagged as**: `latest`, `v2` - **CI Image**: `skyfay/dbackup:ci` - **Platforms**: linux/amd64, linux/arm64 diff --git a/docs/package.json b/docs/package.json index 558d0bc3..15ede725 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "name": "dbackup-docs", - "version": "2.8.0", + "version": "2.9.0", "private": true, "scripts": { "dev": "vitepress dev", diff --git a/package.json b/package.json index fa295533..9a23c64c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dbackup", - "version": "2.8.0", + "version": "2.9.0", "private": true, "scripts": { "dev": "prisma migrate deploy && prisma generate && next dev", diff --git a/public/openapi.yaml b/public/openapi.yaml index 05cd060b..6cb79c02 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: DBackup API - version: 2.8.0 + version: 2.9.0 description: | REST API for DBackup - a self-hosted database backup automation platform with encryption, compression, and smart retention. From da94629adf7fdfd97fbf4fcdeefb605d953614ed Mon Sep 17 00:00:00 2001 From: Manu Date: Sat, 4 Jul 2026 16:54:09 +0200 Subject: [PATCH 15/15] Fix Valkey retention policy doc link Updates the Valkey source guide to point the Retention Policies link to the correct `/user-guide/jobs/retention` path. --- docs/user-guide/sources/valkey.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-guide/sources/valkey.md b/docs/user-guide/sources/valkey.md index f5217f95..079584f1 100644 --- a/docs/user-guide/sources/valkey.md +++ b/docs/user-guide/sources/valkey.md @@ -39,4 +39,4 @@ If you previously configured a Redis source pointing to a Valkey server, it will - [Redis source guide](/user-guide/sources/redis) - Full configuration reference - [Encryption](/user-guide/security/encryption) - Encrypting your backups -- [Retention Policies](/user-guide/features/retention) - Managing backup storage +- [Retention Policies](/user-guide/jobs/retention) - Managing backup storage