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/.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/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 diff --git a/README.md b/README.md index e4ccca97..90d04170 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ MongoDB SQLite Redis + Valkey MSSQL
License @@ -57,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 @@ -159,15 +160,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 | 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 | ## ☁️ Supported Destinations 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/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/.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 e18eb084..77fefc6c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,38 @@ All notable changes to DBackup are documented here. +## v2.9.0 - Valkey Support, Storage Alert Fix, and Multiple Improvements +*Released: July 4, 2026* + +### ✨ Features + +- **Valkey**: Added Valkey as a supported database source. Uses the same RDB backup mechanism as Redis with correct version display. + +### 🐛 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 + +- **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. +- **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 + +- **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 + +- **Image**: `skyfay/dbackup:v2.9.0` +- **Also tagged as**: `latest`, `v2` +- **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/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/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/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 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/docs/user-guide/installation.md b/docs/user-guide/installation.md index 5222756b..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` | @@ -196,6 +197,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 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 DBackup includes a built-in Docker health check that verifies both the application and database are running: 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..079584f1 --- /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/jobs/retention) - Managing backup storage 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. 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..6ea18d7b 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,9 +350,10 @@ export function RestoreClient() { ); } - const isRedisBackup = file.sourceType?.toLowerCase() === 'redis'; + 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/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 9b87e268..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"; @@ -145,6 +144,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}; @@ -419,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-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..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 && ( + + )}
)} @@ -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/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/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} 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/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); 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/src/lib/prisma.ts b/src/lib/prisma.ts index fc76af8c..af3d8ace 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,54 @@ 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 + +// 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) + 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. + // + // 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))) // ── Transparent SSO Secret Decryption ──────────────────────── // SSO clientId/clientSecret and oidcConfig are stored encrypted in the DB. 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/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/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(), 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/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 + } } ]; 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"); 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', () => { 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');