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/Dockerfile b/Dockerfile index 5267666..b9c01db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG BUN_VERSION="1.3.1" +ARG BUN_VERSION="1.3.3" FROM oven/bun:${BUN_VERSION}-alpine AS base @@ -14,7 +14,7 @@ WORKDIR /deps ARG TARGETARCH ARG RESTIC_VERSION="0.18.1" -ARG SHOUTRRR_VERSION="0.12.0" +ARG SHOUTRRR_VERSION="0.12.1" ENV TARGETARCH=${TARGETARCH} RUN apk add --no-cache curl bzip2 unzip tar diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index 5beda44..a4ac2a3 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -3,8 +3,8 @@ import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { client } from '../client.gen'; -import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getNotificationDestination, getRepository, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleNotifications, updateVolume } from '../sdk.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; +import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; /** * Register a new user @@ -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,60 @@ export const updateScheduleNotificationsMutation = (options?: Partial) => createQueryKey("listNotificationDestinations", options); +export const getScheduleMirrorsQueryKey = (options: Options) => createQueryKey('getScheduleMirrors', options); + +/** + * Get mirror repository assignments for a backup schedule + */ +export const getScheduleMirrorsOptions = (options: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getScheduleMirrors({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getScheduleMirrorsQueryKey(options) +}); + +/** + * Update mirror repository assignments for a backup schedule + */ +export const updateScheduleMirrorsMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await updateScheduleMirrors({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + +export const getMirrorCompatibilityQueryKey = (options: Options) => createQueryKey('getMirrorCompatibility', options); + +/** + * Get mirror compatibility info for all repositories relative to a backup schedule's primary repository + */ +export const getMirrorCompatibilityOptions = (options: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getMirrorCompatibility({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getMirrorCompatibilityQueryKey(options) +}); + +export const listNotificationDestinationsQueryKey = (options?: Options) => createQueryKey('listNotificationDestinations', options); /** * List all notification destinations @@ -807,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 @@ -859,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 9f5afe4..c27c269 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; export type Options = Options2 & { /** @@ -21,549 +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) => (options.client ?? client).get({ url: '/api/v1/backups/{scheduleId}/mirrors', ...options }); + +/** + * Update mirror repository assignments for a backup schedule + */ +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) => (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 7e82ed3..2790e84 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -1124,6 +1124,7 @@ export type ListSnapshotsResponses = { paths: Array; short_id: string; size: number; + tags: Array; time: number; }>; }; @@ -1170,6 +1171,7 @@ export type GetSnapshotDetailsResponses = { paths: Array; short_id: string; size: number; + tags: Array; time: number; }; }; @@ -1289,12 +1291,14 @@ export type ListBackupSchedulesResponses = { createdAt: number; cronExpression: string; enabled: boolean; + excludeIfPresent: Array | null; excludePatterns: Array | null; id: number; includePatterns: Array | null; lastBackupAt: number | null; lastBackupError: string | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; + name: string; nextBackupAt: number | null; repository: { compressionMode: 'auto' | 'max' | 'off' | null; @@ -1433,8 +1437,10 @@ export type CreateBackupScheduleData = { body?: { cronExpression: string; enabled: boolean; + name: string; repositoryId: string; volumeId: number; + excludeIfPresent?: Array; excludePatterns?: Array; includePatterns?: Array; retentionPolicy?: { @@ -1461,12 +1467,14 @@ export type CreateBackupScheduleResponses = { createdAt: number; cronExpression: string; enabled: boolean; + excludeIfPresent: Array | null; excludePatterns: Array | null; id: number; includePatterns: Array | null; lastBackupAt: number | null; lastBackupError: string | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; + name: string; nextBackupAt: number | null; repositoryId: string; retentionPolicy: { @@ -1522,12 +1530,14 @@ export type GetBackupScheduleResponses = { createdAt: number; cronExpression: string; enabled: boolean; + excludeIfPresent: Array | null; excludePatterns: Array | null; id: number; includePatterns: Array | null; lastBackupAt: number | null; lastBackupError: string | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; + name: string; nextBackupAt: number | null; repository: { compressionMode: 'auto' | 'max' | 'off' | null; @@ -1667,8 +1677,10 @@ export type UpdateBackupScheduleData = { cronExpression: string; repositoryId: string; enabled?: boolean; + excludeIfPresent?: Array; excludePatterns?: Array; includePatterns?: Array; + name?: string; retentionPolicy?: { keepDaily?: number; keepHourly?: number; @@ -1695,12 +1707,14 @@ export type UpdateBackupScheduleResponses = { createdAt: number; cronExpression: string; enabled: boolean; + excludeIfPresent: Array | null; excludePatterns: Array | null; id: number; includePatterns: Array | null; lastBackupAt: number | null; lastBackupError: string | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; + name: string; nextBackupAt: number | null; repositoryId: string; retentionPolicy: { @@ -1736,12 +1750,14 @@ export type GetBackupScheduleForVolumeResponses = { createdAt: number; cronExpression: string; enabled: boolean; + excludeIfPresent: Array | null; excludePatterns: Array | null; id: number; includePatterns: Array | null; lastBackupAt: number | null; lastBackupError: string | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; + name: string; nextBackupAt: number | null; repository: { compressionMode: 'auto' | 'max' | 'off' | null; @@ -2112,6 +2128,231 @@ export type UpdateScheduleNotificationsResponses = { export type UpdateScheduleNotificationsResponse = UpdateScheduleNotificationsResponses[keyof UpdateScheduleNotificationsResponses]; +export type GetScheduleMirrorsData = { + body?: never; + path: { + scheduleId: string; + }; + query?: never; + url: '/api/v1/backups/{scheduleId}/mirrors'; +}; + +export type GetScheduleMirrorsResponses = { + /** + * List of mirror repository assignments for the schedule + */ + 200: Array<{ + createdAt: number; + enabled: boolean; + lastCopyAt: number | null; + lastCopyError: string | null; + lastCopyStatus: 'error' | 'success' | null; + repository: { + compressionMode: 'auto' | 'max' | 'off' | null; + config: { + accessKeyId: string; + backend: 'r2'; + bucket: string; + endpoint: string; + secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + accessKeyId: string; + backend: 's3'; + bucket: string; + endpoint: string; + secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + accountKey: string; + accountName: string; + backend: 'azure'; + container: string; + customPassword?: string; + endpointSuffix?: string; + isExistingRepository?: boolean; + } | { + backend: 'gcs'; + bucket: string; + credentialsJson: string; + projectId: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + backend: 'local'; + name: string; + customPassword?: string; + isExistingRepository?: boolean; + path?: string; + } | { + backend: 'rclone'; + path: string; + remote: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + backend: 'rest'; + url: string; + customPassword?: string; + isExistingRepository?: boolean; + password?: string; + path?: string; + username?: string; + } | { + backend: 'sftp'; + host: string; + path: string; + privateKey: string; + user: string; + port?: number; + customPassword?: string; + isExistingRepository?: boolean; + }; + createdAt: number; + id: string; + lastChecked: number | null; + lastError: string | null; + name: string; + shortId: string; + status: 'error' | 'healthy' | 'unknown' | null; + type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; + updatedAt: number; + }; + repositoryId: string; + scheduleId: number; + }>; +}; + +export type GetScheduleMirrorsResponse = GetScheduleMirrorsResponses[keyof GetScheduleMirrorsResponses]; + +export type UpdateScheduleMirrorsData = { + body?: { + mirrors: Array<{ + enabled: boolean; + repositoryId: string; + }>; + }; + path: { + scheduleId: string; + }; + query?: never; + url: '/api/v1/backups/{scheduleId}/mirrors'; +}; + +export type UpdateScheduleMirrorsResponses = { + /** + * Mirror assignments updated successfully + */ + 200: Array<{ + createdAt: number; + enabled: boolean; + lastCopyAt: number | null; + lastCopyError: string | null; + lastCopyStatus: 'error' | 'success' | null; + repository: { + compressionMode: 'auto' | 'max' | 'off' | null; + config: { + accessKeyId: string; + backend: 'r2'; + bucket: string; + endpoint: string; + secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + accessKeyId: string; + backend: 's3'; + bucket: string; + endpoint: string; + secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + accountKey: string; + accountName: string; + backend: 'azure'; + container: string; + customPassword?: string; + endpointSuffix?: string; + isExistingRepository?: boolean; + } | { + backend: 'gcs'; + bucket: string; + credentialsJson: string; + projectId: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + backend: 'local'; + name: string; + customPassword?: string; + isExistingRepository?: boolean; + path?: string; + } | { + backend: 'rclone'; + path: string; + remote: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + backend: 'rest'; + url: string; + customPassword?: string; + isExistingRepository?: boolean; + password?: string; + path?: string; + username?: string; + } | { + backend: 'sftp'; + host: string; + path: string; + privateKey: string; + user: string; + port?: number; + customPassword?: string; + isExistingRepository?: boolean; + }; + createdAt: number; + id: string; + lastChecked: number | null; + lastError: string | null; + name: string; + shortId: string; + status: 'error' | 'healthy' | 'unknown' | null; + type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; + updatedAt: number; + }; + repositoryId: string; + scheduleId: number; + }>; +}; + +export type UpdateScheduleMirrorsResponse = UpdateScheduleMirrorsResponses[keyof UpdateScheduleMirrorsResponses]; + +export type GetMirrorCompatibilityData = { + body?: never; + path: { + scheduleId: string; + }; + query?: never; + url: '/api/v1/backups/{scheduleId}/mirrors/compatibility'; +}; + +export type GetMirrorCompatibilityResponses = { + /** + * List of repositories with their mirror compatibility status + */ + 200: Array<{ + compatible: boolean; + reason: string | null; + repositoryId: string; + }>; +}; + +export type GetMirrorCompatibilityResponse = GetMirrorCompatibilityResponses[keyof GetMirrorCompatibilityResponses]; + export type ListNotificationDestinationsData = { body?: never; path?: never; diff --git a/app/client/components/snapshots-table.tsx b/app/client/components/snapshots-table.tsx index 4b33e14..5b2fa42 100644 --- a/app/client/components/snapshots-table.tsx +++ b/app/client/components/snapshots-table.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Calendar, Clock, Database, FolderTree, HardDrive, Trash2, X } from "lucide-react"; -import { useNavigate } from "react-router"; +import { Link, useNavigate } from "react-router"; import { toast } from "sonner"; import { ByteSize } from "~/client/components/bytes-size"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table"; @@ -18,18 +18,17 @@ import { AlertDialogTitle, } from "~/client/components/ui/alert-dialog"; import { formatDuration } from "~/utils/utils"; -import type { ListSnapshotsResponse } from "../api-client"; import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { parseError } from "~/client/lib/errors"; - -type Snapshot = ListSnapshotsResponse[number]; +import type { BackupSchedule, Snapshot } from "../lib/types"; type Props = { snapshots: Snapshot[]; + backups: BackupSchedule[]; repositoryName: string; }; -export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => { +export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) => { const navigate = useNavigate(); const queryClient = useQueryClient(); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); @@ -76,6 +75,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => { Snapshot ID + Schedule Date & Time Size Duration @@ -84,71 +84,91 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => { - {snapshots.map((snapshot) => ( - handleRowClick(snapshot.short_id)} - > - -
- - {snapshot.short_id} -
-
- -
- - {new Date(snapshot.time).toLocaleString()} -
-
- -
- - - - -
-
- -
- - {formatDuration(snapshot.duration / 1000)} -
-
- -
- - - - - {snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"} - - - -
- {snapshot.paths.map((path) => ( -
- {path} -
- ))} -
-
-
-
-
- - - -
- ))} + {snapshots.map((snapshot) => { + const backupIds = snapshot.tags.map(Number).filter((tag) => !Number.isNaN(tag)); + const backup = backups.find((b) => backupIds.includes(b.id)); + + return ( + handleRowClick(snapshot.short_id)} + > + +
+ + {snapshot.short_id} +
+
+ +
+ e.stopPropagation()} + className="hover:underline" + > + {backup ? backup.id : "-"} + + +
+
+ +
+ + {new Date(snapshot.time).toLocaleString()} +
+
+ +
+ + + + +
+
+ +
+ + {formatDuration(snapshot.duration / 1000)} +
+
+ +
+ + + + + {snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"} + + + +
+ {snapshot.paths.map((path) => ( +
+ {path} +
+ ))} +
+
+
+
+
+ + + +
+ ); + })}
diff --git a/app/client/hooks/use-server-events.ts b/app/client/hooks/use-server-events.ts index 43c7205..d7c0377 100644 --- a/app/client/hooks/use-server-events.ts +++ b/app/client/hooks/use-server-events.ts @@ -9,7 +9,9 @@ type ServerEventType = | "backup:completed" | "volume:mounted" | "volume:unmounted" - | "volume:updated"; + | "volume:updated" + | "mirror:started" + | "mirror:completed"; export interface BackupEvent { scheduleId: number; @@ -35,6 +37,14 @@ export interface VolumeEvent { volumeName: string; } +export interface MirrorEvent { + scheduleId: number; + repositoryId: string; + repositoryName: string; + status?: "success" | "error"; + error?: string; +} + type EventHandler = (data: unknown) => void; /** @@ -125,6 +135,27 @@ export function useServerEvents() { }); }); + eventSource.addEventListener("mirror:started", (e) => { + const data = JSON.parse(e.data) as MirrorEvent; + console.log("[SSE] Mirror copy started:", data); + + handlersRef.current.get("mirror:started")?.forEach((handler) => { + handler(data); + }); + }); + + eventSource.addEventListener("mirror:completed", (e) => { + const data = JSON.parse(e.data) as MirrorEvent; + console.log("[SSE] Mirror copy completed:", data); + + // Invalidate queries to refresh mirror status in the UI + queryClient.invalidateQueries(); + + handlersRef.current.get("mirror:completed")?.forEach((handler) => { + handler(data); + }); + }); + eventSource.onerror = (error) => { console.error("[SSE] Connection error:", error); }; diff --git a/app/client/modules/backups/components/create-schedule-form.tsx b/app/client/modules/backups/components/create-schedule-form.tsx index ee4d1d8..8f6de25 100644 --- a/app/client/modules/backups/components/create-schedule-form.tsx +++ b/app/client/modules/backups/components/create-schedule-form.tsx @@ -23,8 +23,11 @@ import type { BackupSchedule, Volume } from "~/client/lib/types"; import { deepClean } from "~/utils/object"; const internalFormSchema = type({ + name: "1 <= string <= 32", repositoryId: "string", excludePatternsText: "string?", + excludeIfPresentText: "string?", + includePatternsText: "string?", includePatterns: "string[]?", frequency: "string", dailyTime: "string?", @@ -50,8 +53,12 @@ export const weeklyDays = [ type InternalFormValues = typeof internalFormSchema.infer; -export type BackupScheduleFormValues = Omit & { +export type BackupScheduleFormValues = Omit< + InternalFormValues, + "excludePatternsText" | "excludeIfPresentText" | "includePatternsText" +> & { excludePatterns?: string[]; + excludeIfPresent?: string[]; }; type Props = { @@ -79,13 +86,21 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValu const weeklyDay = frequency === "weekly" ? dayOfWeekPart : undefined; + const patterns = schedule.includePatterns || []; + const isGlobPattern = (p: string) => /[*?[\]]/.test(p); + const fileBrowserPaths = patterns.filter((p) => !isGlobPattern(p)); + const textPatterns = patterns.filter(isGlobPattern); + return { + name: schedule.name, repositoryId: schedule.repositoryId, frequency, dailyTime, weeklyDay, - includePatterns: schedule.includePatterns || undefined, + includePatterns: fileBrowserPaths.length > 0 ? fileBrowserPaths : undefined, + includePatternsText: textPatterns.length > 0 ? textPatterns.join("\n") : undefined, excludePatternsText: schedule.excludePatterns?.join("\n") || undefined, + excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined, ...schedule.retentionPolicy, }; }; @@ -98,18 +113,40 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: const handleSubmit = useCallback( (data: InternalFormValues) => { - // Convert excludePatternsText string to excludePatterns array - const { excludePatternsText, ...rest } = data; + const { + excludePatternsText, + excludeIfPresentText, + includePatternsText, + includePatterns: fileBrowserPatterns, + ...rest + } = data; const excludePatterns = excludePatternsText ? excludePatternsText .split("\n") .map((p) => p.trim()) .filter(Boolean) - : undefined; + : []; + + const excludeIfPresent = excludeIfPresentText + ? excludeIfPresentText + .split("\n") + .map((p) => p.trim()) + .filter(Boolean) + : []; + + const textPatterns = includePatternsText + ? includePatternsText + .split("\n") + .map((p) => p.trim()) + .filter(Boolean) + : []; + const includePatterns = [...(fileBrowserPatterns || []), ...textPatterns]; onSubmit({ ...rest, + includePatterns: includePatterns.length > 0 ? includePatterns : [], excludePatterns, + excludeIfPresent, }); }, [onSubmit], @@ -148,6 +185,21 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: + ( + + Backup name + + + + A unique name to identify this backup schedule. + + + )} + /> + )} + ( + + Additional include patterns + +