From 1e20fb225e210c4d17bca348d28c3b230f82e3aa Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Thu, 4 Dec 2025 18:31:00 +0100 Subject: [PATCH] feat: naming backup schedules (#103) * docs: add agents instructions * feat: naming backup schedules * fix: wrong table for filtering --- .github/copilot-instructions.md | 1 + AGENTS.md | 267 ++++++ .../api-client/@tanstack/react-query.gen.ts | 48 +- app/client/api-client/client.gen.ts | 4 +- app/client/api-client/sdk.gen.ts | 540 ++++-------- app/client/api-client/types.gen.ts | 7 + .../components/create-schedule-form.tsx | 17 + .../backups/components/schedule-summary.tsx | 18 +- .../modules/backups/routes/backup-details.tsx | 9 +- app/client/modules/backups/routes/backups.tsx | 21 +- .../modules/backups/routes/create-backup.tsx | 1 + app/drizzle/0019_secret_nomad.sql | 24 + app/drizzle/meta/0019_snapshot.json | 807 ++++++++++++++++++ app/drizzle/meta/_journal.json | 7 + app/server/db/schema.ts | 1 + app/server/modules/backups/backups.dto.ts | 3 + app/server/modules/backups/backups.service.ts | 23 +- app/server/utils/restic.ts | 4 +- bun.lock | 10 +- package.json | 4 +- 20 files changed, 1382 insertions(+), 434 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 AGENTS.md create mode 100644 app/drizzle/0019_secret_nomad.sql create mode 100644 app/drizzle/meta/0019_snapshot.json diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..11d4b9c --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +- This project uses the AGENTS.md file to give detailed information about the repository structure and development commands. Make sure to read this file before starting development. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..136692d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,267 @@ +# AGENTS.md + +## Important instructions + +- Never create migration files manually. Always use the provided command to generate migrations +- If you realize an automated migration is incorrect, make sure to remove all the associated entries from the `_journal.json` and the newly created files located in `app/drizzle/` before re-generating the migration + +## Project Overview + +Zerobyte is a backup automation tool built on top of Restic that provides a web interface for scheduling, managing, and monitoring encrypted backups. It supports multiple volume backends (NFS, SMB, WebDAV, local directories) and repository backends (S3, Azure, GCS, local, and rclone-based storage). + +## Technology Stack + +- **Runtime**: Bun 1.3.1 +- **Server**: Hono (web framework) with Bun runtime +- **Client**: React Router v7 (SSR) with React 19 +- **Database**: SQLite with Drizzle ORM +- **Validation**: ArkType for runtime schema validation +- **Styling**: Tailwind CSS v4 + Radix UI components +- **Architecture**: Unified application structure (not a monorepo) +- **Code Quality**: Biome (formatter & linter) +- **Containerization**: Docker with multi-stage builds + +## Repository Structure + +This is a unified application with the following structure: + +- `app/server` - Bun-based API server with Hono +- `app/client` - React Router SSR frontend components and modules +- `app/schemas` - Shared ArkType schemas for validation +- `app/drizzle` - Database migrations + +### Type Checking + +```bash +# Run type checking and generate React Router types +bun run tsc +``` + +### Building + +```bash +# Build for production +bun run build +``` + +### Database Migrations + +```bash +# Generate new migration from schema changes +bun gen:migrations + +# Generate a custom empty migration +bunx drizzle-kit generate --custom --name=fix-timestamps-to-ms + +``` + +### API Client Generation + +```bash +# Generate TypeScript API client from OpenAPI spec +# Note: Server is always running don't need to start it separately +bun run gen:api-client +``` + +### Code Quality + +```bash +# Format and lint (Biome) +bunx biome check --write . + +# Format only +bunx biome format --write . + +# Lint only +bunx biome lint . +``` + +## Architecture + +### Server Architecture + +The server follows a modular service-oriented architecture: + +**Entry Point**: `app/server/index.ts` + +- Initializes servers using `react-router-hono-server`: + 1. Main API server on port 4096 (REST API + serves static frontend) + 2. Docker volume plugin server on Unix socket `/run/docker/plugins/zerobyte.sock` (optional, if Docker is available) + +**Modules** (`app/server/modules/`): +Each module follows a controller � service � database pattern: + +- `auth/` - User authentication and session management +- `volumes/` - Volume mounting/unmounting (NFS, SMB, WebDAV, directories) +- `repositories/` - Restic repository management (S3, Azure, GCS, local, rclone) +- `backups/` - Backup schedule management and execution +- `notifications/` - Notification system with multiple providers (Discord, email, Gotify, Ntfy, Slack, Pushover) +- `driver/` - Docker volume plugin implementation +- `events/` - Server-Sent Events for real-time updates +- `system/` - System information and capabilities +- `lifecycle/` - Application startup/shutdown hooks + +**Backends** (`app/server/modules/backends/`): +Each volume backend (NFS, SMB, WebDAV, directory) implements mounting logic using system tools (mount.nfs, mount.cifs, davfs2). + +**Jobs** (`app/server/jobs/`): +Cron-based background jobs managed by the Scheduler: + +- `backup-execution.ts` - Runs scheduled backups (every minute) +- `cleanup-dangling.ts` - Removes stale mounts (hourly) +- `healthchecks.ts` - Checks volume health (every 5 minutes) +- `repository-healthchecks.ts` - Validates repositories (every 10 minutes) +- `cleanup-sessions.ts` - Expires old sessions (daily) + +**Core** (`app/server/core/`): + +- `scheduler.ts` - Job scheduling system using node-cron +- `capabilities.ts` - Detects available system features (Docker support, etc.) +- `constants.ts` - Application-wide constants + +**Utils** (`app/server/utils/`): + +- `restic.ts` - Restic CLI wrapper with type-safe output parsing +- `spawn.ts` - Safe subprocess execution helpers +- `logger.ts` - Winston-based logging +- `crypto.ts` - Encryption utilities +- `errors.ts` - Error handling middleware + +**Database** (`app/server/db/`): + +- Uses Drizzle ORM with SQLite +- Schema in `schema.ts` defines: volumes, repositories, backup schedules, notifications, users, sessions +- Migrations: `app/drizzle/` + +### Client Architecture + +**Framework**: React Router v7 with SSR +**Entry Point**: `app/root.tsx` + +The client uses: + +- TanStack Query for server state management +- Auto-generated API client from OpenAPI spec (in `app/client/api-client/`) +- Radix UI primitives with custom Tailwind styling +- Server-Sent Events hook (`use-server-events.ts`) for real-time updates + +Routes are organized in feature modules at `app/client/modules/*/routes/`. + +### Shared Schemas + +`app/schemas/` contains ArkType schemas used by both client and server: + +- Volume configurations (NFS, SMB, WebDAV, directory) +- Repository configurations (S3, Azure, GCS, local, rclone) +- Restic command output parsing types +- Backend status types + +These schemas provide runtime validation and TypeScript types. + +## Restic Integration + +Zerobyte is a wrapper around Restic for backup operations. Key integration points: + +**Repository Management**: + +- Creates/initializes Restic repositories via `restic init` +- Supports multiple backends: local, S3, Azure Blob Storage, Google Cloud Storage, or any rclone-supported backend +- Stores single encryption password in `/var/lib/zerobyte/restic/password` (auto-generated on first run) + +**Backup Operations**: + +- Executes `restic backup` with user-defined schedules (cron expressions) +- Supports include/exclude patterns for selective backups +- Parses JSON output for progress tracking and statistics +- Implements retention policies via `restic forget --prune` + +**Repository Utilities** (`utils/restic.ts`): + +- `buildRepoUrl()` - Constructs repository URLs for different backends +- `buildEnv()` - Sets environment variables (credentials, cache dir) +- `ensurePassfile()` - Manages encryption password file +- Type-safe parsing of Restic JSON output using ArkType schemas + +**Rclone Integration** (`app/server/modules/repositories/`): + +- Allows using any rclone backend as a Restic repository +- Dynamically generates rclone config and passes via environment variables +- Supports backends like Dropbox, Google Drive, OneDrive, Backblaze B2, etc. + +## Docker Volume Plugin + +When Docker socket is available (`/var/run/docker.sock`), Zerobyte registers as a Docker volume plugin: + +**Plugin Location**: `/run/docker/plugins/zerobyte.sock` +**Implementation**: `app/server/modules/driver/driver.controller.ts` + +This allows other containers to mount Zerobyte volumes using Docker. + +The plugin implements the Docker Volume Plugin API v1. + +## Environment & Configuration + +**Runtime Environment Variables**: + +- Database path: `./data/zerobyte.db` (configurable via `drizzle.config.ts`) +- Restic cache: `/var/lib/zerobyte/restic/cache` +- Restic password: `/var/lib/zerobyte/restic/password` +- Volume mounts: `/var/lib/zerobyte/mounts/` +- Local repositories: `/var/lib/zerobyte/repositories/` + +**Capabilities Detection**: +On startup, the server detects available capabilities (see `core/capabilities.ts`): + +- **Docker**: Requires `/var/run/docker.sock` access +- System will gracefully degrade if capabilities are unavailable + +## Common Workflows + +### Adding a New Volume Backend + +1. Create backend implementation in `app/server/modules/backends//` +2. Implement `mount()` and `unmount()` methods +3. Add schema to `app/schemas/volumes.ts` +4. Update `volumeConfigSchema` discriminated union +5. Update backend factory in `app/server/modules/backends/backend.ts` + +### Adding a New Repository Backend + +1. Add backend type to `app/schemas/restic.ts` +2. Update `buildRepoUrl()` in `app/server/utils/restic.ts` +3. Update `buildEnv()` to handle credentials/configuration +4. Add DTO schemas in `app/server/modules/repositories/repositories.dto.ts` +5. Update repository service to handle new backend + +### Adding a New Scheduled Job + +1. Create job class in `app/server/jobs/.ts` extending `Job` +2. Implement `run()` method +3. Register in `app/server/modules/lifecycle/startup.ts` with cron expression: + ```typescript + Scheduler.build(YourJob).schedule("* * * * *"); + ``` + +## Important Notes + +- **Code Style**: Uses Biome with tabs (not spaces), 120 char line width, double quotes +- **Imports**: Organize imports is disabled in Biome - do not auto-organize +- **TypeScript**: Uses `"type": "module"` - all imports must include extensions when targeting Node/Bun +- **Validation**: Prefer ArkType over Zod - it's used throughout the codebase +- **Database**: Timestamps are stored as Unix epoch integers, not ISO strings +- **Security**: Restic password file has 0600 permissions - never expose it +- **Mounting**: Requires privileged container or CAP_SYS_ADMIN for FUSE mounts +- **API Documentation**: OpenAPI spec auto-generated at `/api/v1/openapi.json`, docs at `/api/v1/docs` + +## Docker Development Setup + +The `docker-compose.yml` defines two services: + +- `zerobyte-dev` - Development with hot reload (uses `development` stage) +- `zerobyte-prod` - Production build (uses `production` stage) + +Both mount: + +- `/var/lib/zerobyte` for persistent data +- `/dev/fuse` device for FUSE mounting +- Optionally `/var/run/docker.sock` for Docker plugin functionality diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index 411da6d..a4ac2a3 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -87,12 +87,10 @@ const createQueryKey = (id: string, options?: TOptions if (options?.query) { params.query = options.query; } - return [ - params - ]; + return [params]; }; -export const getMeQueryKey = (options?: Options) => createQueryKey("getMe", options); +export const getMeQueryKey = (options?: Options) => createQueryKey('getMe', options); /** * Get current authenticated user @@ -110,7 +108,7 @@ export const getMeOptions = (options?: Options) => queryOptions) => createQueryKey("getStatus", options); +export const getStatusQueryKey = (options?: Options) => createQueryKey('getStatus', options); /** * Get authentication system status @@ -145,7 +143,7 @@ export const changePasswordMutation = (options?: Partial) => createQueryKey("listVolumes", options); +export const listVolumesQueryKey = (options?: Options) => createQueryKey('listVolumes', options); /** * List all volumes @@ -214,7 +212,7 @@ export const deleteVolumeMutation = (options?: Partial return mutationOptions; }; -export const getVolumeQueryKey = (options: Options) => createQueryKey("getVolume", options); +export const getVolumeQueryKey = (options: Options) => createQueryKey('getVolume', options); /** * Get a volume by name @@ -249,7 +247,7 @@ export const updateVolumeMutation = (options?: Partial return mutationOptions; }; -export const getContainersUsingVolumeQueryKey = (options: Options) => createQueryKey("getContainersUsingVolume", options); +export const getContainersUsingVolumeQueryKey = (options: Options) => createQueryKey('getContainersUsingVolume', options); /** * Get containers using a volume by name @@ -318,7 +316,7 @@ export const healthCheckVolumeMutation = (options?: Partial) => createQueryKey("listFiles", options); +export const listFilesQueryKey = (options: Options) => createQueryKey('listFiles', options); /** * List files in a volume directory @@ -336,7 +334,7 @@ export const listFilesOptions = (options: Options) => queryOption queryKey: listFilesQueryKey(options) }); -export const browseFilesystemQueryKey = (options?: Options) => createQueryKey("browseFilesystem", options); +export const browseFilesystemQueryKey = (options?: Options) => createQueryKey('browseFilesystem', options); /** * Browse directories on the host filesystem @@ -354,7 +352,7 @@ export const browseFilesystemOptions = (options?: Options) queryKey: browseFilesystemQueryKey(options) }); -export const listRepositoriesQueryKey = (options?: Options) => createQueryKey("listRepositories", options); +export const listRepositoriesQueryKey = (options?: Options) => createQueryKey('listRepositories', options); /** * List all repositories @@ -389,7 +387,7 @@ export const createRepositoryMutation = (options?: Partial) => createQueryKey("listRcloneRemotes", options); +export const listRcloneRemotesQueryKey = (options?: Options) => createQueryKey('listRcloneRemotes', options); /** * List all configured rclone remotes on the host system @@ -424,7 +422,7 @@ export const deleteRepositoryMutation = (options?: Partial) => createQueryKey("getRepository", options); +export const getRepositoryQueryKey = (options: Options) => createQueryKey('getRepository', options); /** * Get a single repository by name @@ -459,7 +457,7 @@ export const updateRepositoryMutation = (options?: Partial) => createQueryKey("listSnapshots", options); +export const listSnapshotsQueryKey = (options: Options) => createQueryKey('listSnapshots', options); /** * List all snapshots in a repository @@ -494,7 +492,7 @@ export const deleteSnapshotMutation = (options?: Partial) => createQueryKey("getSnapshotDetails", options); +export const getSnapshotDetailsQueryKey = (options: Options) => createQueryKey('getSnapshotDetails', options); /** * Get details of a specific snapshot @@ -512,7 +510,7 @@ export const getSnapshotDetailsOptions = (options: Options) => createQueryKey("listSnapshotFiles", options); +export const listSnapshotFilesQueryKey = (options: Options) => createQueryKey('listSnapshotFiles', options); /** * List files and directories in a snapshot @@ -564,7 +562,7 @@ export const doctorRepositoryMutation = (options?: Partial) => createQueryKey("listBackupSchedules", options); +export const listBackupSchedulesQueryKey = (options?: Options) => createQueryKey('listBackupSchedules', options); /** * List all backup schedules @@ -616,7 +614,7 @@ export const deleteBackupScheduleMutation = (options?: Partial) => createQueryKey("getBackupSchedule", options); +export const getBackupScheduleQueryKey = (options: Options) => createQueryKey('getBackupSchedule', options); /** * Get a backup schedule by ID @@ -651,7 +649,7 @@ export const updateBackupScheduleMutation = (options?: Partial) => createQueryKey("getBackupScheduleForVolume", options); +export const getBackupScheduleForVolumeQueryKey = (options: Options) => createQueryKey('getBackupScheduleForVolume', options); /** * Get a backup schedule for a specific volume @@ -720,7 +718,7 @@ export const runForgetMutation = (options?: Partial>): Us return mutationOptions; }; -export const getScheduleNotificationsQueryKey = (options: Options) => createQueryKey("getScheduleNotifications", options); +export const getScheduleNotificationsQueryKey = (options: Options) => createQueryKey('getScheduleNotifications', options); /** * Get notification assignments for a backup schedule @@ -755,7 +753,7 @@ export const updateScheduleNotificationsMutation = (options?: Partial) => createQueryKey("getScheduleMirrors", options); +export const getScheduleMirrorsQueryKey = (options: Options) => createQueryKey('getScheduleMirrors', options); /** * Get mirror repository assignments for a backup schedule @@ -790,7 +788,7 @@ export const updateScheduleMirrorsMutation = (options?: Partial) => createQueryKey("getMirrorCompatibility", options); +export const getMirrorCompatibilityQueryKey = (options: Options) => createQueryKey('getMirrorCompatibility', options); /** * Get mirror compatibility info for all repositories relative to a backup schedule's primary repository @@ -808,7 +806,7 @@ export const getMirrorCompatibilityOptions = (options: Options) => createQueryKey("listNotificationDestinations", options); +export const listNotificationDestinationsQueryKey = (options?: Options) => createQueryKey('listNotificationDestinations', options); /** * List all notification destinations @@ -860,7 +858,7 @@ export const deleteNotificationDestinationMutation = (options?: Partial) => createQueryKey("getNotificationDestination", options); +export const getNotificationDestinationQueryKey = (options: Options) => createQueryKey('getNotificationDestination', options); /** * Get a notification destination by ID @@ -912,7 +910,7 @@ export const testNotificationDestinationMutation = (options?: Partial) => createQueryKey("getSystemInfo", options); +export const getSystemInfoQueryKey = (options?: Options) => createQueryKey('getSystemInfo', options); /** * Get system information including available capabilities diff --git a/app/client/api-client/client.gen.ts b/app/client/api-client/client.gen.ts index 50c3a3f..4dc9424 100644 --- a/app/client/api-client/client.gen.ts +++ b/app/client/api-client/client.gen.ts @@ -13,6 +13,4 @@ import type { ClientOptions as ClientOptions2 } from './types.gen'; */ export type CreateClientConfig = (override?: Config) => Config & T>; -export const client = createClient(createConfig({ - baseUrl: 'http://192.168.2.42:4096' -})); +export const client = createClient(createConfig({ baseUrl: 'http://192.168.2.42:4096' })); diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 1b8c032..c27c269 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -21,583 +21,371 @@ export type Options(options?: Options) => { - return (options?.client ?? client).post({ - url: '/api/v1/auth/register', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } - }); -}; +export const register = (options?: Options) => (options?.client ?? client).post({ + url: '/api/v1/auth/register', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); /** * Login with username and password */ -export const login = (options?: Options) => { - return (options?.client ?? client).post({ - url: '/api/v1/auth/login', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } - }); -}; +export const login = (options?: Options) => (options?.client ?? client).post({ + url: '/api/v1/auth/login', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); /** * Logout current user */ -export const logout = (options?: Options) => { - return (options?.client ?? client).post({ - url: '/api/v1/auth/logout', - ...options - }); -}; +export const logout = (options?: Options) => (options?.client ?? client).post({ url: '/api/v1/auth/logout', ...options }); /** * Get current authenticated user */ -export const getMe = (options?: Options) => { - return (options?.client ?? client).get({ - url: '/api/v1/auth/me', - ...options - }); -}; +export const getMe = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/auth/me', ...options }); /** * Get authentication system status */ -export const getStatus = (options?: Options) => { - return (options?.client ?? client).get({ - url: '/api/v1/auth/status', - ...options - }); -}; +export const getStatus = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/auth/status', ...options }); /** * Change current user password */ -export const changePassword = (options?: Options) => { - return (options?.client ?? client).post({ - url: '/api/v1/auth/change-password', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } - }); -}; +export const changePassword = (options?: Options) => (options?.client ?? client).post({ + url: '/api/v1/auth/change-password', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); /** * List all volumes */ -export const listVolumes = (options?: Options) => { - return (options?.client ?? client).get({ - url: '/api/v1/volumes', - ...options - }); -}; +export const listVolumes = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/volumes', ...options }); /** * Create a new volume */ -export const createVolume = (options?: Options) => { - return (options?.client ?? client).post({ - url: '/api/v1/volumes', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } - }); -}; +export const createVolume = (options?: Options) => (options?.client ?? client).post({ + url: '/api/v1/volumes', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); /** * Test connection to backend */ -export const testConnection = (options?: Options) => { - return (options?.client ?? client).post({ - url: '/api/v1/volumes/test-connection', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } - }); -}; +export const testConnection = (options?: Options) => (options?.client ?? client).post({ + url: '/api/v1/volumes/test-connection', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); /** * Delete a volume */ -export const deleteVolume = (options: Options) => { - return (options.client ?? client).delete({ - url: '/api/v1/volumes/{name}', - ...options - }); -}; +export const deleteVolume = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/volumes/{name}', ...options }); /** * Get a volume by name */ -export const getVolume = (options: Options) => { - return (options.client ?? client).get({ - url: '/api/v1/volumes/{name}', - ...options - }); -}; +export const getVolume = (options: Options) => (options.client ?? client).get({ url: '/api/v1/volumes/{name}', ...options }); /** * Update a volume's configuration */ -export const updateVolume = (options: Options) => { - return (options.client ?? client).put({ - url: '/api/v1/volumes/{name}', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } - }); -}; +export const updateVolume = (options: Options) => (options.client ?? client).put({ + url: '/api/v1/volumes/{name}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); /** * Get containers using a volume by name */ -export const getContainersUsingVolume = (options: Options) => { - return (options.client ?? client).get({ - url: '/api/v1/volumes/{name}/containers', - ...options - }); -}; +export const getContainersUsingVolume = (options: Options) => (options.client ?? client).get({ url: '/api/v1/volumes/{name}/containers', ...options }); /** * Mount a volume */ -export const mountVolume = (options: Options) => { - return (options.client ?? client).post({ - url: '/api/v1/volumes/{name}/mount', - ...options - }); -}; +export const mountVolume = (options: Options) => (options.client ?? client).post({ url: '/api/v1/volumes/{name}/mount', ...options }); /** * Unmount a volume */ -export const unmountVolume = (options: Options) => { - return (options.client ?? client).post({ - url: '/api/v1/volumes/{name}/unmount', - ...options - }); -}; +export const unmountVolume = (options: Options) => (options.client ?? client).post({ url: '/api/v1/volumes/{name}/unmount', ...options }); /** * Perform a health check on a volume */ -export const healthCheckVolume = (options: Options) => { - return (options.client ?? client).post({ - url: '/api/v1/volumes/{name}/health-check', - ...options - }); -}; +export const healthCheckVolume = (options: Options) => (options.client ?? client).post({ url: '/api/v1/volumes/{name}/health-check', ...options }); /** * List files in a volume directory */ -export const listFiles = (options: Options) => { - return (options.client ?? client).get({ - url: '/api/v1/volumes/{name}/files', - ...options - }); -}; +export const listFiles = (options: Options) => (options.client ?? client).get({ url: '/api/v1/volumes/{name}/files', ...options }); /** * Browse directories on the host filesystem */ -export const browseFilesystem = (options?: Options) => { - return (options?.client ?? client).get({ - url: '/api/v1/volumes/filesystem/browse', - ...options - }); -}; +export const browseFilesystem = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/volumes/filesystem/browse', ...options }); /** * List all repositories */ -export const listRepositories = (options?: Options) => { - return (options?.client ?? client).get({ - url: '/api/v1/repositories', - ...options - }); -}; +export const listRepositories = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/repositories', ...options }); /** * Create a new restic repository */ -export const createRepository = (options?: Options) => { - return (options?.client ?? client).post({ - url: '/api/v1/repositories', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } - }); -}; +export const createRepository = (options?: Options) => (options?.client ?? client).post({ + url: '/api/v1/repositories', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); /** * List all configured rclone remotes on the host system */ -export const listRcloneRemotes = (options?: Options) => { - return (options?.client ?? client).get({ - url: '/api/v1/repositories/rclone-remotes', - ...options - }); -}; +export const listRcloneRemotes = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/repositories/rclone-remotes', ...options }); /** * Delete a repository */ -export const deleteRepository = (options: Options) => { - return (options.client ?? client).delete({ - url: '/api/v1/repositories/{name}', - ...options - }); -}; +export const deleteRepository = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/repositories/{name}', ...options }); /** * Get a single repository by name */ -export const getRepository = (options: Options) => { - return (options.client ?? client).get({ - url: '/api/v1/repositories/{name}', - ...options - }); -}; +export const getRepository = (options: Options) => (options.client ?? client).get({ url: '/api/v1/repositories/{name}', ...options }); /** * Update a repository's name or settings */ -export const updateRepository = (options: Options) => { - return (options.client ?? client).patch({ - url: '/api/v1/repositories/{name}', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } - }); -}; +export const updateRepository = (options: Options) => (options.client ?? client).patch({ + url: '/api/v1/repositories/{name}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); /** * List all snapshots in a repository */ -export const listSnapshots = (options: Options) => { - return (options.client ?? client).get({ - url: '/api/v1/repositories/{name}/snapshots', - ...options - }); -}; +export const listSnapshots = (options: Options) => (options.client ?? client).get({ url: '/api/v1/repositories/{name}/snapshots', ...options }); /** * Delete a specific snapshot from a repository */ -export const deleteSnapshot = (options: Options) => { - return (options.client ?? client).delete({ - url: '/api/v1/repositories/{name}/snapshots/{snapshotId}', - ...options - }); -}; +export const deleteSnapshot = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}', ...options }); /** * Get details of a specific snapshot */ -export const getSnapshotDetails = (options: Options) => { - return (options.client ?? client).get({ - url: '/api/v1/repositories/{name}/snapshots/{snapshotId}', - ...options - }); -}; +export const getSnapshotDetails = (options: Options) => (options.client ?? client).get({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}', ...options }); /** * List files and directories in a snapshot */ -export const listSnapshotFiles = (options: Options) => { - return (options.client ?? client).get({ - url: '/api/v1/repositories/{name}/snapshots/{snapshotId}/files', - ...options - }); -}; +export const listSnapshotFiles = (options: Options) => (options.client ?? client).get({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}/files', ...options }); /** * Restore a snapshot to a target path on the filesystem */ -export const restoreSnapshot = (options: Options) => { - return (options.client ?? client).post({ - url: '/api/v1/repositories/{name}/restore', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } - }); -}; +export const restoreSnapshot = (options: Options) => (options.client ?? client).post({ + url: '/api/v1/repositories/{name}/restore', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); /** * Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors. */ -export const doctorRepository = (options: Options) => { - return (options.client ?? client).post({ - url: '/api/v1/repositories/{name}/doctor', - ...options - }); -}; +export const doctorRepository = (options: Options) => (options.client ?? client).post({ url: '/api/v1/repositories/{name}/doctor', ...options }); /** * List all backup schedules */ -export const listBackupSchedules = (options?: Options) => { - return (options?.client ?? client).get({ - url: '/api/v1/backups', - ...options - }); -}; +export const listBackupSchedules = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/backups', ...options }); /** * Create a new backup schedule for a volume */ -export const createBackupSchedule = (options?: Options) => { - return (options?.client ?? client).post({ - url: '/api/v1/backups', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } - }); -}; +export const createBackupSchedule = (options?: Options) => (options?.client ?? client).post({ + url: '/api/v1/backups', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); /** * Delete a backup schedule */ -export const deleteBackupSchedule = (options: Options) => { - return (options.client ?? client).delete({ - url: '/api/v1/backups/{scheduleId}', - ...options - }); -}; +export const deleteBackupSchedule = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/backups/{scheduleId}', ...options }); /** * Get a backup schedule by ID */ -export const getBackupSchedule = (options: Options) => { - return (options.client ?? client).get({ - url: '/api/v1/backups/{scheduleId}', - ...options - }); -}; +export const getBackupSchedule = (options: Options) => (options.client ?? client).get({ url: '/api/v1/backups/{scheduleId}', ...options }); /** * Update a backup schedule */ -export const updateBackupSchedule = (options: Options) => { - return (options.client ?? client).patch({ - url: '/api/v1/backups/{scheduleId}', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } - }); -}; +export const updateBackupSchedule = (options: Options) => (options.client ?? client).patch({ + url: '/api/v1/backups/{scheduleId}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); /** * Get a backup schedule for a specific volume */ -export const getBackupScheduleForVolume = (options: Options) => { - return (options.client ?? client).get({ - url: '/api/v1/backups/volume/{volumeId}', - ...options - }); -}; +export const getBackupScheduleForVolume = (options: Options) => (options.client ?? client).get({ url: '/api/v1/backups/volume/{volumeId}', ...options }); /** * Trigger a backup immediately for a schedule */ -export const runBackupNow = (options: Options) => { - return (options.client ?? client).post({ - url: '/api/v1/backups/{scheduleId}/run', - ...options - }); -}; +export const runBackupNow = (options: Options) => (options.client ?? client).post({ url: '/api/v1/backups/{scheduleId}/run', ...options }); /** * Stop a backup that is currently in progress */ -export const stopBackup = (options: Options) => { - return (options.client ?? client).post({ - url: '/api/v1/backups/{scheduleId}/stop', - ...options - }); -}; +export const stopBackup = (options: Options) => (options.client ?? client).post({ url: '/api/v1/backups/{scheduleId}/stop', ...options }); /** * Manually apply retention policy to clean up old snapshots */ -export const runForget = (options: Options) => { - return (options.client ?? client).post({ - url: '/api/v1/backups/{scheduleId}/forget', - ...options - }); -}; +export const runForget = (options: Options) => (options.client ?? client).post({ url: '/api/v1/backups/{scheduleId}/forget', ...options }); /** * Get notification assignments for a backup schedule */ -export const getScheduleNotifications = (options: Options) => { - return (options.client ?? client).get({ - url: '/api/v1/backups/{scheduleId}/notifications', - ...options - }); -}; +export const getScheduleNotifications = (options: Options) => (options.client ?? client).get({ url: '/api/v1/backups/{scheduleId}/notifications', ...options }); /** * Update notification assignments for a backup schedule */ -export const updateScheduleNotifications = (options: Options) => { - return (options.client ?? client).put({ - url: '/api/v1/backups/{scheduleId}/notifications', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } - }); -}; +export const updateScheduleNotifications = (options: Options) => (options.client ?? client).put({ + url: '/api/v1/backups/{scheduleId}/notifications', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); /** * Get mirror repository assignments for a backup schedule */ -export const getScheduleMirrors = (options: Options) => { - return (options.client ?? client).get({ - url: '/api/v1/backups/{scheduleId}/mirrors', - ...options - }); -}; +export const getScheduleMirrors = (options: Options) => (options.client ?? client).get({ url: '/api/v1/backups/{scheduleId}/mirrors', ...options }); /** * Update mirror repository assignments for a backup schedule */ -export const updateScheduleMirrors = (options: Options) => { - return (options.client ?? client).put({ - url: '/api/v1/backups/{scheduleId}/mirrors', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } - }); -}; +export const updateScheduleMirrors = (options: Options) => (options.client ?? client).put({ + url: '/api/v1/backups/{scheduleId}/mirrors', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); /** * Get mirror compatibility info for all repositories relative to a backup schedule's primary repository */ -export const getMirrorCompatibility = (options: Options) => { - return (options.client ?? client).get({ - url: '/api/v1/backups/{scheduleId}/mirrors/compatibility', - ...options - }); -}; +export const getMirrorCompatibility = (options: Options) => (options.client ?? client).get({ url: '/api/v1/backups/{scheduleId}/mirrors/compatibility', ...options }); /** * List all notification destinations */ -export const listNotificationDestinations = (options?: Options) => { - return (options?.client ?? client).get({ - url: '/api/v1/notifications/destinations', - ...options - }); -}; +export const listNotificationDestinations = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/notifications/destinations', ...options }); /** * Create a new notification destination */ -export const createNotificationDestination = (options?: Options) => { - return (options?.client ?? client).post({ - url: '/api/v1/notifications/destinations', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } - }); -}; +export const createNotificationDestination = (options?: Options) => (options?.client ?? client).post({ + url: '/api/v1/notifications/destinations', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); /** * Delete a notification destination */ -export const deleteNotificationDestination = (options: Options) => { - return (options.client ?? client).delete({ - url: '/api/v1/notifications/destinations/{id}', - ...options - }); -}; +export const deleteNotificationDestination = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/notifications/destinations/{id}', ...options }); /** * Get a notification destination by ID */ -export const getNotificationDestination = (options: Options) => { - return (options.client ?? client).get({ - url: '/api/v1/notifications/destinations/{id}', - ...options - }); -}; +export const getNotificationDestination = (options: Options) => (options.client ?? client).get({ url: '/api/v1/notifications/destinations/{id}', ...options }); /** * Update a notification destination */ -export const updateNotificationDestination = (options: Options) => { - return (options.client ?? client).patch({ - url: '/api/v1/notifications/destinations/{id}', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } - }); -}; +export const updateNotificationDestination = (options: Options) => (options.client ?? client).patch({ + url: '/api/v1/notifications/destinations/{id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); /** * Test a notification destination by sending a test message */ -export const testNotificationDestination = (options: Options) => { - return (options.client ?? client).post({ - url: '/api/v1/notifications/destinations/{id}/test', - ...options - }); -}; +export const testNotificationDestination = (options: Options) => (options.client ?? client).post({ url: '/api/v1/notifications/destinations/{id}/test', ...options }); /** * Get system information including available capabilities */ -export const getSystemInfo = (options?: Options) => { - return (options?.client ?? client).get({ - url: '/api/v1/system/info', - ...options - }); -}; +export const getSystemInfo = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/system/info', ...options }); /** * Download the Restic password file for backup recovery. Requires password re-authentication. */ -export const downloadResticPassword = (options?: Options) => { - return (options?.client ?? client).post({ - url: '/api/v1/system/restic-password', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } - }); -}; +export const downloadResticPassword = (options?: Options) => (options?.client ?? client).post({ + url: '/api/v1/system/restic-password', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } +}); diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index bb15e40..6a8bc0d 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -1297,6 +1297,7 @@ export type ListBackupSchedulesResponses = { lastBackupAt: number | null; lastBackupError: string | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; + name: string; nextBackupAt: number | null; repository: { compressionMode: 'auto' | 'max' | 'off' | null; @@ -1435,6 +1436,7 @@ export type CreateBackupScheduleData = { body?: { cronExpression: string; enabled: boolean; + name: string; repositoryId: string; volumeId: number; excludePatterns?: Array; @@ -1469,6 +1471,7 @@ export type CreateBackupScheduleResponses = { lastBackupAt: number | null; lastBackupError: string | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; + name: string; nextBackupAt: number | null; repositoryId: string; retentionPolicy: { @@ -1530,6 +1533,7 @@ export type GetBackupScheduleResponses = { lastBackupAt: number | null; lastBackupError: string | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; + name: string; nextBackupAt: number | null; repository: { compressionMode: 'auto' | 'max' | 'off' | null; @@ -1671,6 +1675,7 @@ export type UpdateBackupScheduleData = { enabled?: boolean; excludePatterns?: Array; includePatterns?: Array; + name?: string; retentionPolicy?: { keepDaily?: number; keepHourly?: number; @@ -1703,6 +1708,7 @@ export type UpdateBackupScheduleResponses = { lastBackupAt: number | null; lastBackupError: string | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; + name: string; nextBackupAt: number | null; repositoryId: string; retentionPolicy: { @@ -1744,6 +1750,7 @@ export type GetBackupScheduleForVolumeResponses = { lastBackupAt: number | null; lastBackupError: string | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; + name: string; nextBackupAt: number | null; repository: { compressionMode: 'auto' | 'max' | 'off' | null; diff --git a/app/client/modules/backups/components/create-schedule-form.tsx b/app/client/modules/backups/components/create-schedule-form.tsx index ee4d1d8..1fdede6 100644 --- a/app/client/modules/backups/components/create-schedule-form.tsx +++ b/app/client/modules/backups/components/create-schedule-form.tsx @@ -23,6 +23,7 @@ import type { BackupSchedule, Volume } from "~/client/lib/types"; import { deepClean } from "~/utils/object"; const internalFormSchema = type({ + name: "1 <= string <= 32", repositoryId: "string", excludePatternsText: "string?", includePatterns: "string[]?", @@ -80,6 +81,7 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValu const weeklyDay = frequency === "weekly" ? dayOfWeekPart : undefined; return { + name: schedule.name, repositoryId: schedule.repositoryId, frequency, dailyTime, @@ -148,6 +150,21 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: + ( + + Backup name + + + + A unique name to identify this backup schedule. + + + )} + /> + {
- Backup schedule - - Automated backup configuration for volume  - {schedule.volume.name} + {schedule.name} + + + + {schedule.volume.name} + + + + + {schedule.repository.name} +
diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index be07779..5a03609 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -35,10 +35,10 @@ import { ScheduleMirrorsConfig } from "../components/schedule-mirrors-config"; import { cn } from "~/client/lib/utils"; export const handle = { - breadcrumb: (match: Route.MetaArgs) => [ - { label: "Backups", href: "/backups" }, - { label: `Schedule #${match.params.id}` }, - ], + breadcrumb: (match: Route.MetaArgs) => { + const data = match.loaderData; + return [{ label: "Backups", href: "/backups" }, { label: data.schedule.name }]; + }, }; export function meta(_: Route.MetaArgs) { @@ -153,6 +153,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon updateSchedule.mutate({ path: { scheduleId: schedule.id.toString() }, body: { + name: formValues.name, repositoryId: formValues.repositoryId, enabled: schedule.enabled, cronExpression, diff --git a/app/client/modules/backups/routes/backups.tsx b/app/client/modules/backups/routes/backups.tsx index 7a547f0..b6ac602 100644 --- a/app/client/modules/backups/routes/backups.tsx +++ b/app/client/modules/backups/routes/backups.tsx @@ -67,13 +67,11 @@ export default function Backups({ loaderData }: Route.ComponentProps) { {schedules.map((schedule) => ( - -
-
- - - Volume {schedule.volume.name} - + +
+
+ + {schedule.name}
- - - {schedule.repository.name} + + + {schedule.volume.name} + + + {schedule.repository.name}
diff --git a/app/client/modules/backups/routes/create-backup.tsx b/app/client/modules/backups/routes/create-backup.tsx index f982086..400da91 100644 --- a/app/client/modules/backups/routes/create-backup.tsx +++ b/app/client/modules/backups/routes/create-backup.tsx @@ -83,6 +83,7 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) { createSchedule.mutate({ body: { + name: formValues.name, volumeId: selectedVolumeId, repositoryId: formValues.repositoryId, enabled: true, diff --git a/app/drizzle/0019_secret_nomad.sql b/app/drizzle/0019_secret_nomad.sql new file mode 100644 index 0000000..b8e3bbb --- /dev/null +++ b/app/drizzle/0019_secret_nomad.sql @@ -0,0 +1,24 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_backup_schedules_table` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` text NOT NULL, + `volume_id` integer NOT NULL REFERENCES `volumes_table`(`id`) ON DELETE CASCADE, + `repository_id` text NOT NULL REFERENCES `repositories_table`(`id`) ON DELETE CASCADE, + `enabled` integer DEFAULT true NOT NULL, + `cron_expression` text NOT NULL, + `retention_policy` text, + `exclude_patterns` text DEFAULT '[]', + `include_patterns` text DEFAULT '[]', + `last_backup_at` integer, + `last_backup_status` text, + `last_backup_error` text, + `next_backup_at` integer, + `created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL, + `updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL +);--> statement-breakpoint +INSERT INTO `__new_backup_schedules_table`(`id`, `name`, `volume_id`, `repository_id`, `enabled`, `cron_expression`, `retention_policy`, `exclude_patterns`, `include_patterns`, `last_backup_at`, `last_backup_status`, `last_backup_error`, `next_backup_at`, `created_at`, `updated_at`) +SELECT `id`, lower(hex(randomblob(3))), `volume_id`, `repository_id`, `enabled`, `cron_expression`, `retention_policy`, `exclude_patterns`, `include_patterns`, `last_backup_at`, `last_backup_status`, `last_backup_error`, `next_backup_at`, `created_at`, `updated_at` FROM `backup_schedules_table`;--> statement-breakpoint +DROP TABLE `backup_schedules_table`;--> statement-breakpoint +ALTER TABLE `__new_backup_schedules_table` RENAME TO `backup_schedules_table`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE UNIQUE INDEX `backup_schedules_table_name_unique` ON `backup_schedules_table` (`name`); \ No newline at end of file diff --git a/app/drizzle/meta/0019_snapshot.json b/app/drizzle/meta/0019_snapshot.json new file mode 100644 index 0000000..3f2d178 --- /dev/null +++ b/app/drizzle/meta/0019_snapshot.json @@ -0,0 +1,807 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b5b3acff-51d7-45ae-b9d2-4b07a6286fc3", + "prevId": "d5a60aea-4490-423e-8725-6ace87a76c9b", + "tables": { + "app_metadata": { + "name": "app_metadata", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backup_schedule_mirrors_table": { + "name": "backup_schedule_mirrors_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_copy_at": { + "name": "last_copy_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_copy_status": { + "name": "last_copy_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_copy_error": { + "name": "last_copy_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "backup_schedule_mirrors_table_schedule_id_repository_id_unique": { + "name": "backup_schedule_mirrors_table_schedule_id_repository_id_unique", + "columns": [ + "schedule_id", + "repository_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk": { + "name": "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk", + "tableFrom": "backup_schedule_mirrors_table", + "tableTo": "backup_schedules_table", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk": { + "name": "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk", + "tableFrom": "backup_schedule_mirrors_table", + "tableTo": "repositories_table", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backup_schedule_notifications_table": { + "name": "backup_schedule_notifications_table", + "columns": { + "schedule_id": { + "name": "schedule_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "destination_id": { + "name": "destination_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notify_on_start": { + "name": "notify_on_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notify_on_success": { + "name": "notify_on_success", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notify_on_failure": { + "name": "notify_on_failure", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk": { + "name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk", + "tableFrom": "backup_schedule_notifications_table", + "tableTo": "backup_schedules_table", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk": { + "name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk", + "tableFrom": "backup_schedule_notifications_table", + "tableTo": "notification_destinations_table", + "columnsFrom": [ + "destination_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "backup_schedule_notifications_table_schedule_id_destination_id_pk": { + "columns": [ + "schedule_id", + "destination_id" + ], + "name": "backup_schedule_notifications_table_schedule_id_destination_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backup_schedules_table": { + "name": "backup_schedules_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "volume_id": { + "name": "volume_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retention_policy": { + "name": "retention_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exclude_patterns": { + "name": "exclude_patterns", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "include_patterns": { + "name": "include_patterns", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "last_backup_at": { + "name": "last_backup_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backup_status": { + "name": "last_backup_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backup_error": { + "name": "last_backup_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_backup_at": { + "name": "next_backup_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "backup_schedules_table_name_unique": { + "name": "backup_schedules_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "backup_schedules_table_volume_id_volumes_table_id_fk": { + "name": "backup_schedules_table_volume_id_volumes_table_id_fk", + "tableFrom": "backup_schedules_table", + "tableTo": "volumes_table", + "columnsFrom": [ + "volume_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_schedules_table_repository_id_repositories_table_id_fk": { + "name": "backup_schedules_table_repository_id_repositories_table_id_fk", + "tableFrom": "backup_schedules_table", + "tableTo": "repositories_table", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_destinations_table": { + "name": "notification_destinations_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "notification_destinations_table_name_unique": { + "name": "notification_destinations_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories_table": { + "name": "repositories_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "short_id": { + "name": "short_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "compression_mode": { + "name": "compression_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'auto'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'unknown'" + }, + "last_checked": { + "name": "last_checked", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "repositories_table_short_id_unique": { + "name": "repositories_table_short_id_unique", + "columns": [ + "short_id" + ], + "isUnique": true + }, + "repositories_table_name_unique": { + "name": "repositories_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions_table": { + "name": "sessions_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_table_user_id_users_table_id_fk": { + "name": "sessions_table_user_id_users_table_id_fk", + "tableFrom": "sessions_table", + "tableTo": "users_table", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_table": { + "name": "users_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_downloaded_restic_password": { + "name": "has_downloaded_restic_password", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "users_table_username_unique": { + "name": "users_table_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "volumes_table": { + "name": "volumes_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "short_id": { + "name": "short_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unmounted'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_health_check": { + "name": "last_health_check", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auto_remount": { + "name": "auto_remount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "volumes_table_short_id_unique": { + "name": "volumes_table_short_id_unique", + "columns": [ + "short_id" + ], + "isUnique": true + }, + "volumes_table_name_unique": { + "name": "volumes_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/app/drizzle/meta/_journal.json b/app/drizzle/meta/_journal.json index 46fb2dd..36145bb 100644 --- a/app/drizzle/meta/_journal.json +++ b/app/drizzle/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1764794371040, "tag": "0018_breezy_invaders", "breakpoints": true + }, + { + "idx": 19, + "version": "6", + "when": 1764839917446, + "tag": "0019_secret_nomad", + "breakpoints": true } ] } \ No newline at end of file diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index 6bfa699..469732d 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -67,6 +67,7 @@ export type Repository = typeof repositoriesTable.$inferSelect; */ export const backupSchedulesTable = sqliteTable("backup_schedules_table", { id: int().primaryKey({ autoIncrement: true }), + name: text().notNull().unique(), volumeId: int("volume_id") .notNull() .references(() => volumesTable.id, { onDelete: "cascade" }), diff --git a/app/server/modules/backups/backups.dto.ts b/app/server/modules/backups/backups.dto.ts index 464891b..4cbc048 100644 --- a/app/server/modules/backups/backups.dto.ts +++ b/app/server/modules/backups/backups.dto.ts @@ -17,6 +17,7 @@ export type RetentionPolicy = typeof retentionPolicySchema.infer; const backupScheduleSchema = type({ id: "number", + name: "string", volumeId: "number", repositoryId: "string", enabled: "boolean", @@ -120,6 +121,7 @@ export const getBackupScheduleForVolumeDto = describeRoute({ * Create a new backup schedule */ export const createBackupScheduleBody = type({ + name: "1 <= string <= 32", volumeId: "number", repositoryId: "string", enabled: "boolean", @@ -156,6 +158,7 @@ export const createBackupScheduleDto = describeRoute({ * Update a backup schedule */ export const updateBackupScheduleBody = type({ + name: "(1 <= string <= 32)?", repositoryId: "string", enabled: "boolean?", cronExpression: "string", diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts index df9e42b..9dcb8be 100644 --- a/app/server/modules/backups/backups.service.ts +++ b/app/server/modules/backups/backups.service.ts @@ -1,4 +1,4 @@ -import { eq } from "drizzle-orm"; +import { and, eq, ne } from "drizzle-orm"; import cron from "node-cron"; import { CronExpressionParser } from "cron-parser"; import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced"; @@ -44,7 +44,7 @@ const listSchedules = async () => { const getSchedule = async (scheduleId: number) => { const schedule = await db.query.backupSchedulesTable.findFirst({ - where: eq(volumesTable.id, scheduleId), + where: eq(backupSchedulesTable.id, scheduleId), with: { volume: true, repository: true, @@ -63,6 +63,14 @@ const createSchedule = async (data: CreateBackupScheduleBody) => { throw new BadRequestError("Invalid cron expression"); } + const existingName = await db.query.backupSchedulesTable.findFirst({ + where: eq(backupSchedulesTable.name, data.name), + }); + + if (existingName) { + throw new ConflictError("A backup schedule with this name already exists"); + } + const volume = await db.query.volumesTable.findFirst({ where: eq(volumesTable.id, data.volumeId), }); @@ -84,6 +92,7 @@ const createSchedule = async (data: CreateBackupScheduleBody) => { const [newSchedule] = await db .insert(backupSchedulesTable) .values({ + name: data.name, volumeId: data.volumeId, repositoryId: data.repositoryId, enabled: data.enabled, @@ -115,6 +124,16 @@ const updateSchedule = async (scheduleId: number, data: UpdateBackupScheduleBody throw new BadRequestError("Invalid cron expression"); } + if (data.name) { + const existingName = await db.query.backupSchedulesTable.findFirst({ + where: and(eq(backupSchedulesTable.name, data.name), ne(backupSchedulesTable.id, scheduleId)), + }); + + if (existingName) { + throw new ConflictError("A backup schedule with this name already exists"); + } + } + const repository = await db.query.repositoriesTable.findFirst({ where: eq(repositoriesTable.id, data.repositoryId), }); diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index 84eaf08..55442f3 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; +import os from "node:os"; import { throttle } from "es-toolkit"; import { type } from "arktype"; import { $ } from "bun"; @@ -261,8 +262,9 @@ const backup = async ( let includeFile: string | null = null; if (options?.include && options.include.length > 0) { - const tmp = await fs.mkdtemp("restic-include"); + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-include-")); includeFile = path.join(tmp, `include.txt`); + const includePaths = options.include.map((p) => path.join(source, p)); await fs.writeFile(includeFile, includePaths.join("\n"), "utf-8"); diff --git a/bun.lock b/bun.lock index dd2432a..66bf1d8 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ "dotenv": "^17.2.3", "drizzle-orm": "^0.44.7", "es-toolkit": "^1.42.0", - "hono": "^4.10.7", + "hono": "4.10.5", "hono-openapi": "^1.1.1", "http-errors-enhanced": "^4.0.2", "isbot": "^5.1.32", @@ -41,7 +41,7 @@ "node-cron": "^4.2.1", "react": "^19.2.1", "react-dom": "^19.2.1", - "react-hook-form": "^7.67.0", + "react-hook-form": "^7.68.0", "react-router": "^7.10.0", "react-router-hono-server": "^2.22.0", "recharts": "3.5.1", @@ -770,7 +770,7 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="], + "hono": ["hono@4.10.5", "", {}, "sha512-h/MXuTkoAK8NG1EfDp0jI1YLf6yGdDnfkebRO2pwEh5+hE3RAJFXkCsnD0vamSiARK4ZrB6MY+o3E/hCnOyHrQ=="], "hono-openapi": ["hono-openapi@1.1.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.1.2", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.8", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-AC3HNhZYPHhnZdSy2Je7GDoTTNxPos6rKRQKVDBbSilY3cWJPqsxRnN6zA4pU7tfxmQEMTqkiLXbw6sAaemB8Q=="], @@ -952,7 +952,7 @@ "react-dom": ["react-dom@19.2.1", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.1" } }, "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg=="], - "react-hook-form": ["react-hook-form@7.67.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-E55EOwKJHHIT/I6J9DmQbCWToAYSw9nN5R57MZw9rMtjh+YQreMDxRLfdjfxQbiJ3/qbg3Z02wGzBX4M+5fMtQ=="], + "react-hook-form": ["react-hook-form@7.68.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q=="], "react-is": ["react-is@19.2.0", "", {}, "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA=="], @@ -1216,8 +1216,6 @@ "react-router-hono-server/@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], - "react-router-hono-server/hono": ["hono@4.10.5", "", {}, "sha512-h/MXuTkoAK8NG1EfDp0jI1YLf6yGdDnfkebRO2pwEh5+hE3RAJFXkCsnD0vamSiARK4ZrB6MY+o3E/hCnOyHrQ=="], - "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], diff --git a/package.json b/package.json index a1f54ed..4ea70bd 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "dotenv": "^17.2.3", "drizzle-orm": "^0.44.7", "es-toolkit": "^1.42.0", - "hono": "^4.10.7", + "hono": "4.10.5", "hono-openapi": "^1.1.1", "http-errors-enhanced": "^4.0.2", "isbot": "^5.1.32", @@ -54,7 +54,7 @@ "node-cron": "^4.2.1", "react": "^19.2.1", "react-dom": "^19.2.1", - "react-hook-form": "^7.67.0", + "react-hook-form": "^7.68.0", "react-router": "^7.10.0", "react-router-hono-server": "^2.22.0", "recharts": "3.5.1",