Compare commits

...

35 Commits

Author SHA1 Message Date
Nicolas Meienberger
e85cc35b1a feat: add DOCKER_HOST support 2025-12-04 19:09:23 +01:00
Nicolas Meienberger
321dc4cdf7 fix: volume data not refreshing when changing selection 2025-12-04 18:54:02 +01:00
Nicolas Meienberger
0f7bd1e042 fix: remove debug logs in production 2025-12-04 18:46:12 +01:00
Nico
08d8a47352 feat: custom include patterns (#104)
* feat: add custom include patterns

* feat: add exclude-if-present option
2025-12-04 18:44:34 +01:00
Nico
1e20fb225e feat: naming backup schedules (#103)
* docs: add agents instructions

* feat: naming backup schedules

* fix: wrong table for filtering
2025-12-04 18:31:00 +01:00
Nicolas Meienberger
b8e30e298c fix: broken migration 2025-12-03 21:56:41 +01:00
Nicolas Meienberger
ce07c588ad chore: update dependencies 2025-12-03 20:55:38 +01:00
Nico
e7f0a2828d feat: mirror repositories (#95)
* feat: mirror repositories

feat: mirror backup repositories

* chore: pr feedbacks
2025-12-03 20:52:47 +01:00
Nicolas Meienberger
7ff38f0128 refactor: run forget commands in the background 2025-12-01 20:22:43 +01:00
Nicolas Meienberger
33e6f3773b fix: only lock shared tasks if an exclusive is running 2025-12-01 20:07:30 +01:00
Nicolas Meienberger
a91dede086 docs: bump version in readme 2025-12-01 19:48:55 +01:00
Nico
9b46737852 refactor(repositories): add a locking mechanism for restic operations (#94)
* refactor(repositories): add a locking mechanism for restic operations

* fix: add missing lock in list repositories
2025-12-01 19:47:21 +01:00
Nicolas Meienberger
999850dab8 Merge branch 'tvarohohlavy-telegram-notification' 2025-11-30 17:06:07 +01:00
Nicolas Meienberger
dbd9ae2241 chore: generate types 2025-11-30 17:05:40 +01:00
Nico
0287bca4bb restore as a page (#87)
* feat: add custom restore target directory

Adds the ability to restore snapshots to a custom directory instead of
only the original path. Closes #12.

Changes:
- Add target parameter to restore API endpoint
- Add directory picker UI in file browser restore dialog
- Add target input field in snapshot restore form
- Create reusable PathSelector component

Note: Run `bun run gen:api-client` after merging to regenerate types.

* refactor: path selector design

* refactor: unify restore snapshot dialogs

* refactor: restore snapshot as a page

* chore: fix liniting issues

* chore(create-notification): remove un-used prop

---------

Co-authored-by: Deepseek1 <Deepseek1@users.noreply.github.com>
2025-11-30 16:47:14 +01:00
Nico
9a9991eb9b restore as a page (#87)
* feat: add custom restore target directory

Adds the ability to restore snapshots to a custom directory instead of
only the original path. Closes #12.

Changes:
- Add target parameter to restore API endpoint
- Add directory picker UI in file browser restore dialog
- Add target input field in snapshot restore form
- Create reusable PathSelector component

Note: Run `bun run gen:api-client` after merging to regenerate types.

* refactor: path selector design

* refactor: unify restore snapshot dialogs

* refactor: restore snapshot as a page

* chore: fix liniting issues

* chore(create-notification): remove un-used prop

---------

Co-authored-by: Deepseek1 <Deepseek1@users.noreply.github.com>
2025-11-30 16:43:34 +01:00
Jakub Trávník
03b898f84c Update app/client/modules/notifications/components/create-notification-form.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-30 15:38:45 +01:00
Jakub Trávník
6fbb11fefe telegram notification 2025-11-30 15:15:26 +01:00
Nico
3bf3b22b96 feat: restore to custom location (#78)
* feat: restore to custom location

* refactor: define overwrite mode in shared schema
2025-11-29 16:53:44 +01:00
Nicolas Meienberger
58708cf35d refactor: repo healthcheck once per day 2025-11-29 12:25:46 +01:00
Nicolas Meienberger
1d4e7100ab fix: healtcheck, to not read full data 2025-11-29 12:24:07 +01:00
Nicolas Meienberger
0dfe000148 feat: rename volumes & repositories 2025-11-28 20:47:27 +01:00
Nicolas Meienberger
7d9d3d5d3d fix: wrong compression modes used 2025-11-28 20:28:47 +01:00
Nicolas Meienberger
8e90c4ace1 refactor: native repository healthcheck 2025-11-28 08:20:06 +01:00
Nicolas Meienberger
803eb1cd76 chore: update favicon 2025-11-28 08:18:34 +01:00
Nico
673827f9f3 refactor: all timestamps to ms (#77)
* refactor: change all timestamps to be in miliseconds

* chore: format files

* chore: fix syntax error
2025-11-26 23:20:22 +01:00
Nicolas Meienberger
4328607cc1 fix: skip renaming imported repository 2025-11-26 22:20:42 +01:00
Nicolas Meienberger
bedd325a60 fix(db): set pragma after migrations 2025-11-26 20:12:12 +01:00
Nico
b26a062648 refactor: use short ids to allow changing the name of volumes & repos (#67)
* refactor: use short ids to allow changing the name of volumes & repos

* refactor: address PR feedbacks

* fix: make short_id non null after initial population
2025-11-26 19:47:09 +01:00
Nico
d190d9c8cd feat: partial success warning status (#74)
* feat: report partial backups with warnings

* chore: rebase

* chore: remove un-used size prop
2025-11-26 19:02:29 +01:00
Nicolas Meienberger
f8363a6c71 docs: update readme with TZ environment 2025-11-25 18:25:22 +01:00
Nico
59b2b53837 docs: update README 2025-11-23 21:21:52 +01:00
Nicolas Meienberger
e99487eed9 fix(notifications): multiple providers using the wrong params 2025-11-23 21:09:23 +01:00
Nicolas Meienberger
8d4e5d2d4e fix(ntfy): wrong usage of token 2025-11-23 20:49:44 +01:00
Nicolas Meienberger
daea3e64e4 fix(smtp-notification): always use smtp:// 2025-11-23 20:37:21 +01:00
112 changed files with 11506 additions and 2130 deletions

1
.github/copilot-instructions.md vendored Normal file
View File

@@ -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.

267
AGENTS.md Normal file
View File

@@ -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 <20> service <20> 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/<volume-name>`
- Local repositories: `/var/lib/zerobyte/repositories/<repo-name>`
**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/<backend>/`
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/<job-name>.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

View File

@@ -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

View File

@@ -18,6 +18,10 @@
> [!WARNING]
> Zerobyte is still in version 0.x.x and is subject to major changes from version to version. I am developing the core features and collecting feedbacks. Expect bugs! Please open issues or feature requests
<p align="center">
<a href="https://www.buymeacoffee.com/nicotsx" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
</p>
## Intro
Zerobyte is a backup automation tool that helps you save your data across multiple storage backends. Built on top of Restic, it provides an modern web interface to schedule, manage, and monitor encrypted backups of your remote storage.
@@ -36,7 +40,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o
```yaml
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.13
image: ghcr.io/nicotsx/zerobyte:v0.15
container_name: zerobyte
restart: unless-stopped
cap_add:
@@ -45,6 +49,8 @@ services:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris # Set your timezone here
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
@@ -72,7 +78,7 @@ If you want to track a local directory on the same server where Zerobyte is runn
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.13
image: ghcr.io/nicotsx/zerobyte:v0.15
container_name: zerobyte
restart: unless-stopped
cap_add:
@@ -81,6 +87,8 @@ services:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
@@ -138,7 +146,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.13
image: ghcr.io/nicotsx/zerobyte:v0.15
container_name: zerobyte
restart: unless-stopped
cap_add:
@@ -147,6 +155,8 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
@@ -195,13 +205,15 @@ In order to enable this feature, you need to change your bind mount `/var/lib/ze
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.13
image: ghcr.io/nicotsx/zerobyte:v0.15
container_name: zerobyte
restart: unless-stopped
ports:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris
volumes:
- /etc/localtime:/etc/localtime:ro
- - /var/lib/zerobyte:/var/lib/zerobyte
@@ -224,7 +236,7 @@ In order to enable this feature, you need to run Zerobyte with several items sha
```diff
services:
zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.13
image: ghcr.io/nicotsx/zerobyte:v0.15
container_name: zerobyte
restart: unless-stopped
cap_add:
@@ -233,6 +245,8 @@ services:
- "4096:4096"
devices:
- /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris
volumes:
- /etc/localtime:/etc/localtime:ro
- - /var/lib/zerobyte:/var/lib/zerobyte
@@ -251,7 +265,7 @@ docker compose up -d
Your Zerobyte volumes will now be available as Docker volumes that you can mount into other containers using the `--volume` flag:
```bash
docker run -v im-nfs:/path/in/container nginx:latest
docker run -v zb-abc12:/path/in/container nginx:latest
```
Or using Docker Compose:
@@ -261,13 +275,13 @@ services:
myservice:
image: nginx:latest
volumes:
- im-nfs:/path/in/container
- zb-abc12:/path/in/container
volumes:
im-nfs:
zb-abc12:
external: true
```
The volume name format is `im-<volume-name>` where `<volume-name>` is the name you assigned to the volume in Zerobyte. You can verify that the volume is available by running:
The volume name format is `zb-<short-id>` where `<short-id>` is the unique identifier shown on the volume's Docker tab in Zerobyte. This short ID remains stable even if you rename the volume. You can verify that the volume is available by running:
```bash
docker volume ls

View File

@@ -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, 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, 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 = <TOptions extends Options>(id: string, options?: TOptions
if (options?.query) {
params.query = options.query;
}
return [
params
];
return [params];
};
export const getMeQueryKey = (options?: Options<GetMeData>) => createQueryKey("getMe", options);
export const getMeQueryKey = (options?: Options<GetMeData>) => createQueryKey('getMe', options);
/**
* Get current authenticated user
@@ -110,7 +108,7 @@ export const getMeOptions = (options?: Options<GetMeData>) => queryOptions<GetMe
queryKey: getMeQueryKey(options)
});
export const getStatusQueryKey = (options?: Options<GetStatusData>) => createQueryKey("getStatus", options);
export const getStatusQueryKey = (options?: Options<GetStatusData>) => createQueryKey('getStatus', options);
/**
* Get authentication system status
@@ -145,7 +143,7 @@ export const changePasswordMutation = (options?: Partial<Options<ChangePasswordD
return mutationOptions;
};
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey("listVolumes", options);
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey('listVolumes', options);
/**
* List all volumes
@@ -214,7 +212,7 @@ export const deleteVolumeMutation = (options?: Partial<Options<DeleteVolumeData>
return mutationOptions;
};
export const getVolumeQueryKey = (options: Options<GetVolumeData>) => createQueryKey("getVolume", options);
export const getVolumeQueryKey = (options: Options<GetVolumeData>) => createQueryKey('getVolume', options);
/**
* Get a volume by name
@@ -249,7 +247,7 @@ export const updateVolumeMutation = (options?: Partial<Options<UpdateVolumeData>
return mutationOptions;
};
export const getContainersUsingVolumeQueryKey = (options: Options<GetContainersUsingVolumeData>) => createQueryKey("getContainersUsingVolume", options);
export const getContainersUsingVolumeQueryKey = (options: Options<GetContainersUsingVolumeData>) => createQueryKey('getContainersUsingVolume', options);
/**
* Get containers using a volume by name
@@ -318,7 +316,7 @@ export const healthCheckVolumeMutation = (options?: Partial<Options<HealthCheckV
return mutationOptions;
};
export const listFilesQueryKey = (options: Options<ListFilesData>) => createQueryKey("listFiles", options);
export const listFilesQueryKey = (options: Options<ListFilesData>) => createQueryKey('listFiles', options);
/**
* List files in a volume directory
@@ -336,7 +334,7 @@ export const listFilesOptions = (options: Options<ListFilesData>) => queryOption
queryKey: listFilesQueryKey(options)
});
export const browseFilesystemQueryKey = (options?: Options<BrowseFilesystemData>) => createQueryKey("browseFilesystem", options);
export const browseFilesystemQueryKey = (options?: Options<BrowseFilesystemData>) => createQueryKey('browseFilesystem', options);
/**
* Browse directories on the host filesystem
@@ -354,7 +352,7 @@ export const browseFilesystemOptions = (options?: Options<BrowseFilesystemData>)
queryKey: browseFilesystemQueryKey(options)
});
export const listRepositoriesQueryKey = (options?: Options<ListRepositoriesData>) => createQueryKey("listRepositories", options);
export const listRepositoriesQueryKey = (options?: Options<ListRepositoriesData>) => createQueryKey('listRepositories', options);
/**
* List all repositories
@@ -389,7 +387,7 @@ export const createRepositoryMutation = (options?: Partial<Options<CreateReposit
return mutationOptions;
};
export const listRcloneRemotesQueryKey = (options?: Options<ListRcloneRemotesData>) => createQueryKey("listRcloneRemotes", options);
export const listRcloneRemotesQueryKey = (options?: Options<ListRcloneRemotesData>) => createQueryKey('listRcloneRemotes', options);
/**
* List all configured rclone remotes on the host system
@@ -424,7 +422,7 @@ export const deleteRepositoryMutation = (options?: Partial<Options<DeleteReposit
return mutationOptions;
};
export const getRepositoryQueryKey = (options: Options<GetRepositoryData>) => createQueryKey("getRepository", options);
export const getRepositoryQueryKey = (options: Options<GetRepositoryData>) => createQueryKey('getRepository', options);
/**
* Get a single repository by name
@@ -442,7 +440,24 @@ export const getRepositoryOptions = (options: Options<GetRepositoryData>) => que
queryKey: getRepositoryQueryKey(options)
});
export const listSnapshotsQueryKey = (options: Options<ListSnapshotsData>) => createQueryKey("listSnapshots", options);
/**
* Update a repository's name or settings
*/
export const updateRepositoryMutation = (options?: Partial<Options<UpdateRepositoryData>>): UseMutationOptions<UpdateRepositoryResponse, DefaultError, Options<UpdateRepositoryData>> => {
const mutationOptions: UseMutationOptions<UpdateRepositoryResponse, DefaultError, Options<UpdateRepositoryData>> = {
mutationFn: async (fnOptions) => {
const { data } = await updateRepository({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const listSnapshotsQueryKey = (options: Options<ListSnapshotsData>) => createQueryKey('listSnapshots', options);
/**
* List all snapshots in a repository
@@ -477,7 +492,7 @@ export const deleteSnapshotMutation = (options?: Partial<Options<DeleteSnapshotD
return mutationOptions;
};
export const getSnapshotDetailsQueryKey = (options: Options<GetSnapshotDetailsData>) => createQueryKey("getSnapshotDetails", options);
export const getSnapshotDetailsQueryKey = (options: Options<GetSnapshotDetailsData>) => createQueryKey('getSnapshotDetails', options);
/**
* Get details of a specific snapshot
@@ -495,7 +510,7 @@ export const getSnapshotDetailsOptions = (options: Options<GetSnapshotDetailsDat
queryKey: getSnapshotDetailsQueryKey(options)
});
export const listSnapshotFilesQueryKey = (options: Options<ListSnapshotFilesData>) => createQueryKey("listSnapshotFiles", options);
export const listSnapshotFilesQueryKey = (options: Options<ListSnapshotFilesData>) => createQueryKey('listSnapshotFiles', options);
/**
* List files and directories in a snapshot
@@ -547,7 +562,7 @@ export const doctorRepositoryMutation = (options?: Partial<Options<DoctorReposit
return mutationOptions;
};
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) => createQueryKey("listBackupSchedules", options);
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) => createQueryKey('listBackupSchedules', options);
/**
* List all backup schedules
@@ -599,7 +614,7 @@ export const deleteBackupScheduleMutation = (options?: Partial<Options<DeleteBac
return mutationOptions;
};
export const getBackupScheduleQueryKey = (options: Options<GetBackupScheduleData>) => createQueryKey("getBackupSchedule", options);
export const getBackupScheduleQueryKey = (options: Options<GetBackupScheduleData>) => createQueryKey('getBackupSchedule', options);
/**
* Get a backup schedule by ID
@@ -634,7 +649,7 @@ export const updateBackupScheduleMutation = (options?: Partial<Options<UpdateBac
return mutationOptions;
};
export const getBackupScheduleForVolumeQueryKey = (options: Options<GetBackupScheduleForVolumeData>) => createQueryKey("getBackupScheduleForVolume", options);
export const getBackupScheduleForVolumeQueryKey = (options: Options<GetBackupScheduleForVolumeData>) => createQueryKey('getBackupScheduleForVolume', options);
/**
* Get a backup schedule for a specific volume
@@ -703,7 +718,7 @@ export const runForgetMutation = (options?: Partial<Options<RunForgetData>>): Us
return mutationOptions;
};
export const getScheduleNotificationsQueryKey = (options: Options<GetScheduleNotificationsData>) => createQueryKey("getScheduleNotifications", options);
export const getScheduleNotificationsQueryKey = (options: Options<GetScheduleNotificationsData>) => createQueryKey('getScheduleNotifications', options);
/**
* Get notification assignments for a backup schedule
@@ -738,7 +753,60 @@ export const updateScheduleNotificationsMutation = (options?: Partial<Options<Up
return mutationOptions;
};
export const listNotificationDestinationsQueryKey = (options?: Options<ListNotificationDestinationsData>) => createQueryKey("listNotificationDestinations", options);
export const getScheduleMirrorsQueryKey = (options: Options<GetScheduleMirrorsData>) => createQueryKey('getScheduleMirrors', options);
/**
* Get mirror repository assignments for a backup schedule
*/
export const getScheduleMirrorsOptions = (options: Options<GetScheduleMirrorsData>) => queryOptions<GetScheduleMirrorsResponse, DefaultError, GetScheduleMirrorsResponse, ReturnType<typeof getScheduleMirrorsQueryKey>>({
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<Options<UpdateScheduleMirrorsData>>): UseMutationOptions<UpdateScheduleMirrorsResponse, DefaultError, Options<UpdateScheduleMirrorsData>> => {
const mutationOptions: UseMutationOptions<UpdateScheduleMirrorsResponse, DefaultError, Options<UpdateScheduleMirrorsData>> = {
mutationFn: async (fnOptions) => {
const { data } = await updateScheduleMirrors({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getMirrorCompatibilityQueryKey = (options: Options<GetMirrorCompatibilityData>) => createQueryKey('getMirrorCompatibility', options);
/**
* Get mirror compatibility info for all repositories relative to a backup schedule's primary repository
*/
export const getMirrorCompatibilityOptions = (options: Options<GetMirrorCompatibilityData>) => queryOptions<GetMirrorCompatibilityResponse, DefaultError, GetMirrorCompatibilityResponse, ReturnType<typeof getMirrorCompatibilityQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getMirrorCompatibility({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getMirrorCompatibilityQueryKey(options)
});
export const listNotificationDestinationsQueryKey = (options?: Options<ListNotificationDestinationsData>) => createQueryKey('listNotificationDestinations', options);
/**
* List all notification destinations
@@ -790,7 +858,7 @@ export const deleteNotificationDestinationMutation = (options?: Partial<Options<
return mutationOptions;
};
export const getNotificationDestinationQueryKey = (options: Options<GetNotificationDestinationData>) => createQueryKey("getNotificationDestination", options);
export const getNotificationDestinationQueryKey = (options: Options<GetNotificationDestinationData>) => createQueryKey('getNotificationDestination', options);
/**
* Get a notification destination by ID
@@ -842,7 +910,7 @@ export const testNotificationDestinationMutation = (options?: Partial<Options<Te
return mutationOptions;
};
export const getSystemInfoQueryKey = (options?: Options<GetSystemInfoData>) => createQueryKey("getSystemInfo", options);
export const getSystemInfoQueryKey = (options?: Options<GetSystemInfoData>) => createQueryKey('getSystemInfo', options);
/**
* Get system information including available capabilities

View File

@@ -13,6 +13,4 @@ import type { ClientOptions as ClientOptions2 } from './types.gen';
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
export const client = createClient(createConfig<ClientOptions2>({
baseUrl: 'http://192.168.2.42:4096'
}));
export const client = createClient(createConfig<ClientOptions2>({ baseUrl: 'http://192.168.2.42:4096' }));

View File

@@ -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, 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<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
@@ -21,535 +21,371 @@ export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends
/**
* Register a new user
*/
export const register = <ThrowOnError extends boolean = false>(options?: Options<RegisterData, ThrowOnError>) => {
return (options?.client ?? client).post<RegisterResponses, unknown, ThrowOnError>({
url: '/api/v1/auth/register',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
export const register = <ThrowOnError extends boolean = false>(options?: Options<RegisterData, ThrowOnError>) => (options?.client ?? client).post<RegisterResponses, unknown, ThrowOnError>({
url: '/api/v1/auth/register',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
/**
* Login with username and password
*/
export const login = <ThrowOnError extends boolean = false>(options?: Options<LoginData, ThrowOnError>) => {
return (options?.client ?? client).post<LoginResponses, unknown, ThrowOnError>({
url: '/api/v1/auth/login',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
export const login = <ThrowOnError extends boolean = false>(options?: Options<LoginData, ThrowOnError>) => (options?.client ?? client).post<LoginResponses, unknown, ThrowOnError>({
url: '/api/v1/auth/login',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
/**
* Logout current user
*/
export const logout = <ThrowOnError extends boolean = false>(options?: Options<LogoutData, ThrowOnError>) => {
return (options?.client ?? client).post<LogoutResponses, unknown, ThrowOnError>({
url: '/api/v1/auth/logout',
...options
});
};
export const logout = <ThrowOnError extends boolean = false>(options?: Options<LogoutData, ThrowOnError>) => (options?.client ?? client).post<LogoutResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/logout', ...options });
/**
* Get current authenticated user
*/
export const getMe = <ThrowOnError extends boolean = false>(options?: Options<GetMeData, ThrowOnError>) => {
return (options?.client ?? client).get<GetMeResponses, unknown, ThrowOnError>({
url: '/api/v1/auth/me',
...options
});
};
export const getMe = <ThrowOnError extends boolean = false>(options?: Options<GetMeData, ThrowOnError>) => (options?.client ?? client).get<GetMeResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/me', ...options });
/**
* Get authentication system status
*/
export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) => {
return (options?.client ?? client).get<GetStatusResponses, unknown, ThrowOnError>({
url: '/api/v1/auth/status',
...options
});
};
export const getStatus = <ThrowOnError extends boolean = false>(options?: Options<GetStatusData, ThrowOnError>) => (options?.client ?? client).get<GetStatusResponses, unknown, ThrowOnError>({ url: '/api/v1/auth/status', ...options });
/**
* Change current user password
*/
export const changePassword = <ThrowOnError extends boolean = false>(options?: Options<ChangePasswordData, ThrowOnError>) => {
return (options?.client ?? client).post<ChangePasswordResponses, unknown, ThrowOnError>({
url: '/api/v1/auth/change-password',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
export const changePassword = <ThrowOnError extends boolean = false>(options?: Options<ChangePasswordData, ThrowOnError>) => (options?.client ?? client).post<ChangePasswordResponses, unknown, ThrowOnError>({
url: '/api/v1/auth/change-password',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
/**
* List all volumes
*/
export const listVolumes = <ThrowOnError extends boolean = false>(options?: Options<ListVolumesData, ThrowOnError>) => {
return (options?.client ?? client).get<ListVolumesResponses, unknown, ThrowOnError>({
url: '/api/v1/volumes',
...options
});
};
export const listVolumes = <ThrowOnError extends boolean = false>(options?: Options<ListVolumesData, ThrowOnError>) => (options?.client ?? client).get<ListVolumesResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes', ...options });
/**
* Create a new volume
*/
export const createVolume = <ThrowOnError extends boolean = false>(options?: Options<CreateVolumeData, ThrowOnError>) => {
return (options?.client ?? client).post<CreateVolumeResponses, unknown, ThrowOnError>({
url: '/api/v1/volumes',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
export const createVolume = <ThrowOnError extends boolean = false>(options?: Options<CreateVolumeData, ThrowOnError>) => (options?.client ?? client).post<CreateVolumeResponses, unknown, ThrowOnError>({
url: '/api/v1/volumes',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
/**
* Test connection to backend
*/
export const testConnection = <ThrowOnError extends boolean = false>(options?: Options<TestConnectionData, ThrowOnError>) => {
return (options?.client ?? client).post<TestConnectionResponses, unknown, ThrowOnError>({
url: '/api/v1/volumes/test-connection',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
export const testConnection = <ThrowOnError extends boolean = false>(options?: Options<TestConnectionData, ThrowOnError>) => (options?.client ?? client).post<TestConnectionResponses, unknown, ThrowOnError>({
url: '/api/v1/volumes/test-connection',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
/**
* Delete a volume
*/
export const deleteVolume = <ThrowOnError extends boolean = false>(options: Options<DeleteVolumeData, ThrowOnError>) => {
return (options.client ?? client).delete<DeleteVolumeResponses, unknown, ThrowOnError>({
url: '/api/v1/volumes/{name}',
...options
});
};
export const deleteVolume = <ThrowOnError extends boolean = false>(options: Options<DeleteVolumeData, ThrowOnError>) => (options.client ?? client).delete<DeleteVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{name}', ...options });
/**
* Get a volume by name
*/
export const getVolume = <ThrowOnError extends boolean = false>(options: Options<GetVolumeData, ThrowOnError>) => {
return (options.client ?? client).get<GetVolumeResponses, GetVolumeErrors, ThrowOnError>({
url: '/api/v1/volumes/{name}',
...options
});
};
export const getVolume = <ThrowOnError extends boolean = false>(options: Options<GetVolumeData, ThrowOnError>) => (options.client ?? client).get<GetVolumeResponses, GetVolumeErrors, ThrowOnError>({ url: '/api/v1/volumes/{name}', ...options });
/**
* Update a volume's configuration
*/
export const updateVolume = <ThrowOnError extends boolean = false>(options: Options<UpdateVolumeData, ThrowOnError>) => {
return (options.client ?? client).put<UpdateVolumeResponses, UpdateVolumeErrors, ThrowOnError>({
url: '/api/v1/volumes/{name}',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
export const updateVolume = <ThrowOnError extends boolean = false>(options: Options<UpdateVolumeData, ThrowOnError>) => (options.client ?? client).put<UpdateVolumeResponses, UpdateVolumeErrors, ThrowOnError>({
url: '/api/v1/volumes/{name}',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Get containers using a volume by name
*/
export const getContainersUsingVolume = <ThrowOnError extends boolean = false>(options: Options<GetContainersUsingVolumeData, ThrowOnError>) => {
return (options.client ?? client).get<GetContainersUsingVolumeResponses, GetContainersUsingVolumeErrors, ThrowOnError>({
url: '/api/v1/volumes/{name}/containers',
...options
});
};
export const getContainersUsingVolume = <ThrowOnError extends boolean = false>(options: Options<GetContainersUsingVolumeData, ThrowOnError>) => (options.client ?? client).get<GetContainersUsingVolumeResponses, GetContainersUsingVolumeErrors, ThrowOnError>({ url: '/api/v1/volumes/{name}/containers', ...options });
/**
* Mount a volume
*/
export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>) => {
return (options.client ?? client).post<MountVolumeResponses, unknown, ThrowOnError>({
url: '/api/v1/volumes/{name}/mount',
...options
});
};
export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>) => (options.client ?? client).post<MountVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{name}/mount', ...options });
/**
* Unmount a volume
*/
export const unmountVolume = <ThrowOnError extends boolean = false>(options: Options<UnmountVolumeData, ThrowOnError>) => {
return (options.client ?? client).post<UnmountVolumeResponses, unknown, ThrowOnError>({
url: '/api/v1/volumes/{name}/unmount',
...options
});
};
export const unmountVolume = <ThrowOnError extends boolean = false>(options: Options<UnmountVolumeData, ThrowOnError>) => (options.client ?? client).post<UnmountVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{name}/unmount', ...options });
/**
* Perform a health check on a volume
*/
export const healthCheckVolume = <ThrowOnError extends boolean = false>(options: Options<HealthCheckVolumeData, ThrowOnError>) => {
return (options.client ?? client).post<HealthCheckVolumeResponses, HealthCheckVolumeErrors, ThrowOnError>({
url: '/api/v1/volumes/{name}/health-check',
...options
});
};
export const healthCheckVolume = <ThrowOnError extends boolean = false>(options: Options<HealthCheckVolumeData, ThrowOnError>) => (options.client ?? client).post<HealthCheckVolumeResponses, HealthCheckVolumeErrors, ThrowOnError>({ url: '/api/v1/volumes/{name}/health-check', ...options });
/**
* List files in a volume directory
*/
export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) => {
return (options.client ?? client).get<ListFilesResponses, unknown, ThrowOnError>({
url: '/api/v1/volumes/{name}/files',
...options
});
};
export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) => (options.client ?? client).get<ListFilesResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/{name}/files', ...options });
/**
* Browse directories on the host filesystem
*/
export const browseFilesystem = <ThrowOnError extends boolean = false>(options?: Options<BrowseFilesystemData, ThrowOnError>) => {
return (options?.client ?? client).get<BrowseFilesystemResponses, unknown, ThrowOnError>({
url: '/api/v1/volumes/filesystem/browse',
...options
});
};
export const browseFilesystem = <ThrowOnError extends boolean = false>(options?: Options<BrowseFilesystemData, ThrowOnError>) => (options?.client ?? client).get<BrowseFilesystemResponses, unknown, ThrowOnError>({ url: '/api/v1/volumes/filesystem/browse', ...options });
/**
* List all repositories
*/
export const listRepositories = <ThrowOnError extends boolean = false>(options?: Options<ListRepositoriesData, ThrowOnError>) => {
return (options?.client ?? client).get<ListRepositoriesResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories',
...options
});
};
export const listRepositories = <ThrowOnError extends boolean = false>(options?: Options<ListRepositoriesData, ThrowOnError>) => (options?.client ?? client).get<ListRepositoriesResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories', ...options });
/**
* Create a new restic repository
*/
export const createRepository = <ThrowOnError extends boolean = false>(options?: Options<CreateRepositoryData, ThrowOnError>) => {
return (options?.client ?? client).post<CreateRepositoryResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
export const createRepository = <ThrowOnError extends boolean = false>(options?: Options<CreateRepositoryData, ThrowOnError>) => (options?.client ?? client).post<CreateRepositoryResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
/**
* List all configured rclone remotes on the host system
*/
export const listRcloneRemotes = <ThrowOnError extends boolean = false>(options?: Options<ListRcloneRemotesData, ThrowOnError>) => {
return (options?.client ?? client).get<ListRcloneRemotesResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories/rclone-remotes',
...options
});
};
export const listRcloneRemotes = <ThrowOnError extends boolean = false>(options?: Options<ListRcloneRemotesData, ThrowOnError>) => (options?.client ?? client).get<ListRcloneRemotesResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/rclone-remotes', ...options });
/**
* Delete a repository
*/
export const deleteRepository = <ThrowOnError extends boolean = false>(options: Options<DeleteRepositoryData, ThrowOnError>) => {
return (options.client ?? client).delete<DeleteRepositoryResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories/{name}',
...options
});
};
export const deleteRepository = <ThrowOnError extends boolean = false>(options: Options<DeleteRepositoryData, ThrowOnError>) => (options.client ?? client).delete<DeleteRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}', ...options });
/**
* Get a single repository by name
*/
export const getRepository = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryData, ThrowOnError>) => {
return (options.client ?? client).get<GetRepositoryResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories/{name}',
...options
});
};
export const getRepository = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryData, ThrowOnError>) => (options.client ?? client).get<GetRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}', ...options });
/**
* Update a repository's name or settings
*/
export const updateRepository = <ThrowOnError extends boolean = false>(options: Options<UpdateRepositoryData, ThrowOnError>) => (options.client ?? client).patch<UpdateRepositoryResponses, UpdateRepositoryErrors, ThrowOnError>({
url: '/api/v1/repositories/{name}',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* List all snapshots in a repository
*/
export const listSnapshots = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotsData, ThrowOnError>) => {
return (options.client ?? client).get<ListSnapshotsResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories/{name}/snapshots',
...options
});
};
export const listSnapshots = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotsData, ThrowOnError>) => (options.client ?? client).get<ListSnapshotsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots', ...options });
/**
* Delete a specific snapshot from a repository
*/
export const deleteSnapshot = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotData, ThrowOnError>) => {
return (options.client ?? client).delete<DeleteSnapshotResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}',
...options
});
};
export const deleteSnapshot = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotData, ThrowOnError>) => (options.client ?? client).delete<DeleteSnapshotResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}', ...options });
/**
* Get details of a specific snapshot
*/
export const getSnapshotDetails = <ThrowOnError extends boolean = false>(options: Options<GetSnapshotDetailsData, ThrowOnError>) => {
return (options.client ?? client).get<GetSnapshotDetailsResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}',
...options
});
};
export const getSnapshotDetails = <ThrowOnError extends boolean = false>(options: Options<GetSnapshotDetailsData, ThrowOnError>) => (options.client ?? client).get<GetSnapshotDetailsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}', ...options });
/**
* List files and directories in a snapshot
*/
export const listSnapshotFiles = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotFilesData, ThrowOnError>) => {
return (options.client ?? client).get<ListSnapshotFilesResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}/files',
...options
});
};
export const listSnapshotFiles = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotFilesData, ThrowOnError>) => (options.client ?? client).get<ListSnapshotFilesResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}/files', ...options });
/**
* Restore a snapshot to a target path on the filesystem
*/
export const restoreSnapshot = <ThrowOnError extends boolean = false>(options: Options<RestoreSnapshotData, ThrowOnError>) => {
return (options.client ?? client).post<RestoreSnapshotResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories/{name}/restore',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
export const restoreSnapshot = <ThrowOnError extends boolean = false>(options: Options<RestoreSnapshotData, ThrowOnError>) => (options.client ?? client).post<RestoreSnapshotResponses, unknown, ThrowOnError>({
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 = <ThrowOnError extends boolean = false>(options: Options<DoctorRepositoryData, ThrowOnError>) => {
return (options.client ?? client).post<DoctorRepositoryResponses, unknown, ThrowOnError>({
url: '/api/v1/repositories/{name}/doctor',
...options
});
};
export const doctorRepository = <ThrowOnError extends boolean = false>(options: Options<DoctorRepositoryData, ThrowOnError>) => (options.client ?? client).post<DoctorRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/doctor', ...options });
/**
* List all backup schedules
*/
export const listBackupSchedules = <ThrowOnError extends boolean = false>(options?: Options<ListBackupSchedulesData, ThrowOnError>) => {
return (options?.client ?? client).get<ListBackupSchedulesResponses, unknown, ThrowOnError>({
url: '/api/v1/backups',
...options
});
};
export const listBackupSchedules = <ThrowOnError extends boolean = false>(options?: Options<ListBackupSchedulesData, ThrowOnError>) => (options?.client ?? client).get<ListBackupSchedulesResponses, unknown, ThrowOnError>({ url: '/api/v1/backups', ...options });
/**
* Create a new backup schedule for a volume
*/
export const createBackupSchedule = <ThrowOnError extends boolean = false>(options?: Options<CreateBackupScheduleData, ThrowOnError>) => {
return (options?.client ?? client).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({
url: '/api/v1/backups',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
export const createBackupSchedule = <ThrowOnError extends boolean = false>(options?: Options<CreateBackupScheduleData, ThrowOnError>) => (options?.client ?? client).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({
url: '/api/v1/backups',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
/**
* Delete a backup schedule
*/
export const deleteBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<DeleteBackupScheduleData, ThrowOnError>) => {
return (options.client ?? client).delete<DeleteBackupScheduleResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/{scheduleId}',
...options
});
};
export const deleteBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<DeleteBackupScheduleData, ThrowOnError>) => (options.client ?? client).delete<DeleteBackupScheduleResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}', ...options });
/**
* Get a backup schedule by ID
*/
export const getBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<GetBackupScheduleData, ThrowOnError>) => {
return (options.client ?? client).get<GetBackupScheduleResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/{scheduleId}',
...options
});
};
export const getBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<GetBackupScheduleData, ThrowOnError>) => (options.client ?? client).get<GetBackupScheduleResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}', ...options });
/**
* Update a backup schedule
*/
export const updateBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<UpdateBackupScheduleData, ThrowOnError>) => {
return (options.client ?? client).patch<UpdateBackupScheduleResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/{scheduleId}',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
export const updateBackupSchedule = <ThrowOnError extends boolean = false>(options: Options<UpdateBackupScheduleData, ThrowOnError>) => (options.client ?? client).patch<UpdateBackupScheduleResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/{scheduleId}',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Get a backup schedule for a specific volume
*/
export const getBackupScheduleForVolume = <ThrowOnError extends boolean = false>(options: Options<GetBackupScheduleForVolumeData, ThrowOnError>) => {
return (options.client ?? client).get<GetBackupScheduleForVolumeResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/volume/{volumeId}',
...options
});
};
export const getBackupScheduleForVolume = <ThrowOnError extends boolean = false>(options: Options<GetBackupScheduleForVolumeData, ThrowOnError>) => (options.client ?? client).get<GetBackupScheduleForVolumeResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/volume/{volumeId}', ...options });
/**
* Trigger a backup immediately for a schedule
*/
export const runBackupNow = <ThrowOnError extends boolean = false>(options: Options<RunBackupNowData, ThrowOnError>) => {
return (options.client ?? client).post<RunBackupNowResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/{scheduleId}/run',
...options
});
};
export const runBackupNow = <ThrowOnError extends boolean = false>(options: Options<RunBackupNowData, ThrowOnError>) => (options.client ?? client).post<RunBackupNowResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/run', ...options });
/**
* Stop a backup that is currently in progress
*/
export const stopBackup = <ThrowOnError extends boolean = false>(options: Options<StopBackupData, ThrowOnError>) => {
return (options.client ?? client).post<StopBackupResponses, StopBackupErrors, ThrowOnError>({
url: '/api/v1/backups/{scheduleId}/stop',
...options
});
};
export const stopBackup = <ThrowOnError extends boolean = false>(options: Options<StopBackupData, ThrowOnError>) => (options.client ?? client).post<StopBackupResponses, StopBackupErrors, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/stop', ...options });
/**
* Manually apply retention policy to clean up old snapshots
*/
export const runForget = <ThrowOnError extends boolean = false>(options: Options<RunForgetData, ThrowOnError>) => {
return (options.client ?? client).post<RunForgetResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/{scheduleId}/forget',
...options
});
};
export const runForget = <ThrowOnError extends boolean = false>(options: Options<RunForgetData, ThrowOnError>) => (options.client ?? client).post<RunForgetResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/forget', ...options });
/**
* Get notification assignments for a backup schedule
*/
export const getScheduleNotifications = <ThrowOnError extends boolean = false>(options: Options<GetScheduleNotificationsData, ThrowOnError>) => {
return (options.client ?? client).get<GetScheduleNotificationsResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/{scheduleId}/notifications',
...options
});
};
export const getScheduleNotifications = <ThrowOnError extends boolean = false>(options: Options<GetScheduleNotificationsData, ThrowOnError>) => (options.client ?? client).get<GetScheduleNotificationsResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/notifications', ...options });
/**
* Update notification assignments for a backup schedule
*/
export const updateScheduleNotifications = <ThrowOnError extends boolean = false>(options: Options<UpdateScheduleNotificationsData, ThrowOnError>) => {
return (options.client ?? client).put<UpdateScheduleNotificationsResponses, unknown, ThrowOnError>({
url: '/api/v1/backups/{scheduleId}/notifications',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
export const updateScheduleNotifications = <ThrowOnError extends boolean = false>(options: Options<UpdateScheduleNotificationsData, ThrowOnError>) => (options.client ?? client).put<UpdateScheduleNotificationsResponses, unknown, ThrowOnError>({
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 = <ThrowOnError extends boolean = false>(options: Options<GetScheduleMirrorsData, ThrowOnError>) => (options.client ?? client).get<GetScheduleMirrorsResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/mirrors', ...options });
/**
* Update mirror repository assignments for a backup schedule
*/
export const updateScheduleMirrors = <ThrowOnError extends boolean = false>(options: Options<UpdateScheduleMirrorsData, ThrowOnError>) => (options.client ?? client).put<UpdateScheduleMirrorsResponses, unknown, ThrowOnError>({
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 = <ThrowOnError extends boolean = false>(options: Options<GetMirrorCompatibilityData, ThrowOnError>) => (options.client ?? client).get<GetMirrorCompatibilityResponses, unknown, ThrowOnError>({ url: '/api/v1/backups/{scheduleId}/mirrors/compatibility', ...options });
/**
* List all notification destinations
*/
export const listNotificationDestinations = <ThrowOnError extends boolean = false>(options?: Options<ListNotificationDestinationsData, ThrowOnError>) => {
return (options?.client ?? client).get<ListNotificationDestinationsResponses, unknown, ThrowOnError>({
url: '/api/v1/notifications/destinations',
...options
});
};
export const listNotificationDestinations = <ThrowOnError extends boolean = false>(options?: Options<ListNotificationDestinationsData, ThrowOnError>) => (options?.client ?? client).get<ListNotificationDestinationsResponses, unknown, ThrowOnError>({ url: '/api/v1/notifications/destinations', ...options });
/**
* Create a new notification destination
*/
export const createNotificationDestination = <ThrowOnError extends boolean = false>(options?: Options<CreateNotificationDestinationData, ThrowOnError>) => {
return (options?.client ?? client).post<CreateNotificationDestinationResponses, unknown, ThrowOnError>({
url: '/api/v1/notifications/destinations',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
export const createNotificationDestination = <ThrowOnError extends boolean = false>(options?: Options<CreateNotificationDestinationData, ThrowOnError>) => (options?.client ?? client).post<CreateNotificationDestinationResponses, unknown, ThrowOnError>({
url: '/api/v1/notifications/destinations',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
/**
* Delete a notification destination
*/
export const deleteNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<DeleteNotificationDestinationData, ThrowOnError>) => {
return (options.client ?? client).delete<DeleteNotificationDestinationResponses, DeleteNotificationDestinationErrors, ThrowOnError>({
url: '/api/v1/notifications/destinations/{id}',
...options
});
};
export const deleteNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<DeleteNotificationDestinationData, ThrowOnError>) => (options.client ?? client).delete<DeleteNotificationDestinationResponses, DeleteNotificationDestinationErrors, ThrowOnError>({ url: '/api/v1/notifications/destinations/{id}', ...options });
/**
* Get a notification destination by ID
*/
export const getNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<GetNotificationDestinationData, ThrowOnError>) => {
return (options.client ?? client).get<GetNotificationDestinationResponses, GetNotificationDestinationErrors, ThrowOnError>({
url: '/api/v1/notifications/destinations/{id}',
...options
});
};
export const getNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<GetNotificationDestinationData, ThrowOnError>) => (options.client ?? client).get<GetNotificationDestinationResponses, GetNotificationDestinationErrors, ThrowOnError>({ url: '/api/v1/notifications/destinations/{id}', ...options });
/**
* Update a notification destination
*/
export const updateNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<UpdateNotificationDestinationData, ThrowOnError>) => {
return (options.client ?? client).patch<UpdateNotificationDestinationResponses, UpdateNotificationDestinationErrors, ThrowOnError>({
url: '/api/v1/notifications/destinations/{id}',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
export const updateNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<UpdateNotificationDestinationData, ThrowOnError>) => (options.client ?? client).patch<UpdateNotificationDestinationResponses, UpdateNotificationDestinationErrors, ThrowOnError>({
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 = <ThrowOnError extends boolean = false>(options: Options<TestNotificationDestinationData, ThrowOnError>) => {
return (options.client ?? client).post<TestNotificationDestinationResponses, TestNotificationDestinationErrors, ThrowOnError>({
url: '/api/v1/notifications/destinations/{id}/test',
...options
});
};
export const testNotificationDestination = <ThrowOnError extends boolean = false>(options: Options<TestNotificationDestinationData, ThrowOnError>) => (options.client ?? client).post<TestNotificationDestinationResponses, TestNotificationDestinationErrors, ThrowOnError>({ url: '/api/v1/notifications/destinations/{id}/test', ...options });
/**
* Get system information including available capabilities
*/
export const getSystemInfo = <ThrowOnError extends boolean = false>(options?: Options<GetSystemInfoData, ThrowOnError>) => {
return (options?.client ?? client).get<GetSystemInfoResponses, unknown, ThrowOnError>({
url: '/api/v1/system/info',
...options
});
};
export const getSystemInfo = <ThrowOnError extends boolean = false>(options?: Options<GetSystemInfoData, ThrowOnError>) => (options?.client ?? client).get<GetSystemInfoResponses, unknown, ThrowOnError>({ url: '/api/v1/system/info', ...options });
/**
* Download the Restic password file for backup recovery. Requires password re-authentication.
*/
export const downloadResticPassword = <ThrowOnError extends boolean = false>(options?: Options<DownloadResticPasswordData, ThrowOnError>) => {
return (options?.client ?? client).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({
url: '/api/v1/system/restic-password',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});
};
export const downloadResticPassword = <ThrowOnError extends boolean = false>(options?: Options<DownloadResticPasswordData, ThrowOnError>) => (options?.client ?? client).post<DownloadResticPasswordResponses, unknown, ThrowOnError>({
url: '/api/v1/system/restic-password',
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers
}
});

View File

@@ -189,6 +189,7 @@ export type ListVolumesResponses = {
lastError: string | null;
lastHealthCheck: number;
name: string;
shortId: string;
status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number;
@@ -279,6 +280,7 @@ export type CreateVolumeResponses = {
lastError: string | null;
lastHealthCheck: number;
name: string;
shortId: string;
status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number;
@@ -422,6 +424,7 @@ export type GetVolumeResponses = {
lastError: string | null;
lastHealthCheck: number;
name: string;
shortId: string;
status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number;
@@ -465,6 +468,7 @@ export type UpdateVolumeData = {
ssl?: boolean;
username?: string;
};
name?: string;
};
path: {
name: string;
@@ -522,6 +526,7 @@ export type UpdateVolumeResponses = {
lastError: string | null;
lastHealthCheck: number;
name: string;
shortId: string;
status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number;
@@ -704,7 +709,7 @@ export type ListRepositoriesResponses = {
* List of repositories
*/
200: Array<{
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
compressionMode: 'auto' | 'max' | 'off' | null;
config: {
accessKeyId: string;
backend: 'r2';
@@ -771,6 +776,7 @@ export type ListRepositoriesResponses = {
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;
@@ -843,7 +849,7 @@ export type CreateRepositoryData = {
isExistingRepository?: boolean;
};
name: string;
compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off';
compressionMode?: 'auto' | 'max' | 'off';
};
path?: never;
query?: never;
@@ -918,7 +924,7 @@ export type GetRepositoryResponses = {
* Repository details
*/
200: {
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
compressionMode: 'auto' | 'max' | 'off' | null;
config: {
accessKeyId: string;
backend: 'r2';
@@ -985,6 +991,7 @@ export type GetRepositoryResponses = {
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;
@@ -993,6 +1000,110 @@ export type GetRepositoryResponses = {
export type GetRepositoryResponse = GetRepositoryResponses[keyof GetRepositoryResponses];
export type UpdateRepositoryData = {
body?: {
compressionMode?: 'auto' | 'max' | 'off';
name?: string;
};
path: {
name: string;
};
query?: never;
url: '/api/v1/repositories/{name}';
};
export type UpdateRepositoryErrors = {
/**
* Repository not found
*/
404: unknown;
/**
* Repository with this name already exists
*/
409: unknown;
};
export type UpdateRepositoryResponses = {
/**
* Repository updated successfully
*/
200: {
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;
};
};
export type UpdateRepositoryResponse = UpdateRepositoryResponses[keyof UpdateRepositoryResponses];
export type ListSnapshotsData = {
body?: never;
path: {
@@ -1013,6 +1124,7 @@ export type ListSnapshotsResponses = {
paths: Array<string>;
short_id: string;
size: number;
tags: Array<string>;
time: number;
}>;
};
@@ -1059,6 +1171,7 @@ export type GetSnapshotDetailsResponses = {
paths: Array<string>;
short_id: string;
size: number;
tags: Array<string>;
time: number;
};
};
@@ -1113,6 +1226,8 @@ export type RestoreSnapshotData = {
exclude?: Array<string>;
excludeXattr?: Array<string>;
include?: Array<string>;
overwrite?: 'always' | 'if-changed' | 'if-newer' | 'never';
targetPath?: string;
};
path: {
name: string;
@@ -1176,15 +1291,17 @@ export type ListBackupSchedulesResponses = {
createdAt: number;
cronExpression: string;
enabled: boolean;
excludeIfPresent: Array<string> | null;
excludePatterns: Array<string> | null;
id: number;
includePatterns: Array<string> | null;
lastBackupAt: number | null;
lastBackupError: string | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
name: string;
nextBackupAt: number | null;
repository: {
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
compressionMode: 'auto' | 'max' | 'off' | null;
config: {
accessKeyId: string;
backend: 'r2';
@@ -1251,6 +1368,7 @@ export type ListBackupSchedulesResponses = {
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;
@@ -1304,6 +1422,7 @@ export type ListBackupSchedulesResponses = {
lastError: string | null;
lastHealthCheck: number;
name: string;
shortId: string;
status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number;
@@ -1318,8 +1437,10 @@ export type CreateBackupScheduleData = {
body?: {
cronExpression: string;
enabled: boolean;
name: string;
repositoryId: string;
volumeId: number;
excludeIfPresent?: Array<string>;
excludePatterns?: Array<string>;
includePatterns?: Array<string>;
retentionPolicy?: {
@@ -1346,12 +1467,14 @@ export type CreateBackupScheduleResponses = {
createdAt: number;
cronExpression: string;
enabled: boolean;
excludeIfPresent: Array<string> | null;
excludePatterns: Array<string> | null;
id: number;
includePatterns: Array<string> | null;
lastBackupAt: number | null;
lastBackupError: string | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
name: string;
nextBackupAt: number | null;
repositoryId: string;
retentionPolicy: {
@@ -1407,15 +1530,17 @@ export type GetBackupScheduleResponses = {
createdAt: number;
cronExpression: string;
enabled: boolean;
excludeIfPresent: Array<string> | null;
excludePatterns: Array<string> | null;
id: number;
includePatterns: Array<string> | null;
lastBackupAt: number | null;
lastBackupError: string | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
name: string;
nextBackupAt: number | null;
repository: {
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
compressionMode: 'auto' | 'max' | 'off' | null;
config: {
accessKeyId: string;
backend: 'r2';
@@ -1482,6 +1607,7 @@ export type GetBackupScheduleResponses = {
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;
@@ -1535,6 +1661,7 @@ export type GetBackupScheduleResponses = {
lastError: string | null;
lastHealthCheck: number;
name: string;
shortId: string;
status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number;
@@ -1550,8 +1677,10 @@ export type UpdateBackupScheduleData = {
cronExpression: string;
repositoryId: string;
enabled?: boolean;
excludeIfPresent?: Array<string>;
excludePatterns?: Array<string>;
includePatterns?: Array<string>;
name?: string;
retentionPolicy?: {
keepDaily?: number;
keepHourly?: number;
@@ -1578,12 +1707,14 @@ export type UpdateBackupScheduleResponses = {
createdAt: number;
cronExpression: string;
enabled: boolean;
excludeIfPresent: Array<string> | null;
excludePatterns: Array<string> | null;
id: number;
includePatterns: Array<string> | null;
lastBackupAt: number | null;
lastBackupError: string | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
name: string;
nextBackupAt: number | null;
repositoryId: string;
retentionPolicy: {
@@ -1619,15 +1750,17 @@ export type GetBackupScheduleForVolumeResponses = {
createdAt: number;
cronExpression: string;
enabled: boolean;
excludeIfPresent: Array<string> | null;
excludePatterns: Array<string> | null;
id: number;
includePatterns: Array<string> | null;
lastBackupAt: number | null;
lastBackupError: string | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
name: string;
nextBackupAt: number | null;
repository: {
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null;
compressionMode: 'auto' | 'max' | 'off' | null;
config: {
accessKeyId: string;
backend: 'r2';
@@ -1694,6 +1827,7 @@ export type GetBackupScheduleForVolumeResponses = {
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;
@@ -1747,6 +1881,7 @@ export type GetBackupScheduleForVolumeResponses = {
lastError: string | null;
lastHealthCheck: number;
name: string;
shortId: string;
status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number;
@@ -1846,6 +1981,10 @@ export type GetScheduleNotificationsResponses = {
type: 'pushover';
userKey: string;
devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | {
from: string;
password: string;
@@ -1859,13 +1998,15 @@ export type GetScheduleNotificationsResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
password?: string;
serverUrl?: string;
token?: string;
username?: string;
} | {
priority: number;
serverUrl: string;
token: string;
type: 'gotify';
path?: string;
} | {
shoutrrrUrl: string;
type: 'custom';
@@ -1873,6 +2014,7 @@ export type GetScheduleNotificationsResponses = {
type: 'discord';
webhookUrl: string;
avatarUrl?: string;
threadId?: string;
username?: string;
} | {
type: 'slack';
@@ -1885,7 +2027,7 @@ export type GetScheduleNotificationsResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
destinationId: number;
@@ -1927,6 +2069,10 @@ export type UpdateScheduleNotificationsResponses = {
type: 'pushover';
userKey: string;
devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | {
from: string;
password: string;
@@ -1940,13 +2086,15 @@ export type UpdateScheduleNotificationsResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
password?: string;
serverUrl?: string;
token?: string;
username?: string;
} | {
priority: number;
serverUrl: string;
token: string;
type: 'gotify';
path?: string;
} | {
shoutrrrUrl: string;
type: 'custom';
@@ -1954,6 +2102,7 @@ export type UpdateScheduleNotificationsResponses = {
type: 'discord';
webhookUrl: string;
avatarUrl?: string;
threadId?: string;
username?: string;
} | {
type: 'slack';
@@ -1966,7 +2115,7 @@ export type UpdateScheduleNotificationsResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
destinationId: number;
@@ -1979,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;
@@ -1997,6 +2371,10 @@ export type ListNotificationDestinationsResponses = {
type: 'pushover';
userKey: string;
devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | {
from: string;
password: string;
@@ -2010,13 +2388,15 @@ export type ListNotificationDestinationsResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
password?: string;
serverUrl?: string;
token?: string;
username?: string;
} | {
priority: number;
serverUrl: string;
token: string;
type: 'gotify';
path?: string;
} | {
shoutrrrUrl: string;
type: 'custom';
@@ -2024,6 +2404,7 @@ export type ListNotificationDestinationsResponses = {
type: 'discord';
webhookUrl: string;
avatarUrl?: string;
threadId?: string;
username?: string;
} | {
type: 'slack';
@@ -2036,7 +2417,7 @@ export type ListNotificationDestinationsResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
}>;
};
@@ -2051,6 +2432,10 @@ export type CreateNotificationDestinationData = {
type: 'pushover';
userKey: string;
devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | {
from: string;
password: string;
@@ -2064,13 +2449,15 @@ export type CreateNotificationDestinationData = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
password?: string;
serverUrl?: string;
token?: string;
username?: string;
} | {
priority: number;
serverUrl: string;
token: string;
type: 'gotify';
path?: string;
} | {
shoutrrrUrl: string;
type: 'custom';
@@ -2078,6 +2465,7 @@ export type CreateNotificationDestinationData = {
type: 'discord';
webhookUrl: string;
avatarUrl?: string;
threadId?: string;
username?: string;
} | {
type: 'slack';
@@ -2104,6 +2492,10 @@ export type CreateNotificationDestinationResponses = {
type: 'pushover';
userKey: string;
devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | {
from: string;
password: string;
@@ -2117,13 +2509,15 @@ export type CreateNotificationDestinationResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
password?: string;
serverUrl?: string;
token?: string;
username?: string;
} | {
priority: number;
serverUrl: string;
token: string;
type: 'gotify';
path?: string;
} | {
shoutrrrUrl: string;
type: 'custom';
@@ -2131,6 +2525,7 @@ export type CreateNotificationDestinationResponses = {
type: 'discord';
webhookUrl: string;
avatarUrl?: string;
threadId?: string;
username?: string;
} | {
type: 'slack';
@@ -2143,7 +2538,7 @@ export type CreateNotificationDestinationResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
};
@@ -2204,6 +2599,10 @@ export type GetNotificationDestinationResponses = {
type: 'pushover';
userKey: string;
devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | {
from: string;
password: string;
@@ -2217,13 +2616,15 @@ export type GetNotificationDestinationResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
password?: string;
serverUrl?: string;
token?: string;
username?: string;
} | {
priority: number;
serverUrl: string;
token: string;
type: 'gotify';
path?: string;
} | {
shoutrrrUrl: string;
type: 'custom';
@@ -2231,6 +2632,7 @@ export type GetNotificationDestinationResponses = {
type: 'discord';
webhookUrl: string;
avatarUrl?: string;
threadId?: string;
username?: string;
} | {
type: 'slack';
@@ -2243,7 +2645,7 @@ export type GetNotificationDestinationResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
};
@@ -2258,6 +2660,10 @@ export type UpdateNotificationDestinationData = {
type: 'pushover';
userKey: string;
devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | {
from: string;
password: string;
@@ -2271,13 +2677,15 @@ export type UpdateNotificationDestinationData = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
password?: string;
serverUrl?: string;
token?: string;
username?: string;
} | {
priority: number;
serverUrl: string;
token: string;
type: 'gotify';
path?: string;
} | {
shoutrrrUrl: string;
type: 'custom';
@@ -2285,6 +2693,7 @@ export type UpdateNotificationDestinationData = {
type: 'discord';
webhookUrl: string;
avatarUrl?: string;
threadId?: string;
username?: string;
} | {
type: 'slack';
@@ -2321,6 +2730,10 @@ export type UpdateNotificationDestinationResponses = {
type: 'pushover';
userKey: string;
devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | {
from: string;
password: string;
@@ -2334,13 +2747,15 @@ export type UpdateNotificationDestinationResponses = {
priority: 'default' | 'high' | 'low' | 'max' | 'min';
topic: string;
type: 'ntfy';
password?: string;
serverUrl?: string;
token?: string;
username?: string;
} | {
priority: number;
serverUrl: string;
token: string;
type: 'gotify';
path?: string;
} | {
shoutrrrUrl: string;
type: 'custom';
@@ -2348,6 +2763,7 @@ export type UpdateNotificationDestinationResponses = {
type: 'discord';
webhookUrl: string;
avatarUrl?: string;
threadId?: string;
username?: string;
} | {
type: 'slack';
@@ -2360,7 +2776,7 @@ export type UpdateNotificationDestinationResponses = {
enabled: boolean;
id: number;
name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack';
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number;
};
};

View File

@@ -115,8 +115,6 @@ export const CreateRepositoryForm = ({
onChange={(e) => field.onChange(slugify(e.target.value))}
max={32}
min={2}
disabled={mode === "update"}
className={mode === "update" ? "bg-gray-50" : ""}
/>
</FormControl>
<FormDescription>Unique identifier for the repository.</FormDescription>
@@ -176,10 +174,8 @@ export const CreateRepositoryForm = ({
</FormControl>
<SelectContent>
<SelectItem value="off">Off</SelectItem>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="fastest">Fastest</SelectItem>
<SelectItem value="better">Better</SelectItem>
<SelectItem value="max">Max</SelectItem>
<SelectItem value="auto">Auto (fast)</SelectItem>
<SelectItem value="max">Max (slower, better compression)</SelectItem>
</SelectContent>
</Select>
<FormDescription>Compression mode for backups stored in this repository.</FormDescription>
@@ -237,8 +233,7 @@ export const CreateRepositoryForm = ({
</SelectContent>
</Select>
<FormDescription>
Choose whether to use Zerobyte's master password or enter a custom password for the existing
repository.
Choose whether to use Zerobyte's master password or enter a custom password for the existing repository.
</FormDescription>
</FormItem>

View File

@@ -104,8 +104,6 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
onChange={(e) => field.onChange(slugify(e.target.value))}
max={32}
min={1}
disabled={mode === "update"}
className={mode === "update" ? "bg-gray-50" : ""}
/>
</FormControl>
<FormDescription>Unique identifier for the volume.</FormDescription>

View File

@@ -0,0 +1,39 @@
import { useState } from "react";
import { DirectoryBrowser } from "./directory-browser";
import { Button } from "./ui/button";
type Props = {
value: string;
onChange: (path: string) => void;
label?: string;
};
export const PathSelector = ({ value, onChange }: Props) => {
const [showBrowser, setShowBrowser] = useState(false);
if (showBrowser) {
return (
<div className="space-y-2">
<DirectoryBrowser
onSelectPath={(path) => {
onChange(path);
setShowBrowser(false);
}}
selectedPath={value}
/>
<Button type="button" variant="ghost" size="sm" onClick={() => setShowBrowser(false)}>
Cancel
</Button>
</div>
);
}
return (
<div className="flex items-center gap-2">
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">{value}</div>
<Button type="button" variant="outline" onClick={() => setShowBrowser(true)} size="sm">
Change
</Button>
</div>
);
};

View File

@@ -0,0 +1,325 @@
import { useCallback, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { ChevronDown, FileIcon, FolderOpen, RotateCcw } from "lucide-react";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Checkbox } from "~/client/components/ui/checkbox";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { PathSelector } from "~/client/components/path-selector";
import { FileTree } from "~/client/components/file-tree";
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic";
import type { Snapshot } from "~/client/lib/types";
type RestoreLocation = "original" | "custom";
interface RestoreFormProps {
snapshot: Snapshot;
repositoryName: string;
snapshotId: string;
returnPath: string;
}
export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }: RestoreFormProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>("original");
const [customTargetPath, setCustomTargetPath] = useState("");
const [overwriteMode, setOverwriteMode] = useState<OverwriteMode>("always");
const [showAdvanced, setShowAdvanced] = useState(false);
const [excludeXattr, setExcludeXattr] = useState("");
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const { data: filesData, isLoading: filesLoading } = useQuery({
...listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId },
query: { path: volumeBasePath },
}),
enabled: !!repositoryName && !!snapshotId,
});
const stripBasePath = useCallback(
(path: string): string => {
if (!volumeBasePath) return path;
if (path === volumeBasePath) return "/";
if (path.startsWith(`${volumeBasePath}/`)) {
const stripped = path.slice(volumeBasePath.length);
return stripped;
}
return path;
},
[volumeBasePath],
);
const addBasePath = useCallback(
(displayPath: string): string => {
const vbp = volumeBasePath === "/" ? "" : volumeBasePath;
if (!vbp) return displayPath;
if (displayPath === "/") return vbp;
return `${vbp}${displayPath}`;
},
[volumeBasePath],
);
const fileBrowser = useFileBrowser({
initialData: filesData,
isLoading: filesLoading,
fetchFolder: async (path) => {
return await queryClient.ensureQueryData(
listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId },
query: { path },
}),
);
},
prefetchFolder: (path) => {
queryClient.prefetchQuery(
listSnapshotFilesOptions({
path: { name: repositoryName, snapshotId },
query: { path },
}),
);
},
pathTransform: {
strip: stripBasePath,
add: addBasePath,
},
});
const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({
...restoreSnapshotMutation(),
onSuccess: (data) => {
toast.success("Restore completed", {
description: `Successfully restored ${data.filesRestored} file(s). ${data.filesSkipped} file(s) skipped.`,
});
navigate(returnPath);
},
onError: (error) => {
toast.error("Restore failed", { description: error.message || "Failed to restore snapshot" });
},
});
const handleRestore = useCallback(() => {
if (!repositoryName || !snapshotId) return;
const excludeXattrArray = excludeXattr
?.split(",")
.map((s) => s.trim())
.filter(Boolean);
const isCustomLocation = restoreLocation === "custom";
const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined;
const pathsArray = Array.from(selectedPaths);
const includePaths = pathsArray.map((path) => addBasePath(path));
restoreSnapshot({
path: { name: repositoryName },
body: {
snapshotId,
include: includePaths.length > 0 ? includePaths : undefined,
delete: deleteExtraFiles,
excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
targetPath,
overwrite: overwriteMode,
},
});
}, [
repositoryName,
snapshotId,
excludeXattr,
restoreLocation,
customTargetPath,
selectedPaths,
addBasePath,
deleteExtraFiles,
overwriteMode,
restoreSnapshot,
]);
const canRestore = restoreLocation === "original" || customTargetPath.trim();
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Restore Snapshot</h1>
<p className="text-sm text-muted-foreground">
{repositoryName} / {snapshotId}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => navigate(returnPath)}>
Cancel
</Button>
<Button variant="primary" onClick={handleRestore} disabled={isRestoring || !canRestore}>
{isRestoring
? "Restoring..."
: selectedPaths.size > 0
? `Restore ${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"}`
: "Restore All"}
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Restore Location</CardTitle>
<CardDescription>Choose where to restore the files</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-2">
<Button
type="button"
variant={restoreLocation === "original" ? "secondary" : "outline"}
size="sm"
className="flex justify-start gap-2"
onClick={() => setRestoreLocation("original")}
>
<RotateCcw size={16} className="mr-1" />
Original location
</Button>
<Button
type="button"
variant={restoreLocation === "custom" ? "secondary" : "outline"}
size="sm"
className="justify-start gap-2"
onClick={() => setRestoreLocation("custom")}
>
<FolderOpen size={16} className="mr-1" />
Custom location
</Button>
</div>
{restoreLocation === "custom" && (
<div className="space-y-2">
<PathSelector value={customTargetPath || "/"} onChange={setCustomTargetPath} />
<p className="text-xs text-muted-foreground">Files will be restored directly to this path</p>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Overwrite Mode</CardTitle>
<CardDescription>How to handle existing files</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Select value={overwriteMode} onValueChange={(value) => setOverwriteMode(value as OverwriteMode)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select overwrite behavior" />
</SelectTrigger>
<SelectContent>
<SelectItem value={OVERWRITE_MODES.always}>Always overwrite</SelectItem>
<SelectItem value={OVERWRITE_MODES.ifChanged}>Only if content changed</SelectItem>
<SelectItem value={OVERWRITE_MODES.ifNewer}>Only if snapshot is newer</SelectItem>
<SelectItem value={OVERWRITE_MODES.never}>Never overwrite</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{overwriteMode === OVERWRITE_MODES.always &&
"Existing files will always be replaced with the snapshot version."}
{overwriteMode === OVERWRITE_MODES.ifChanged &&
"Files are only replaced if their content differs from the snapshot."}
{overwriteMode === OVERWRITE_MODES.ifNewer &&
"Files are only replaced if the snapshot version has a newer modification time."}
{overwriteMode === OVERWRITE_MODES.never &&
"Existing files will never be replaced, only missing files are restored."}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="cursor-pointer" onClick={() => setShowAdvanced(!showAdvanced)}>
<div className="flex items-center justify-between">
<CardTitle className="text-base">Advanced options</CardTitle>
<ChevronDown size={16} className={`transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
</div>
</CardHeader>
{showAdvanced && (
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="exclude-xattr" className="text-sm">
Exclude extended attributes
</Label>
<Input
id="exclude-xattr"
placeholder="com.apple.metadata,user.*,nfs4.*"
value={excludeXattr}
onChange={(e) => setExcludeXattr(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Exclude specific extended attributes during restore (comma-separated)
</p>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="delete-extra"
checked={deleteExtraFiles}
onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)}
/>
<Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer">
Delete files not present in the snapshot
</Label>
</div>
</CardContent>
)}
</Card>
</div>
<Card className="lg:col-span-2 flex flex-col">
<CardHeader>
<CardTitle>Select Files to Restore</CardTitle>
<CardDescription>
{selectedPaths.size > 0
? `${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"} selected`
: "Select specific files or folders, or leave empty to restore everything"}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
{fileBrowser.isLoading && (
<div className="flex items-center justify-center flex-1">
<p className="text-muted-foreground">Loading files...</p>
</div>
)}
{fileBrowser.isEmpty && (
<div className="flex flex-col items-center justify-center flex-1 text-center p-8">
<FileIcon className="w-12 h-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">No files in this snapshot</p>
</div>
)}
{!fileBrowser.isLoading && !fileBrowser.isEmpty && (
<div className="overflow-auto flex-1 border border-border rounded-md bg-card m-4">
<FileTree
files={fileBrowser.fileArray}
onFolderExpand={fileBrowser.handleFolderExpand}
onFolderHover={fileBrowser.handleFolderHover}
expandedFolders={fileBrowser.expandedFolders}
loadingFolders={fileBrowser.loadingFolders}
className="px-2 py-2"
withCheckboxes={true}
selectedPaths={selectedPaths}
onSelectionChange={setSelectedPaths}
/>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Calendar, Clock, Database, FolderTree, HardDrive, Trash2 } 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) => {
<TableHeader className="bg-card-header">
<TableRow>
<TableHead className="uppercase">Snapshot ID</TableHead>
<TableHead className="uppercase">Schedule</TableHead>
<TableHead className="uppercase">Date & Time</TableHead>
<TableHead className="uppercase">Size</TableHead>
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
@@ -84,71 +84,91 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
</TableRow>
</TableHeader>
<TableBody>
{snapshots.map((snapshot) => (
<TableRow
key={snapshot.short_id}
className="hover:bg-accent/50 cursor-pointer"
onClick={() => handleRowClick(snapshot.short_id)}
>
<TableCell className="font-mono text-sm">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="text-strong-accent">{snapshot.short_id}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{new Date(snapshot.time).toLocaleString()}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
<ByteSize bytes={snapshot.size} base={1024} />
</span>
</div>
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex items-center justify-end gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
<div className="flex items-center justify-end gap-2">
<FolderTree className="h-4 w-4 text-muted-foreground" />
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs bg-primary/10 text-primary rounded-md px-2 py-1 cursor-help">
{snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-md">
<div className="flex flex-col gap-1">
{snapshot.paths.map((path) => (
<div key={`${snapshot.short_id}-${path}`} className="text-xs font-mono">
{path}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={(e) => handleDeleteClick(e, snapshot.short_id)}
disabled={deleteSnapshot.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
{snapshots.map((snapshot) => {
const backupIds = snapshot.tags.map(Number).filter((tag) => !Number.isNaN(tag));
const backup = backups.find((b) => backupIds.includes(b.id));
return (
<TableRow
key={snapshot.short_id}
className="hover:bg-accent/50 cursor-pointer"
onClick={() => handleRowClick(snapshot.short_id)}
>
<TableCell className="font-mono text-sm">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="text-strong-accent">{snapshot.short_id}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Link
hidden={!backup}
to={backup ? `/backups/${backup.id}` : "#"}
onClick={(e) => e.stopPropagation()}
className="hover:underline"
>
<span className="text-sm">{backup ? backup.id : "-"}</span>
</Link>
<span hidden={!!backup} className="text-sm text-muted-foreground">
-
</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{new Date(snapshot.time).toLocaleString()}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
<ByteSize bytes={snapshot.size} base={1024} />
</span>
</div>
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex items-center justify-end gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">{formatDuration(snapshot.duration / 1000)}</span>
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
<div className="flex items-center justify-end gap-2">
<FolderTree className="h-4 w-4 text-muted-foreground" />
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs bg-primary/10 text-primary rounded-md px-2 py-1 cursor-help">
{snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-md">
<div className="flex flex-col gap-1">
{snapshot.paths.map((path) => (
<div key={`${snapshot.short_id}-${path}`} className="text-xs font-mono">
{path}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={(e) => handleDeleteClick(e, snapshot.short_id)}
disabled={deleteSnapshot.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>

View File

@@ -3,7 +3,6 @@ import type { BackendType } from "~/schemas/volumes";
type VolumeIconProps = {
backend: BackendType;
size?: number;
};
const getIconAndColor = (backend: BackendType) => {
@@ -41,12 +40,12 @@ const getIconAndColor = (backend: BackendType) => {
}
};
export const VolumeIcon = ({ backend, size = 10 }: VolumeIconProps) => {
export const VolumeIcon = ({ backend }: VolumeIconProps) => {
const { icon: Icon, label } = getIconAndColor(backend);
return (
<span className={`flex items-center gap-2 rounded-md px-2 py-1`}>
<Icon size={size} />
<Icon className="h-4 w-4" />
{label}
</span>
);

View File

@@ -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);
};

View File

@@ -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<InternalFormValues, "excludePatternsText"> & {
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 }:
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 md:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormLabel>Backup name</FormLabel>
<FormControl>
<Input placeholder="My backup" {...field} />
</FormControl>
<FormDescription>A unique name to identify this backup schedule.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="repositoryId"
@@ -260,6 +312,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
</CardHeader>
<CardContent>
<VolumeFileBrowser
key={volume.id}
volumeName={volume.name}
selectedPaths={selectedPaths}
onSelectionChange={handleSelectionChange}
@@ -279,6 +332,27 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
</div>
</div>
)}
<FormField
control={form.control}
name="includePatternsText"
render={({ field }) => (
<FormItem className="mt-6">
<FormLabel>Additional include patterns</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder="/data/**&#10;/config/*.json&#10;*.db"
className="font-mono text-sm min-h-[100px]"
/>
</FormControl>
<FormDescription>
Optionally add custom include patterns using glob syntax. Enter one pattern per line. These will
be combined with the paths selected above.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
@@ -320,6 +394,28 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
</FormItem>
)}
/>
<FormField
control={form.control}
name="excludeIfPresentText"
render={({ field }) => (
<FormItem className="mt-6">
<FormLabel>Exclude if file present</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder=".nobackup&#10;.exclude-from-backup&#10;CACHEDIR.TAG"
className="font-mono text-sm min-h-20"
/>
</FormControl>
<FormDescription>
Exclude folders containing a file with the specified name. Enter one filename per line. For
example, use <code className="bg-muted px-1 rounded">.nobackup</code> to skip any folder
containing a <code className="bg-muted px-1 rounded">.nobackup</code> file.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
@@ -482,18 +578,27 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
{repositoriesData?.find((r) => r.id === formValues.repositoryId)?.name || "—"}
</p>
</div>
{formValues.includePatterns && formValues.includePatterns.length > 0 && (
{(formValues.includePatterns && formValues.includePatterns.length > 0) ||
formValues.includePatternsText ? (
<div>
<p className="text-xs uppercase text-muted-foreground">Include paths</p>
<p className="text-xs uppercase text-muted-foreground">Include paths/patterns</p>
<div className="flex flex-col gap-1">
{formValues.includePatterns.map((path) => (
{formValues.includePatterns?.map((path) => (
<span key={path} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
{path}
</span>
))}
{formValues.includePatternsText
?.split("\n")
.filter(Boolean)
.map((pattern) => (
<span key={pattern} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
{pattern.trim()}
</span>
))}
</div>
</div>
)}
) : null}
{formValues.excludePatternsText && (
<div>
<p className="text-xs uppercase text-muted-foreground">Exclude patterns</p>
@@ -509,6 +614,21 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
</div>
</div>
)}
{formValues.excludeIfPresentText && (
<div>
<p className="text-xs uppercase text-muted-foreground">Exclude if present</p>
<div className="flex flex-col gap-1">
{formValues.excludeIfPresentText
.split("\n")
.filter(Boolean)
.map((filename) => (
<span key={filename} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
{filename.trim()}
</span>
))}
</div>
</div>
)}
<div>
<p className="text-xs uppercase text-muted-foreground">Retention</p>
<p className="font-medium">

View File

@@ -0,0 +1,352 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { Copy, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Switch } from "~/client/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { Badge } from "~/client/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import {
getScheduleMirrorsOptions,
getMirrorCompatibilityOptions,
updateScheduleMirrorsMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors";
import type { Repository } from "~/client/lib/types";
import { RepositoryIcon } from "~/client/components/repository-icon";
import { StatusDot } from "~/client/components/status-dot";
import { formatDistanceToNow } from "date-fns";
import { Link } from "react-router";
import { cn } from "~/client/lib/utils";
type Props = {
scheduleId: number;
primaryRepositoryId: string;
repositories: Repository[];
};
type MirrorAssignment = {
repositoryId: string;
enabled: boolean;
lastCopyAt: number | null;
lastCopyStatus: "success" | "error" | null;
lastCopyError: string | null;
};
export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, repositories }: Props) => {
const [assignments, setAssignments] = useState<Map<string, MirrorAssignment>>(new Map());
const [hasChanges, setHasChanges] = useState(false);
const [isAddingNew, setIsAddingNew] = useState(false);
const { data: currentMirrors } = useQuery({
...getScheduleMirrorsOptions({ path: { scheduleId: scheduleId.toString() } }),
});
const { data: compatibility } = useQuery({
...getMirrorCompatibilityOptions({ path: { scheduleId: scheduleId.toString() } }),
});
const updateMirrors = useMutation({
...updateScheduleMirrorsMutation(),
onSuccess: () => {
toast.success("Mirror settings saved successfully");
setHasChanges(false);
},
onError: (error) => {
toast.error("Failed to save mirror settings", {
description: parseError(error)?.message,
});
},
});
const compatibilityMap = useMemo(() => {
const map = new Map<string, { compatible: boolean; reason: string | null }>();
if (compatibility) {
for (const item of compatibility) {
map.set(item.repositoryId, { compatible: item.compatible, reason: item.reason });
}
}
return map;
}, [compatibility]);
useEffect(() => {
if (currentMirrors && !hasChanges) {
const map = new Map<string, MirrorAssignment>();
for (const mirror of currentMirrors) {
map.set(mirror.repositoryId, {
repositoryId: mirror.repositoryId,
enabled: mirror.enabled,
lastCopyAt: mirror.lastCopyAt,
lastCopyStatus: mirror.lastCopyStatus,
lastCopyError: mirror.lastCopyError,
});
}
setAssignments(map);
}
}, [currentMirrors, hasChanges]);
const addRepository = (repositoryId: string) => {
const newAssignments = new Map(assignments);
newAssignments.set(repositoryId, {
repositoryId,
enabled: true,
lastCopyAt: null,
lastCopyStatus: null,
lastCopyError: null,
});
setAssignments(newAssignments);
setHasChanges(true);
setIsAddingNew(false);
};
const removeRepository = (repositoryId: string) => {
const newAssignments = new Map(assignments);
newAssignments.delete(repositoryId);
setAssignments(newAssignments);
setHasChanges(true);
};
const toggleEnabled = (repositoryId: string) => {
const assignment = assignments.get(repositoryId);
if (!assignment) return;
const newAssignments = new Map(assignments);
newAssignments.set(repositoryId, {
...assignment,
enabled: !assignment.enabled,
});
setAssignments(newAssignments);
setHasChanges(true);
};
const handleSave = () => {
const mirrorsList = Array.from(assignments.values()).map((a) => ({
repositoryId: a.repositoryId,
enabled: a.enabled,
}));
updateMirrors.mutate({
path: { scheduleId: scheduleId.toString() },
body: {
mirrors: mirrorsList,
},
});
};
const handleReset = () => {
if (currentMirrors) {
const map = new Map<string, MirrorAssignment>();
for (const mirror of currentMirrors) {
map.set(mirror.repositoryId, {
repositoryId: mirror.repositoryId,
enabled: mirror.enabled,
lastCopyAt: mirror.lastCopyAt,
lastCopyStatus: mirror.lastCopyStatus,
lastCopyError: mirror.lastCopyError,
});
}
setAssignments(map);
setHasChanges(false);
}
};
const selectableRepositories =
repositories?.filter((r) => {
if (r.id === primaryRepositoryId) return false;
if (assignments.has(r.id)) return false;
return true;
}) || [];
const hasAvailableRepositories = selectableRepositories.some((r) => {
const compat = compatibilityMap.get(r.id);
return compat?.compatible !== false;
});
const assignedRepositories = Array.from(assignments.keys())
.map((id) => repositories?.find((r) => r.id === id))
.filter((r) => r !== undefined);
const getStatusVariant = (status: "success" | "error" | null) => {
if (status === "success") return "success";
if (status === "error") return "error";
return "neutral";
};
const getStatusLabel = (assignment: MirrorAssignment) => {
if (assignment.lastCopyStatus === "error" && assignment.lastCopyError) {
return assignment.lastCopyError;
}
if (assignment.lastCopyStatus === "success") {
return "Last copy successful";
}
return "Never copied";
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Copy className="h-5 w-5" />
Mirror Repositories
</CardTitle>
<CardDescription>
Configure secondary repositories where snapshots will be automatically copied after each backup
</CardDescription>
</div>
{!isAddingNew && selectableRepositories.length > 0 && (
<Button variant="outline" size="sm" onClick={() => setIsAddingNew(true)}>
<Plus className="h-4 w-4 mr-2" />
Add mirror
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isAddingNew && (
<div className="mb-6 flex items-center gap-2 max-w-md">
<Select onValueChange={addRepository}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a repository to mirror to..." />
</SelectTrigger>
<SelectContent>
{selectableRepositories.map((repository) => {
const compat = compatibilityMap.get(repository.id);
return (
<Tooltip key={repository.id}>
<TooltipTrigger asChild>
<div>
<SelectItem value={repository.id} disabled={!compat?.compatible}>
<div className="flex items-center gap-2">
<RepositoryIcon backend={repository.type} className="h-4 w-4" />
<span>{repository.name}</span>
<span className="text-xs uppercase text-muted-foreground">({repository.type})</span>
</div>
</SelectItem>
</div>
</TooltipTrigger>
<TooltipContent side="right" className={cn("max-w-xs", { hidden: compat?.compatible })}>
<p>{compat?.reason || "This repository is not compatible for mirroring."}</p>
<p className="mt-1 text-xs text-muted-foreground">
Consider creating a new backup scheduler with the desired destination instead.
</p>
</TooltipContent>
</Tooltip>
);
})}
{!hasAvailableRepositories && selectableRepositories.length > 0 && (
<div className="px-2 py-3 text-sm text-muted-foreground text-center">
All available repositories have conflicting backends.
<br />
<span className="text-xs">
Consider creating a new backup scheduler with the desired destination instead.
</span>
</div>
)}
</SelectContent>
</Select>
<Button variant="ghost" size="sm" onClick={() => setIsAddingNew(false)}>
Cancel
</Button>
</div>
)}
{assignedRepositories.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
<Copy className="h-8 w-8 mb-2 opacity-20" />
<p className="text-sm">No mirror repositories configured for this schedule.</p>
<p className="text-xs mt-1">Click "Add mirror" to replicate backups to additional repositories.</p>
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Repository</TableHead>
<TableHead className="text-center w-[100px]">Enabled</TableHead>
<TableHead className="w-[180px]">Last Copy</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignedRepositories.map((repository) => {
const assignment = assignments.get(repository.id);
if (!assignment) return null;
return (
<TableRow key={repository.id}>
<TableCell>
<div className="flex items-center gap-2">
<Link
to={`/repositories/${repository.name}`}
className="hover:underline flex items-center gap-2"
>
<RepositoryIcon backend={repository.type} className="h-4 w-4" />
<span className="font-medium">{repository.name}</span>
</Link>
<Badge variant="outline" className="text-[10px] align-middle">
{repository.type}
</Badge>
</div>
</TableCell>
<TableCell className="text-center">
<Switch
className="align-middle"
checked={assignment.enabled}
onCheckedChange={() => toggleEnabled(repository.id)}
/>
</TableCell>
<TableCell>
{assignment.lastCopyAt ? (
<div className="flex items-center gap-2">
<StatusDot
variant={getStatusVariant(assignment.lastCopyStatus)}
label={getStatusLabel(assignment)}
/>
<span className="text-sm text-muted-foreground">
{formatDistanceToNow(new Date(assignment.lastCopyAt), { addSuffix: true })}
</span>
</div>
) : (
<span className="text-sm text-muted-foreground">Never</span>
)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
onClick={() => removeRepository(repository.id)}
className="h-8 w-8 text-muted-foreground hover:text-destructive align-baseline"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
{hasChanges && (
<div className="flex gap-2 justify-end mt-4 pt-4">
<Button variant="outline" size="sm" onClick={handleReset}>
Cancel
</Button>
<Button variant="default" size="sm" onClick={handleSave} loading={updateMirrors.isPending}>
Save changes
</Button>
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -1,4 +1,4 @@
import { Eraser, Pencil, Play, Square, Trash2 } from "lucide-react";
import { Database, Eraser, HardDrive, Pencil, Play, Square, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { OnOff } from "~/client/components/onoff";
import { Button } from "~/client/components/ui/button";
@@ -18,6 +18,7 @@ import { runForgetMutation } from "~/client/api-client/@tanstack/react-query.gen
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { parseError } from "~/client/lib/errors";
import { Link } from "react-router";
type Props = {
schedule: BackupSchedule;
@@ -82,10 +83,17 @@ export const ScheduleSummary = (props: Props) => {
<CardHeader className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<CardTitle>Backup schedule</CardTitle>
<CardDescription>
Automated backup configuration for volume&nbsp;
<strong className="text-strong-accent">{schedule.volume.name}</strong>
<CardTitle>{schedule.name}</CardTitle>
<CardDescription className="mt-1">
<Link to={`/volumes/${schedule.volume.name}`} className="hover:underline">
<HardDrive className="inline h-4 w-4 mr-2" />
<span>{schedule.volume.name}</span>
</Link>
<span className="mx-2"></span>
<Link to={`/repositories/${schedule.repository.name}`} className="hover:underline">
<Database className="inline h-4 w-4 mr-2 text-strong-accent" />
<span className="text-strong-accent">{schedule.repository.name}</span>
</Link>
</CardDescription>
</div>
<div className="flex items-center gap-2 justify-between sm:justify-start">
@@ -164,10 +172,20 @@ export const ScheduleSummary = (props: Props) => {
{schedule.lastBackupStatus === "success" && "✓ Success"}
{schedule.lastBackupStatus === "error" && "✗ Error"}
{schedule.lastBackupStatus === "in_progress" && "⟳ in progress..."}
{schedule.lastBackupStatus === "warning" && "! Warning"}
{!schedule.lastBackupStatus && "—"}
</p>
</div>
{schedule.lastBackupStatus === "warning" && (
<div className="md:col-span-2 lg:col-span-4">
<p className="text-xs uppercase text-muted-foreground">Warning Details</p>
<p className="font-mono text-sm text-yellow-600 whitespace-pre-wrap break-all">
Last backup completed with warnings. Check your container logs for more details.
</p>
</div>
)}
{schedule.lastBackupError && (
<div className="md:col-span-2 lg:col-span-4">
<p className="text-xs uppercase text-muted-foreground">Error Details</p>

View File

@@ -1,47 +1,26 @@
import { useCallback, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, FileIcon } from "lucide-react";
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FileIcon } from "lucide-react";
import { Link } from "react-router";
import { FileTree } from "~/client/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Button } from "~/client/components/ui/button";
import { Checkbox } from "~/client/components/ui/checkbox";
import { Label } from "~/client/components/ui/label";
import { Input } from "~/client/components/ui/input";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
import type { Snapshot, Volume } from "~/client/lib/types";
import { toast } from "sonner";
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { Button, buttonVariants } from "~/client/components/ui/button";
import type { Snapshot } from "~/client/lib/types";
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { useFileBrowser } from "~/client/hooks/use-file-browser";
interface Props {
snapshot: Snapshot;
repositoryName: string;
volume?: Volume;
backupId?: string;
onDeleteSnapshot?: (snapshotId: string) => void;
isDeletingSnapshot?: boolean;
}
export const SnapshotFileBrowser = (props: Props) => {
const { snapshot, repositoryName, volume, onDeleteSnapshot, isDeletingSnapshot } = props;
const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true;
const { snapshot, repositoryName, backupId, onDeleteSnapshot, isDeletingSnapshot } = props;
const queryClient = useQueryClient();
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [showRestoreDialog, setShowRestoreDialog] = useState(false);
const [deleteExtraFiles, setDeleteExtraFiles] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [excludeXattr, setExcludeXattr] = useState("");
const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
@@ -67,7 +46,7 @@ export const SnapshotFileBrowser = (props: Props) => {
const addBasePath = useCallback(
(displayPath: string): string => {
let vbp = volumeBasePath === "/" ? "" : volumeBasePath;
const vbp = volumeBasePath === "/" ? "" : volumeBasePath;
if (!vbp) return displayPath;
if (displayPath === "/") return vbp;
@@ -101,45 +80,6 @@ export const SnapshotFileBrowser = (props: Props) => {
},
});
const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({
...restoreSnapshotMutation(),
onSuccess: (data) => {
toast.success("Restore completed", {
description: `Successfully restored ${data.filesRestored} file(s). ${data.filesSkipped} file(s) skipped.`,
});
setSelectedPaths(new Set());
},
onError: (error) => {
toast.error("Restore failed", { description: error.message || "Failed to restore snapshot" });
},
});
const handleRestoreClick = useCallback(() => {
setShowRestoreDialog(true);
}, []);
const handleConfirmRestore = useCallback(() => {
const pathsArray = Array.from(selectedPaths);
const includePaths = pathsArray.map((path) => addBasePath(path));
const excludeXattrArray = excludeXattr
?.split(",")
.map((s) => s.trim())
.filter(Boolean);
restoreSnapshot({
path: { name: repositoryName },
body: {
snapshotId: snapshot.short_id,
include: includePaths,
delete: deleteExtraFiles,
excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
},
});
setShowRestoreDialog(false);
}, [selectedPaths, addBasePath, repositoryName, snapshot.short_id, restoreSnapshot, deleteExtraFiles, excludeXattr]);
return (
<div className="space-y-4">
<Card className="h-[600px] flex flex-col">
@@ -150,30 +90,16 @@ export const SnapshotFileBrowser = (props: Props) => {
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
</div>
<div className="flex gap-2">
{selectedPaths.size > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={isReadOnly ? 0 : undefined}>
<Button
onClick={handleRestoreClick}
variant="primary"
size="sm"
disabled={isRestoring || isReadOnly}
>
{isRestoring
? "Restoring..."
: `Restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"}`}
</Button>
</span>
</TooltipTrigger>
{isReadOnly && (
<TooltipContent className="text-center">
<p>Volume is mounted as read-only.</p>
<p>Please remount with read-only disabled to restore files.</p>
</TooltipContent>
)}
</Tooltip>
)}
<Link
to={
backupId
? `/backups/${backupId}/${snapshot.short_id}/restore`
: `/repositories/${repositoryName}/${snapshot.short_id}/restore`
}
className={buttonVariants({ variant: "primary", size: "sm" })}
>
Restore
</Link>
{onDeleteSnapshot && (
<Button
variant="destructive"
@@ -211,73 +137,11 @@ export const SnapshotFileBrowser = (props: Props) => {
expandedFolders={fileBrowser.expandedFolders}
loadingFolders={fileBrowser.loadingFolders}
className="px-2 py-2"
withCheckboxes={true}
selectedPaths={selectedPaths}
onSelectionChange={setSelectedPaths}
/>
</div>
)}
</CardContent>
</Card>
<AlertDialog open={showRestoreDialog} onOpenChange={setShowRestoreDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Restore</AlertDialogTitle>
<AlertDialogDescription>
{selectedPaths.size > 0
? `This will restore ${selectedPaths.size} selected ${selectedPaths.size === 1 ? "item" : "items"} from the snapshot.`
: "This will restore everything from the snapshot."}{" "}
Existing files will be overwritten by what's in the snapshot. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-4">
<div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvanced(!showAdvanced)}
className="h-auto p-0 text-sm font-normal"
>
Advanced
<ChevronDown size={16} className={`ml-1 transition-transform ${showAdvanced ? "rotate-180" : ""}`} />
</Button>
{showAdvanced && (
<div className="mt-4 space-y-2">
<Label htmlFor="exclude-xattr" className="text-sm">
Exclude Extended Attributes (Optional)
</Label>
<Input
id="exclude-xattr"
placeholder="com.apple.metadata,user.*,nfs4.*"
value={excludeXattr}
onChange={(e) => setExcludeXattr(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Exclude specific extended attributes during restore (comma-separated)
</p>
<div className="flex items-center space-x-2 mt-2">
<Checkbox
id="delete-extra"
checked={deleteExtraFiles}
onCheckedChange={(checked) => setDeleteExtraFiles(checked === true)}
/>
<Label htmlFor="delete-extra" className="text-sm font-normal cursor-pointer">
Delete files not present in the snapshot?
</Label>
</div>
</div>
)}
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmRestore}>Confirm</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};

View File

@@ -29,15 +29,16 @@ import { ScheduleSummary } from "../components/schedule-summary";
import type { Route } from "./+types/backup-details";
import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
import { SnapshotTimeline } from "../components/snapshot-timeline";
import { getBackupSchedule, listNotificationDestinations } from "~/client/api-client";
import { getBackupSchedule, listNotificationDestinations, listRepositories } from "~/client/api-client";
import { ScheduleNotificationsConfig } from "../components/schedule-notifications-config";
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) {
@@ -53,10 +54,11 @@ export function meta(_: Route.MetaArgs) {
export const clientLoader = async ({ params }: Route.LoaderArgs) => {
const schedule = await getBackupSchedule({ path: { scheduleId: params.id } });
const notifs = await listNotificationDestinations();
const repos = await listRepositories();
if (!schedule.data) return redirect("/backups");
return { schedule: schedule.data, notifs: notifs.data };
return { schedule: schedule.data, notifs: notifs.data, repos: repos.data };
};
export default function ScheduleDetailsPage({ params, loaderData }: Route.ComponentProps) {
@@ -70,8 +72,6 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
const { data: schedule } = useQuery({
...getBackupScheduleOptions({ path: { scheduleId: params.id } }),
initialData: loaderData.schedule,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
const {
@@ -153,12 +153,14 @@ 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,
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
includePatterns: formValues.includePatterns,
excludePatterns: formValues.excludePatterns,
excludeIfPresent: formValues.excludeIfPresent,
},
});
};
@@ -171,8 +173,9 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
enabled,
cronExpression: schedule.cronExpression,
retentionPolicy: schedule.retentionPolicy || undefined,
includePatterns: schedule.includePatterns || undefined,
excludePatterns: schedule.excludePatterns || undefined,
includePatterns: schedule.includePatterns || [],
excludePatterns: schedule.excludePatterns || [],
excludeIfPresent: schedule.excludeIfPresent || [],
},
});
};
@@ -228,6 +231,13 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
<div className={cn({ hidden: !loaderData.notifs?.length })}>
<ScheduleNotificationsConfig scheduleId={schedule.id} destinations={loaderData.notifs ?? []} />
</div>
<div className={cn({ hidden: !loaderData.repos?.length || loaderData.repos.length < 2 })}>
<ScheduleMirrorsConfig
scheduleId={schedule.id}
primaryRepositoryId={schedule.repositoryId}
repositories={loaderData.repos ?? []}
/>
</div>
<SnapshotTimeline
loading={isLoading}
snapshots={snapshots ?? []}
@@ -240,7 +250,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
key={selectedSnapshot?.short_id}
snapshot={selectedSnapshot}
repositoryName={schedule.repository.name}
volume={schedule.volume}
backupId={schedule.id.toString()}
onDeleteSnapshot={handleDeleteSnapshot}
isDeletingSnapshot={deleteSnapshot.isPending}
/>

View File

@@ -33,8 +33,6 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
const { data: schedules, isLoading } = useQuery({
...listBackupSchedulesOptions(),
initialData: loaderData,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
if (isLoading) {
@@ -69,13 +67,11 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
{schedules.map((schedule) => (
<Link key={schedule.id} to={`/backups/${schedule.id}`}>
<Card key={schedule.id} className="flex flex-col h-full">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<HardDrive className="h-5 w-5 text-muted-foreground shrink-0" />
<CardTitle className="text-lg truncate">
Volume <span className="text-strong-accent">{schedule.volume.name}</span>
</CardTitle>
<CardHeader className="pb-3 overflow-hidden">
<div className="flex items-center justify-between gap-2 w-full">
<div className="flex items-center gap-2 flex-1 min-w-0 w-0">
<CalendarClock className="h-5 w-5 text-muted-foreground shrink-0" />
<CardTitle className="text-lg truncate">{schedule.name}</CardTitle>
</div>
<BackupStatusDot
enabled={schedule.enabled}
@@ -83,9 +79,12 @@ export default function Backups({ loaderData }: Route.ComponentProps) {
isInProgress={schedule.lastBackupStatus === "in_progress"}
/>
</div>
<CardDescription className="flex items-center gap-2 mt-2">
<Database className="h-4 w-4" />
<span className="truncate">{schedule.repository.name}</span>
<CardDescription className="ml-0.5 flex items-center gap-2 text-xs">
<HardDrive className="h-3.5 w-3.5" />
<span className="truncate">{schedule.volume.name}</span>
<span className="text-muted-foreground"></span>
<Database className="h-3.5 w-3.5 text-strong-accent" />
<span className="truncate text-strong-accent">{schedule.repository.name}</span>
</CardDescription>
</CardHeader>
<CardContent className="flex-1 space-y-4">

View File

@@ -83,6 +83,7 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) {
createSchedule.mutate({
body: {
name: formValues.name,
volumeId: selectedVolumeId,
repositoryId: formValues.repositoryId,
enabled: true,
@@ -90,6 +91,7 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) {
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
includePatterns: formValues.includePatterns,
excludePatterns: formValues.excludePatterns,
excludeIfPresent: formValues.excludeIfPresent,
},
});
};

View File

@@ -0,0 +1,54 @@
import { redirect } from "react-router";
import { getBackupSchedule, getSnapshotDetails } from "~/client/api-client";
import { RestoreForm } from "~/client/components/restore-form";
import type { Route } from "./+types/restore-snapshot";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
{ label: "Backups", href: "/backups" },
{ label: `Schedule #${match.params.id}`, href: `/backups/${match.params.id}` },
{ label: match.params.snapshotId },
{ label: "Restore" },
],
};
export function meta({ params }: Route.MetaArgs) {
return [
{ title: `Zerobyte - Restore Snapshot ${params.snapshotId}` },
{
name: "description",
content: "Restore files from a backup snapshot.",
},
];
}
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const schedule = await getBackupSchedule({ path: { scheduleId: params.id } });
if (!schedule.data) return redirect("/backups");
const repositoryName = schedule.data.repository.name;
const snapshot = await getSnapshotDetails({
path: { name: repositoryName, snapshotId: params.snapshotId },
});
if (!snapshot.data) return redirect(`/backups/${params.id}`);
return {
snapshot: snapshot.data,
repositoryName,
snapshotId: params.snapshotId,
backupId: params.id,
};
};
export default function RestoreSnapshotFromBackupPage({ loaderData }: Route.ComponentProps) {
const { snapshot, repositoryName, snapshotId, backupId } = loaderData;
return (
<RestoreForm
snapshot={snapshot}
repositoryName={repositoryName}
snapshotId={snapshotId}
returnPath={`/backups/${backupId}`}
/>
);
}

View File

@@ -30,7 +30,6 @@ type Props = {
mode?: "create" | "update";
initialValues?: Partial<NotificationFormValues>;
formId?: string;
loading?: boolean;
className?: string;
};
@@ -70,6 +69,11 @@ const defaultValuesForType = {
apiToken: "",
priority: 0 as const,
},
telegram: {
type: "telegram" as const,
botToken: "",
chatId: "",
},
custom: {
type: "custom" as const,
shoutrrrUrl: "",
@@ -114,8 +118,6 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
onChange={(e) => field.onChange(slugify(e.target.value))}
max={32}
min={2}
disabled={mode === "update"}
className={mode === "update" ? "bg-gray-50" : ""}
/>
</FormControl>
<FormDescription>Unique identifier for this notification destination.</FormDescription>
@@ -148,6 +150,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
<SelectItem value="gotify">Gotify</SelectItem>
<SelectItem value="ntfy">Ntfy</SelectItem>
<SelectItem value="pushover">Pushover</SelectItem>
<SelectItem value="telegram">Telegram</SelectItem>
<SelectItem value="custom">Custom (Shoutrrr URL)</SelectItem>
</SelectContent>
</Select>
@@ -370,6 +373,22 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
</FormItem>
)}
/>
<FormField
control={form.control}
name="threadId"
render={({ field }) => (
<FormItem>
<FormLabel>Thread ID (Optional)</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
ID of the thread to post messages in. Leave empty to post in the main channel.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
@@ -423,6 +442,20 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="/custom/path" />
</FormControl>
<FormDescription>Custom path on the Gotify server, if applicable.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
@@ -458,14 +491,28 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
/>
<FormField
control={form.control}
name="token"
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Access Token (Optional)</FormLabel>
<FormLabel>Username (Optional)</FormLabel>
<FormControl>
<Input {...field} placeholder="username" />
</FormControl>
<FormDescription>Username for server authentication, if required.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password (Optional)</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="••••••••" />
</FormControl>
<FormDescription>Required if the topic is protected.</FormDescription>
<FormDescription>Password for server authentication, if required.</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -571,6 +618,41 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
</>
)}
{watchedType === "telegram" && (
<>
<FormField
control={form.control}
name="botToken"
render={({ field }) => (
<FormItem>
<FormLabel>Bot Token</FormLabel>
<FormControl>
<Input {...field} type="password" placeholder="123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" />
</FormControl>
<FormDescription>
Telegram bot token. Get this from BotFather when you create your bot.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="chatId"
render={({ field }) => (
<FormItem>
<FormLabel>Chat ID</FormLabel>
<FormControl>
<Input {...field} placeholder="-1231234567890" />
</FormControl>
<FormDescription>Telegram chat ID to send notifications to.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{watchedType === "custom" && (
<FormField
control={form.control}

View File

@@ -62,12 +62,7 @@ export default function CreateNotification() {
</AlertDescription>
</Alert>
)}
<CreateNotificationForm
mode="create"
formId={formId}
onSubmit={handleSubmit}
loading={createNotification.isPending}
/>
<CreateNotificationForm mode="create" formId={formId} onSubmit={handleSubmit} />
<div className="flex justify-end gap-2 pt-4 border-t">
<Button type="button" variant="secondary" onClick={() => navigate("/notifications")}>
Cancel

View File

@@ -171,20 +171,12 @@ export default function NotificationDetailsPage({ loaderData }: Route.ComponentP
</AlertDescription>
</Alert>
)}
<>
<CreateNotificationForm
mode="update"
formId={formId}
onSubmit={handleSubmit}
initialValues={data.config}
loading={updateDestination.isPending}
/>
<div className="flex justify-end gap-2 pt-4 border-t">
<Button type="submit" form={formId} loading={updateDestination.isPending}>
Save Changes
</Button>
</div>
</>
<CreateNotificationForm mode="update" formId={formId} onSubmit={handleSubmit} initialValues={data.config} />
<div className="flex justify-end gap-2 pt-4 border-t">
<Button type="submit" form={formId} loading={updateDestination.isPending}>
Save Changes
</Button>
</div>
</CardContent>
</Card>

View File

@@ -49,8 +49,6 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
const { data } = useQuery({
...listNotificationDestinationsOptions(),
initialData: loaderData,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
const filteredNotifications =
@@ -102,6 +100,7 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
<SelectItem value="gotify">Gotify</SelectItem>
<SelectItem value="ntfy">Ntfy</SelectItem>
<SelectItem value="pushover">Pushover</SelectItem>
<SelectItem value="telegram">Telegram</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
@@ -158,7 +157,10 @@ export default function Notifications({ loaderData }: Route.ComponentProps) {
<TableCell className="font-medium text-strong-accent">{notification.name}</TableCell>
<TableCell className="capitalize">{notification.type}</TableCell>
<TableCell className="text-center">
<StatusDot variant={notification.enabled ? "success" : "neutral"} label={notification.enabled ? "Enabled" : "Disabled"} />
<StatusDot
variant={notification.enabled ? "success" : "neutral"}
label={notification.enabled ? "Enabled" : "Disabled"}
/>
</TableCell>
</TableRow>
))

View File

@@ -1,100 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import { RotateCcw } from "lucide-react";
import { useId, useState } from "react";
import { toast } from "sonner";
import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors";
import { Button } from "~/client/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/client/components/ui/dialog";
import { ScrollArea } from "~/client/components/ui/scroll-area";
import { RestoreSnapshotForm, type RestoreSnapshotFormValues } from "./restore-snapshot-form";
type Props = {
name: string;
snapshotId: string;
};
export const RestoreSnapshotDialog = ({ name, snapshotId }: Props) => {
const [open, setOpen] = useState(false);
const formId = useId();
const restore = useMutation({
...restoreSnapshotMutation(),
onSuccess: (data) => {
toast.success("Snapshot restored successfully", {
description: `${data.filesRestored} files restored, ${data.filesSkipped} files skipped`,
});
setOpen(false);
},
onError: (error) => {
toast.error("Failed to restore snapshot", {
description: parseError(error)?.message,
});
},
});
const handleSubmit = (values: RestoreSnapshotFormValues) => {
const include = values.include
?.split(",")
.map((s) => s.trim())
.filter(Boolean);
const exclude = values.exclude
?.split(",")
.map((s) => s.trim())
.filter(Boolean);
const excludeXattr = values.excludeXattr
?.split(",")
.map((s) => s.trim())
.filter(Boolean);
restore.mutate({
path: { name },
body: {
snapshotId,
include: include && include.length > 0 ? include : undefined,
exclude: exclude && exclude.length > 0 ? exclude : undefined,
excludeXattr: excludeXattr && excludeXattr.length > 0 ? excludeXattr : undefined,
},
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<RotateCcw size={16} className="mr-2" />
Restore
</Button>
</DialogTrigger>
<DialogContent>
<ScrollArea className="max-h-[600px] p-4">
<DialogHeader>
<DialogTitle>Restore Snapshot</DialogTitle>
<DialogDescription>
Restore snapshot {snapshotId.substring(0, 8)} to a local filesystem path
</DialogDescription>
</DialogHeader>
<RestoreSnapshotForm className="mt-4" formId={formId} onSubmit={handleSubmit} />
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" form={formId} disabled={restore.isPending}>
{restore.isPending ? "Restoring..." : "Restore"}
</Button>
</DialogFooter>
</ScrollArea>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,141 +0,0 @@
import { arktypeResolver } from "@hookform/resolvers/arktype";
import { type } from "arktype";
import { ChevronDown } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { Button } from "~/client/components/ui/button";
const restoreSnapshotFormSchema = type({
path: "string?",
include: "string?",
exclude: "string?",
excludeXattr: "string?",
});
export type RestoreSnapshotFormValues = typeof restoreSnapshotFormSchema.inferIn;
type Props = {
formId: string;
onSubmit: (values: RestoreSnapshotFormValues) => void;
className?: string;
};
export const RestoreSnapshotForm = ({ formId, onSubmit, className }: Props) => {
const [showAdvanced, setShowAdvanced] = useState(false);
const form = useForm<RestoreSnapshotFormValues>({
resolver: arktypeResolver(restoreSnapshotFormSchema),
defaultValues: {
path: "",
include: "",
exclude: "",
excludeXattr: "",
},
});
const handleSubmit = (values: RestoreSnapshotFormValues) => {
onSubmit(values);
};
return (
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(handleSubmit)} className={className}>
<div className="space-y-4">
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path (Optional)</FormLabel>
<FormControl>
<Input placeholder="/specific/path" {...field} />
</FormControl>
<FormDescription>
Restore only a specific path from the snapshot (leave empty to restore all)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="include"
render={({ field }) => (
<FormItem>
<FormLabel>Include Patterns (Optional)</FormLabel>
<FormControl>
<Input placeholder="*.txt,/documents/**" {...field} />
</FormControl>
<FormDescription>Include only files matching these patterns (comma-separated)</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="exclude"
render={({ field }) => (
<FormItem>
<FormLabel>Exclude Patterns (Optional)</FormLabel>
<FormControl>
<Input placeholder="*.log,/temp/**" {...field} />
</FormControl>
<FormDescription>Exclude files matching these patterns (comma-separated)</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvanced(!showAdvanced)}
className="h-auto p-0 text-sm font-normal"
>
Advanced
<ChevronDown
size={16}
className={`ml-1 transition-transform ${showAdvanced ? "rotate-180" : ""}`}
/>
</Button>
{showAdvanced && (
<div className="mt-4">
<FormField
control={form.control}
name="excludeXattr"
render={({ field }) => (
<FormItem>
<FormLabel>Exclude Extended Attributes (Optional)</FormLabel>
<FormControl>
<Input placeholder="com.apple.metadata,user.custom" {...field} />
</FormControl>
<FormDescription>
Exclude specific extended attributes during restore (comma-separated)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
</div>
</form>
</Form>
);
};

View File

@@ -50,8 +50,6 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
const { data } = useQuery({
...listRepositoriesOptions(),
initialData: loaderData,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
const filteredRepositories =

View File

@@ -64,8 +64,6 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
const { data } = useQuery({
...getRepositoryOptions({ path: { name: loaderData.name } }),
initialData: loaderData,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
useEffect(() => {

View File

@@ -0,0 +1,45 @@
import { redirect } from "react-router";
import { getSnapshotDetails } from "~/client/api-client";
import { RestoreForm } from "~/client/components/restore-form";
import type { Route } from "./+types/restore-snapshot";
export const handle = {
breadcrumb: (match: Route.MetaArgs) => [
{ label: "Repositories", href: "/repositories" },
{ label: match.params.name, href: `/repositories/${match.params.name}` },
{ label: match.params.snapshotId, href: `/repositories/${match.params.name}/${match.params.snapshotId}` },
{ label: "Restore" },
],
};
export function meta({ params }: Route.MetaArgs) {
return [
{ title: `Zerobyte - Restore Snapshot ${params.snapshotId}` },
{
name: "description",
content: "Restore files from a backup snapshot.",
},
];
}
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const snapshot = await getSnapshotDetails({
path: { name: params.name, snapshotId: params.snapshotId },
});
if (snapshot.data) return { snapshot: snapshot.data, name: params.name, snapshotId: params.snapshotId };
return redirect("/repositories");
};
export default function RestoreSnapshotPage({ loaderData }: Route.ComponentProps) {
const { snapshot, name, snapshotId } = loaderData;
return (
<RestoreForm
snapshot={snapshot}
repositoryName={name}
snapshotId={snapshotId}
returnPath={`/repositories/${name}/${snapshotId}`}
/>
);
}

View File

@@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query";
import { redirect, useParams } from "react-router";
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { RestoreSnapshotDialog } from "../components/restore-snapshot-dialog";
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
import { getSnapshotDetails } from "~/client/api-client";
import type { Route } from "./+types/snapshot-details";
@@ -63,7 +62,6 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
<h1 className="text-2xl font-bold">{name}</h1>
<p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p>
</div>
<RestoreSnapshotDialog name={name} snapshotId={snapshotId} />
</div>
<SnapshotFileBrowser repositoryName={name} snapshot={loaderData} />

View File

@@ -1,63 +1,169 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { useNavigate } from "react-router";
import { Card } from "~/client/components/ui/card";
import { Button } from "~/client/components/ui/button";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import type { Repository } from "~/client/lib/types";
import { slugify } from "~/client/lib/utils";
import { updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
import type { UpdateRepositoryResponse } from "~/client/api-client/types.gen";
import type { CompressionMode } from "~/schemas/restic";
type Props = {
repository: Repository;
};
export const RepositoryInfoTabContent = ({ repository }: Props) => {
return (
<Card className="p-6">
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">Repository Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium text-muted-foreground">Name</div>
<p className="mt-1 text-sm">{repository.name}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Backend</div>
<p className="mt-1 text-sm">{repository.type}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Compression Mode</div>
<p className="mt-1 text-sm">{repository.compressionMode || "off"}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Status</div>
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Created At</div>
<p className="mt-1 text-sm">{new Date(repository.createdAt * 1000).toLocaleString()}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
<p className="mt-1 text-sm">
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
</p>
</div>
</div>
</div>
{repository.lastError && (
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
</div>
const navigate = useNavigate();
const [name, setName] = useState(repository.name);
const [compressionMode, setCompressionMode] = useState<CompressionMode>(
(repository.compressionMode as CompressionMode) || "off",
);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
<p className="text-sm text-red-500">{repository.lastError}</p>
const updateMutation = useMutation({
...updateRepositoryMutation(),
onSuccess: (data: UpdateRepositoryResponse) => {
toast.success("Repository updated successfully");
setShowConfirmDialog(false);
if (data.name !== repository.name) {
navigate(`/repositories/${data.name}`);
}
},
onError: (error) => {
toast.error("Failed to update repository", { description: error.message, richColors: true });
setShowConfirmDialog(false);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setShowConfirmDialog(true);
};
const confirmUpdate = () => {
updateMutation.mutate({
path: { name: repository.name },
body: { name, compressionMode },
});
};
const hasChanges =
name !== repository.name || compressionMode !== ((repository.compressionMode as CompressionMode) || "off");
return (
<>
<Card className="p-6">
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">Repository Settings</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(slugify(e.target.value))}
placeholder="Repository name"
maxLength={32}
minLength={2}
/>
<p className="text-sm text-muted-foreground">Unique identifier for the repository.</p>
</div>
<div className="space-y-2">
<Label htmlFor="compressionMode">Compression Mode</Label>
<Select value={compressionMode} onValueChange={(val) => setCompressionMode(val as CompressionMode)}>
<SelectTrigger id="compressionMode">
<SelectValue placeholder="Select compression mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="off">Off</SelectItem>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="max">Max</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">Compression level for new data.</p>
</div>
</div>
</div>
)}
<div>
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
<div className="bg-muted/50 rounded-md p-4">
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
<div>
<h3 className="text-lg font-semibold mb-4">Repository Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium text-muted-foreground">Backend</div>
<p className="mt-1 text-sm">{repository.type}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Status</div>
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Created At</div>
<p className="mt-1 text-sm">{new Date(repository.createdAt * 1000).toLocaleString()}</p>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Last Checked</div>
<p className="mt-1 text-sm">
{repository.lastChecked ? new Date(repository.lastChecked).toLocaleString() : "Never"}
</p>
</div>
</div>
</div>
</div>
</div>
</Card>
{repository.lastError && (
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
</div>
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
<p className="text-sm text-red-500">{repository.lastError}</p>
</div>
</div>
)}
<div>
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
<div className="bg-muted/50 rounded-md p-4">
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
</div>
</div>
<div className="flex justify-end pt-4 border-t">
<Button type="submit" disabled={!hasChanges || updateMutation.isPending} loading={updateMutation.isPending}>
Save Changes
</Button>
</div>
</form>
</Card>
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Update Repository</AlertDialogTitle>
<AlertDialogDescription>Are you sure you want to update the repository settings?</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmUpdate}>Update</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};

View File

@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { Database } from "lucide-react";
import { useState } from "react";
import { listSnapshotsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { listBackupSchedulesOptions, listSnapshotsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { SnapshotsTable } from "~/client/components/snapshots-table";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
@@ -18,11 +18,13 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
const { data, isFetching, failureReason } = useQuery({
...listSnapshotsOptions({ path: { name: repository.name } }),
refetchInterval: 10000,
refetchOnWindowFocus: true,
initialData: [],
});
const schedules = useQuery({
...listBackupSchedulesOptions(),
});
const filteredSnapshots = data.filter((snapshot: Snapshot) => {
if (!searchQuery) return true;
const searchLower = searchQuery.toLowerCase();
@@ -134,7 +136,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
</TableBody>
</Table>
) : (
<SnapshotsTable snapshots={filteredSnapshots} repositoryName={repository.name} />
<SnapshotsTable snapshots={filteredSnapshots} repositoryName={repository.name} backups={schedules.data ?? []} />
)}
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-between border-t">
<span>

View File

@@ -71,8 +71,6 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
const { data } = useQuery({
...getVolumeOptions({ path: { name: name ?? "" } }),
initialData: loaderData,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
const { capabilities } = useSystemInfo();
@@ -142,7 +140,7 @@ export default function VolumeDetails({ loaderData }: Route.ComponentProps) {
&nbsp;
{volume.status[0].toUpperCase() + volume.status.slice(1)}
</span>
<VolumeIcon size={14} backend={volume?.config.backend} />
<VolumeIcon backend={volume?.config.backend} />
</div>
<div className="flex gap-4">
<Button

View File

@@ -61,8 +61,6 @@ export default function Volumes({ loaderData }: Route.ComponentProps) {
const { data } = useQuery({
...listVolumesOptions(),
initialData: loaderData,
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
const filteredVolumes =

View File

@@ -16,17 +16,17 @@ export const DockerTabContent = ({ volume }: Props) => {
services: {
nginx: {
image: "nginx:latest",
volumes: [`im-${volume.name}:/path/in/container`],
volumes: [`zb-${volume.shortId}:/path/in/container`],
},
},
volumes: {
[`im-${volume.name}`]: {
[`zb-${volume.shortId}`]: {
external: true,
},
},
});
const dockerRunCommand = `docker run -v im-${volume.name}:/path/in/container nginx:latest`;
const dockerRunCommand = `docker run -v zb-${volume.shortId}:/path/in/container nginx:latest`;
const {
data: containersData,

View File

@@ -1,5 +1,6 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { CreateVolumeForm, type FormValues } from "~/client/components/create-volume-form";
import {
@@ -17,6 +18,7 @@ import type { StatFs, Volume } from "~/client/lib/types";
import { HealthchecksCard } from "../components/healthchecks-card";
import { StorageChart } from "../components/storage-chart";
import { updateVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
import type { UpdateVolumeResponse } from "~/client/api-client/types.gen";
type Props = {
volume: Volume;
@@ -24,12 +26,18 @@ type Props = {
};
export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
const navigate = useNavigate();
const updateMutation = useMutation({
...updateVolumeMutation(),
onSuccess: (_) => {
onSuccess: (data: UpdateVolumeResponse) => {
toast.success("Volume updated successfully");
setOpen(false);
setPendingValues(null);
if (data.name !== volume.name) {
navigate(`/volumes/${data.name}`);
}
},
onError: (error) => {
toast.error("Failed to update volume", { description: error.message });
@@ -50,7 +58,7 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
if (pendingValues) {
updateMutation.mutate({
path: { name: volume.name },
body: { config: pendingValues },
body: { name: pendingValues.name, config: pendingValues },
});
}
};

View File

@@ -0,0 +1,7 @@
ALTER TABLE `repositories_table` ADD `short_id` text;--> statement-breakpoint
UPDATE `repositories_table` SET `short_id` = lower(hex(randomblob(3))) WHERE `short_id` IS NULL;--> statement-breakpoint
CREATE UNIQUE INDEX `repositories_table_short_id_unique` ON `repositories_table` (`short_id`);--> statement-breakpoint
ALTER TABLE `volumes_table` ADD `short_id` text;--> statement-breakpoint
UPDATE `volumes_table` SET `short_id` = lower(hex(randomblob(3))) WHERE `short_id` IS NULL;--> statement-breakpoint
CREATE UNIQUE INDEX `volumes_table_short_id_unique` ON `volumes_table` (`short_id`);

View File

@@ -0,0 +1,6 @@
CREATE TABLE `app_metadata` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);

View File

@@ -0,0 +1,40 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_repositories_table` (
`id` text PRIMARY KEY NOT NULL,
`short_id` text,
`name` text NOT NULL,
`type` text NOT NULL,
`config` text NOT NULL,
`compression_mode` text DEFAULT 'auto',
`status` text DEFAULT 'unknown',
`last_checked` integer,
`last_error` text,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_repositories_table`("id", "short_id", "name", "type", "config", "compression_mode", "status", "last_checked", "last_error", "created_at", "updated_at") SELECT "id", "short_id", "name", "type", "config", "compression_mode", "status", "last_checked", "last_error", "created_at", "updated_at" FROM `repositories_table`;--> statement-breakpoint
DROP TABLE `repositories_table`;--> statement-breakpoint
ALTER TABLE `__new_repositories_table` RENAME TO `repositories_table`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE UNIQUE INDEX `repositories_table_short_id_unique` ON `repositories_table` (`short_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `repositories_table_name_unique` ON `repositories_table` (`name`);--> statement-breakpoint
CREATE TABLE `__new_volumes_table` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`short_id` text,
`name` text NOT NULL,
`type` text NOT NULL,
`status` text DEFAULT 'unmounted' NOT NULL,
`last_error` text,
`last_health_check` integer DEFAULT (unixepoch()) NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
`config` text NOT NULL,
`auto_remount` integer DEFAULT true NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_volumes_table`("id", "short_id", "name", "type", "status", "last_error", "last_health_check", "created_at", "updated_at", "config", "auto_remount") SELECT "id", "short_id", "name", "type", "status", "last_error", "last_health_check", "created_at", "updated_at", "config", "auto_remount" FROM `volumes_table`;--> statement-breakpoint
DROP TABLE `volumes_table`;--> statement-breakpoint
ALTER TABLE `__new_volumes_table` RENAME TO `volumes_table`;--> statement-breakpoint
CREATE UNIQUE INDEX `volumes_table_short_id_unique` ON `volumes_table` (`short_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `volumes_table_name_unique` ON `volumes_table` (`name`);

View File

@@ -0,0 +1,40 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_repositories_table` (
`id` text PRIMARY KEY NOT NULL,
`short_id` text NOT NULL,
`name` text NOT NULL,
`type` text NOT NULL,
`config` text NOT NULL,
`compression_mode` text DEFAULT 'auto',
`status` text DEFAULT 'unknown',
`last_checked` integer,
`last_error` text,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_repositories_table`("id", "short_id", "name", "type", "config", "compression_mode", "status", "last_checked", "last_error", "created_at", "updated_at") SELECT "id", "short_id", "name", "type", "config", "compression_mode", "status", "last_checked", "last_error", "created_at", "updated_at" FROM `repositories_table`;--> statement-breakpoint
DROP TABLE `repositories_table`;--> statement-breakpoint
ALTER TABLE `__new_repositories_table` RENAME TO `repositories_table`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE UNIQUE INDEX `repositories_table_short_id_unique` ON `repositories_table` (`short_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `repositories_table_name_unique` ON `repositories_table` (`name`);--> statement-breakpoint
CREATE TABLE `__new_volumes_table` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`short_id` text NOT NULL,
`name` text NOT NULL,
`type` text NOT NULL,
`status` text DEFAULT 'unmounted' NOT NULL,
`last_error` text,
`last_health_check` integer DEFAULT (unixepoch()) NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
`config` text NOT NULL,
`auto_remount` integer DEFAULT true NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_volumes_table`("id", "short_id", "name", "type", "status", "last_error", "last_health_check", "created_at", "updated_at", "config", "auto_remount") SELECT "id", "short_id", "name", "type", "status", "last_error", "last_health_check", "created_at", "updated_at", "config", "auto_remount" FROM `volumes_table`;--> statement-breakpoint
DROP TABLE `volumes_table`;--> statement-breakpoint
ALTER TABLE `__new_volumes_table` RENAME TO `volumes_table`;--> statement-breakpoint
CREATE UNIQUE INDEX `volumes_table_short_id_unique` ON `volumes_table` (`short_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `volumes_table_name_unique` ON `volumes_table` (`name`);

View File

@@ -0,0 +1,47 @@
-- Convert timestamps from seconds to milliseconds (multiply by 1000)
-- Only convert values that appear to be in seconds (less than year 2100 threshold)
UPDATE `volumes_table` SET `last_health_check` = `last_health_check` * 1000 WHERE `last_health_check` < 4102444800;
--> statement-breakpoint
UPDATE `volumes_table` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
--> statement-breakpoint
UPDATE `volumes_table` SET `updated_at` = `updated_at` * 1000 WHERE `updated_at` < 4102444800;
--> statement-breakpoint
UPDATE `users_table` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
--> statement-breakpoint
UPDATE `users_table` SET `updated_at` = `updated_at` * 1000 WHERE `updated_at` < 4102444800;
--> statement-breakpoint
UPDATE `sessions_table` SET `expires_at` = `expires_at` * 1000 WHERE `expires_at` < 4102444800;
--> statement-breakpoint
UPDATE `sessions_table` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
--> statement-breakpoint
UPDATE `repositories_table` SET `last_checked` = `last_checked` * 1000 WHERE `last_checked` < 4102444800;
--> statement-breakpoint
UPDATE `repositories_table` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
--> statement-breakpoint
UPDATE `repositories_table` SET `updated_at` = `updated_at` * 1000 WHERE `updated_at` < 4102444800;
--> statement-breakpoint
UPDATE `backup_schedules_table` SET `last_backup_at` = `last_backup_at` * 1000 WHERE `last_backup_at` < 4102444800;
--> statement-breakpoint
UPDATE `backup_schedules_table` SET `next_backup_at` = `next_backup_at` * 1000 WHERE `next_backup_at` < 4102444800;
--> statement-breakpoint
UPDATE `backup_schedules_table` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
--> statement-breakpoint
UPDATE `backup_schedules_table` SET `updated_at` = `updated_at` * 1000 WHERE `updated_at` < 4102444800;
--> statement-breakpoint
UPDATE `notification_destinations_table` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
--> statement-breakpoint
UPDATE `notification_destinations_table` SET `updated_at` = `updated_at` * 1000 WHERE `updated_at` < 4102444800;
--> statement-breakpoint
UPDATE `backup_schedule_notifications_table` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
--> statement-breakpoint
UPDATE `app_metadata` SET `created_at` = `created_at` * 1000 WHERE `created_at` < 4102444800;
--> statement-breakpoint
UPDATE `app_metadata` SET `updated_at` = `updated_at` * 1000 WHERE `updated_at` < 4102444800;

View File

@@ -0,0 +1 @@
UPDATE `repositories_table` SET `compression_mode` = 'auto' WHERE `compression_mode` IN ('fastest', 'better');

View File

@@ -0,0 +1,139 @@
CREATE TABLE `backup_schedule_mirrors_table` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`schedule_id` integer NOT NULL,
`repository_id` text NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`last_copy_at` integer,
`last_copy_status` text,
`last_copy_error` text,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
FOREIGN KEY (`schedule_id`) REFERENCES `backup_schedules_table`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`repository_id`) REFERENCES `repositories_table`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `backup_schedule_mirrors_table_schedule_id_repository_id_unique` ON `backup_schedule_mirrors_table` (`schedule_id`,`repository_id`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_app_metadata` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_app_metadata`("key", "value", "created_at", "updated_at") SELECT "key", "value", "created_at", "updated_at" FROM `app_metadata`;--> statement-breakpoint
DROP TABLE `app_metadata`;--> statement-breakpoint
ALTER TABLE `__new_app_metadata` RENAME TO `app_metadata`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE TABLE `__new_backup_schedule_notifications_table` (
`schedule_id` integer NOT NULL,
`destination_id` integer NOT NULL,
`notify_on_start` integer DEFAULT false NOT NULL,
`notify_on_success` integer DEFAULT false NOT NULL,
`notify_on_failure` integer DEFAULT true NOT NULL,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
PRIMARY KEY(`schedule_id`, `destination_id`),
FOREIGN KEY (`schedule_id`) REFERENCES `backup_schedules_table`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`destination_id`) REFERENCES `notification_destinations_table`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_backup_schedule_notifications_table`("schedule_id", "destination_id", "notify_on_start", "notify_on_success", "notify_on_failure", "created_at") SELECT "schedule_id", "destination_id", "notify_on_start", "notify_on_success", "notify_on_failure", "created_at" FROM `backup_schedule_notifications_table`;--> statement-breakpoint
DROP TABLE `backup_schedule_notifications_table`;--> statement-breakpoint
ALTER TABLE `__new_backup_schedule_notifications_table` RENAME TO `backup_schedule_notifications_table`;--> statement-breakpoint
CREATE TABLE `__new_backup_schedules_table` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`volume_id` integer NOT NULL,
`repository_id` text NOT NULL,
`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,
FOREIGN KEY (`volume_id`) REFERENCES `volumes_table`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`repository_id`) REFERENCES `repositories_table`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_backup_schedules_table`("id", "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", "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
CREATE TABLE `__new_notification_destinations_table` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`type` text NOT NULL,
`config` text NOT NULL,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_notification_destinations_table`("id", "name", "enabled", "type", "config", "created_at", "updated_at") SELECT "id", "name", "enabled", "type", "config", "created_at", "updated_at" FROM `notification_destinations_table`;--> statement-breakpoint
DROP TABLE `notification_destinations_table`;--> statement-breakpoint
ALTER TABLE `__new_notification_destinations_table` RENAME TO `notification_destinations_table`;--> statement-breakpoint
CREATE UNIQUE INDEX `notification_destinations_table_name_unique` ON `notification_destinations_table` (`name`);--> statement-breakpoint
CREATE TABLE `__new_repositories_table` (
`id` text PRIMARY KEY NOT NULL,
`short_id` text NOT NULL,
`name` text NOT NULL,
`type` text NOT NULL,
`config` text NOT NULL,
`compression_mode` text DEFAULT 'auto',
`status` text DEFAULT 'unknown',
`last_checked` integer,
`last_error` text,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_repositories_table`("id", "short_id", "name", "type", "config", "compression_mode", "status", "last_checked", "last_error", "created_at", "updated_at") SELECT "id", "short_id", "name", "type", "config", "compression_mode", "status", "last_checked", "last_error", "created_at", "updated_at" FROM `repositories_table`;--> statement-breakpoint
DROP TABLE `repositories_table`;--> statement-breakpoint
ALTER TABLE `__new_repositories_table` RENAME TO `repositories_table`;--> statement-breakpoint
CREATE UNIQUE INDEX `repositories_table_short_id_unique` ON `repositories_table` (`short_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `repositories_table_name_unique` ON `repositories_table` (`name`);--> statement-breakpoint
CREATE TABLE `__new_sessions_table` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users_table`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_sessions_table`("id", "user_id", "expires_at", "created_at") SELECT "id", "user_id", "expires_at", "created_at" FROM `sessions_table`;--> statement-breakpoint
DROP TABLE `sessions_table`;--> statement-breakpoint
ALTER TABLE `__new_sessions_table` RENAME TO `sessions_table`;--> statement-breakpoint
CREATE TABLE `__new_users_table` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`username` text NOT NULL,
`password_hash` text NOT NULL,
`has_downloaded_restic_password` integer DEFAULT false NOT NULL,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_users_table`("id", "username", "password_hash", "has_downloaded_restic_password", "created_at", "updated_at") SELECT "id", "username", "password_hash", "has_downloaded_restic_password", "created_at", "updated_at" FROM `users_table`;--> statement-breakpoint
DROP TABLE `users_table`;--> statement-breakpoint
ALTER TABLE `__new_users_table` RENAME TO `users_table`;--> statement-breakpoint
CREATE UNIQUE INDEX `users_table_username_unique` ON `users_table` (`username`);--> statement-breakpoint
CREATE TABLE `__new_volumes_table` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`short_id` text NOT NULL,
`name` text NOT NULL,
`type` text NOT NULL,
`status` text DEFAULT 'unmounted' NOT NULL,
`last_error` text,
`last_health_check` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`config` text NOT NULL,
`auto_remount` integer DEFAULT true NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_volumes_table`("id", "short_id", "name", "type", "status", "last_error", "last_health_check", "created_at", "updated_at", "config", "auto_remount") SELECT "id", "short_id", "name", "type", "status", "last_error", "last_health_check", "created_at", "updated_at", "config", "auto_remount" FROM `volumes_table`;--> statement-breakpoint
DROP TABLE `volumes_table`;--> statement-breakpoint
ALTER TABLE `__new_volumes_table` RENAME TO `volumes_table`;--> statement-breakpoint
CREATE UNIQUE INDEX `volumes_table_short_id_unique` ON `volumes_table` (`short_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `volumes_table_name_unique` ON `volumes_table` (`name`);

View File

@@ -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`);

View File

@@ -0,0 +1 @@
ALTER TABLE `backup_schedules_table` ADD `exclude_if_present` text DEFAULT '[]';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,613 @@
{
"version": "6",
"dialect": "sqlite",
"id": "bbca8451-3894-4556-9824-c309b5105628",
"prevId": "67552135-fa49-478f-9333-107d3dbd7610",
"tables": {
"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())"
}
},
"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
},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"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())"
}
},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"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())"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"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": {}
}
}

View File

@@ -0,0 +1,653 @@
{
"version": "6",
"dialect": "sqlite",
"id": "794bddf6-1978-46e4-88d5-051d76cfa2f6",
"prevId": "bbca8451-3894-4556-9824-c309b5105628",
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"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())"
}
},
"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
},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"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())"
}
},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"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())"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"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": {}
}
}

View File

@@ -0,0 +1,653 @@
{
"version": "6",
"dialect": "sqlite",
"id": "05309ea5-8ef2-4d63-b3d2-9842b2b4111b",
"prevId": "794bddf6-1978-46e4-88d5-051d76cfa2f6",
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"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())"
}
},
"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
},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"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": false,
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"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())"
}
},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"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": false,
"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())"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"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": {}
}
}

View File

@@ -0,0 +1,653 @@
{
"version": "6",
"dialect": "sqlite",
"id": "e52fe10a-3f36-4b21-abef-c15990d28363",
"prevId": "05309ea5-8ef2-4d63-b3d2-9842b2b4111b",
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"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())"
}
},
"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
},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"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())"
}
},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"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())"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"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": {}
}
}

View File

@@ -0,0 +1,653 @@
{
"id": "e50ff0fb-4111-4d20-b550-9407ee397517",
"prevId": "e52fe10a-3f36-4b21-abef-c15990d28363",
"version": "6",
"dialect": "sqlite",
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"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())"
}
},
"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",
"columnsFrom": ["schedule_id"],
"tableTo": "backup_schedules_table",
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"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",
"columnsFrom": ["destination_id"],
"tableTo": "notification_destinations_table",
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
},
"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
},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"backup_schedules_table_volume_id_volumes_table_id_fk": {
"name": "backup_schedules_table_volume_id_volumes_table_id_fk",
"tableFrom": "backup_schedules_table",
"columnsFrom": ["volume_id"],
"tableTo": "volumes_table",
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"backup_schedules_table_repository_id_repositories_table_id_fk": {
"name": "backup_schedules_table_repository_id_repositories_table_id_fk",
"tableFrom": "backup_schedules_table",
"columnsFrom": ["repository_id"],
"tableTo": "repositories_table",
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"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())"
}
},
"indexes": {},
"foreignKeys": {
"sessions_table_user_id_users_table_id_fk": {
"name": "sessions_table_user_id_users_table_id_fk",
"tableFrom": "sessions_table",
"columnsFrom": ["user_id"],
"tableTo": "users_table",
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"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())"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"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": {
"columns": {},
"schemas": {},
"tables": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,653 @@
{
"id": "d0bfd316-b8f5-459b-ab17-0ce679479321",
"prevId": "e50ff0fb-4111-4d20-b550-9407ee397517",
"version": "6",
"dialect": "sqlite",
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"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())"
}
},
"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",
"columnsFrom": ["schedule_id"],
"tableTo": "backup_schedules_table",
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"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",
"columnsFrom": ["destination_id"],
"tableTo": "notification_destinations_table",
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
},
"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
},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"backup_schedules_table_volume_id_volumes_table_id_fk": {
"name": "backup_schedules_table_volume_id_volumes_table_id_fk",
"tableFrom": "backup_schedules_table",
"columnsFrom": ["volume_id"],
"tableTo": "volumes_table",
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"backup_schedules_table_repository_id_repositories_table_id_fk": {
"name": "backup_schedules_table_repository_id_repositories_table_id_fk",
"tableFrom": "backup_schedules_table",
"columnsFrom": ["repository_id"],
"tableTo": "repositories_table",
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"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())"
}
},
"indexes": {},
"foreignKeys": {
"sessions_table_user_id_users_table_id_fk": {
"name": "sessions_table_user_id_users_table_id_fk",
"tableFrom": "sessions_table",
"columnsFrom": ["user_id"],
"tableTo": "users_table",
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
},
"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())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"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())"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"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": {
"columns": {},
"schemas": {},
"tables": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,792 @@
{
"version": "6",
"dialect": "sqlite",
"id": "d5a60aea-4490-423e-8725-6ace87a76c9b",
"prevId": "d0bfd316-b8f5-459b-ab17-0ce679479321",
"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
},
"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": {},
"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": {}
}
}

View File

@@ -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": {}
}
}

View File

@@ -0,0 +1,815 @@
{
"version": "6",
"dialect": "sqlite",
"id": "729d3ce9-b4b9-41f6-a270-d74c96510238",
"prevId": "b5b3acff-51d7-45ae-b9d2-4b07a6286fc3",
"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": "'[]'"
},
"exclude_if_present": {
"name": "exclude_if_present",
"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": {}
}
}

View File

@@ -85,6 +85,69 @@
"when": 1763644043601,
"tag": "0011_familiar_stone_men",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1764100562084,
"tag": "0012_add_short_ids",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1764182159797,
"tag": "0013_elite_sprite",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1764182405089,
"tag": "0014_wild_echo",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1764182465287,
"tag": "0015_jazzy_sersi",
"breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1764194697035,
"tag": "0016_fix-timestamps-to-ms",
"breakpoints": true
},
{
"idx": 17,
"version": "6",
"when": 1764357897219,
"tag": "0017_fix-compression-modes",
"breakpoints": true
},
{
"idx": 18,
"version": "6",
"when": 1764794371040,
"tag": "0018_breezy_invaders",
"breakpoints": true
},
{
"idx": 19,
"version": "6",
"when": 1764839917446,
"tag": "0019_secret_nomad",
"breakpoints": true
},
{
"idx": 20,
"version": "6",
"when": 1764847918249,
"tag": "0020_even_dexter_bennett",
"breakpoints": true
}
]
}

View File

@@ -12,10 +12,12 @@ export default [
route("backups", "./client/modules/backups/routes/backups.tsx"),
route("backups/create", "./client/modules/backups/routes/create-backup.tsx"),
route("backups/:id", "./client/modules/backups/routes/backup-details.tsx"),
route("backups/:id/:snapshotId/restore", "./client/modules/backups/routes/restore-snapshot.tsx"),
route("repositories", "./client/modules/repositories/routes/repositories.tsx"),
route("repositories/create", "./client/modules/repositories/routes/create-repository.tsx"),
route("repositories/:name", "./client/modules/repositories/routes/repository-details.tsx"),
route("repositories/:name/:snapshotId", "./client/modules/repositories/routes/snapshot-details.tsx"),
route("repositories/:name/:snapshotId/restore", "./client/modules/repositories/routes/restore-snapshot.tsx"),
route("notifications", "./client/modules/notifications/routes/notifications.tsx"),
route("notifications/create", "./client/modules/notifications/routes/create-notification.tsx"),
route("notifications/:id", "./client/modules/notifications/routes/notification-details.tsx"),

View File

@@ -7,6 +7,7 @@ export const NOTIFICATION_TYPES = {
gotify: "gotify",
ntfy: "ntfy",
pushover: "pushover",
telegram: "telegram",
custom: "custom",
} as const;
@@ -36,12 +37,14 @@ export const discordNotificationConfigSchema = type({
webhookUrl: "string",
username: "string?",
avatarUrl: "string?",
threadId: "string?",
});
export const gotifyNotificationConfigSchema = type({
type: "'gotify'",
serverUrl: "string",
token: "string",
path: "string?",
priority: "0 <= number <= 10",
});
@@ -49,8 +52,9 @@ export const ntfyNotificationConfigSchema = type({
type: "'ntfy'",
serverUrl: "string?",
topic: "string",
token: "string?",
priority: "'max' | 'high' | 'default' | 'low' | 'min'",
username: "string?",
password: "string?",
});
export const pushoverNotificationConfigSchema = type({
@@ -61,6 +65,12 @@ export const pushoverNotificationConfigSchema = type({
priority: "-1 | 0 | 1",
});
export const telegramNotificationConfigSchema = type({
type: "'telegram'",
botToken: "string",
chatId: "string",
});
export const customNotificationConfigSchema = type({
type: "'custom'",
shoutrrrUrl: "string",
@@ -72,6 +82,7 @@ export const notificationConfigSchema = emailNotificationConfigSchema
.or(gotifyNotificationConfigSchema)
.or(ntfyNotificationConfigSchema)
.or(pushoverNotificationConfigSchema)
.or(telegramNotificationConfigSchema)
.or(customNotificationConfigSchema);
export type NotificationConfig = typeof notificationConfigSchema.infer;
@@ -80,6 +91,7 @@ export const NOTIFICATION_EVENTS = {
start: "start",
success: "success",
failure: "failure",
warning: "warning",
} as const;
export type NotificationEvent = keyof typeof NOTIFICATION_EVENTS;

View File

@@ -93,8 +93,6 @@ export type RepositoryConfig = typeof repositoryConfigSchema.infer;
export const COMPRESSION_MODES = {
off: "off",
auto: "auto",
fastest: "fastest",
better: "better",
max: "max",
} as const;
@@ -107,3 +105,12 @@ export const REPOSITORY_STATUS = {
} as const;
export type RepositoryStatus = keyof typeof REPOSITORY_STATUS;
export const OVERWRITE_MODES = {
always: "always",
ifChanged: "if-changed",
ifNewer: "if-newer",
never: "never",
} as const;
export type OverwriteMode = (typeof OVERWRITE_MODES)[keyof typeof OVERWRITE_MODES];

View File

@@ -33,6 +33,18 @@ async function detectCapabilities(): Promise<SystemCapabilities> {
};
}
export const parseDockerHost = (dockerHost?: string) => {
const match = dockerHost?.match(/^(ssh|http|https):\/\/([^:]+)(?::(\d+))?$/);
if (match) {
const protocol = match[1] as "ssh" | "http" | "https";
const host = match[2];
const port = match[3] ? parseInt(match[3], 10) : undefined;
return { protocol, host, port };
}
return {};
};
/**
* Checks if Docker is available by:
* 1. Checking if /var/run/docker.sock exists and is accessible
@@ -40,9 +52,7 @@ async function detectCapabilities(): Promise<SystemCapabilities> {
*/
async function detectDocker(): Promise<boolean> {
try {
await fs.access("/var/run/docker.sock");
const docker = new Docker();
const docker = new Docker(parseDockerHost(process.env.DOCKER_HOST));
await docker.ping();
logger.info("Docker capability: enabled");

View File

@@ -2,12 +2,10 @@ import { type } from "arktype";
import "dotenv/config";
const envSchema = type({
NODE_ENV: type.enumerated("development", "production", "test").default("development"),
SESSION_SECRET: "string?",
NODE_ENV: type.enumerated("development", "production", "test").default("production"),
}).pipe((s) => ({
__prod__: s.NODE_ENV === "production",
environment: s.NODE_ENV,
sessionSecret: s.SESSION_SECRET || "change-me-in-production-please",
}));
const parseConfig = (env: unknown) => {

View File

@@ -4,3 +4,5 @@ export const REPOSITORY_BASE = "/var/lib/zerobyte/repositories";
export const DATABASE_URL = "/var/lib/zerobyte/data/ironmount.db";
export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass";
export const SOCKET_PATH = "/run/docker/plugins/zerobyte.sock";
export const REQUIRED_MIGRATIONS = ["v0.14.0"];

View File

@@ -22,7 +22,15 @@ interface ServerEvents {
scheduleId: number;
volumeName: string;
repositoryName: string;
status: "success" | "error" | "stopped";
status: "success" | "error" | "stopped" | "warning";
}) => void;
"mirror:started": (data: { scheduleId: number; repositoryId: string; repositoryName: string }) => void;
"mirror:completed": (data: {
scheduleId: number;
repositoryId: string;
repositoryName: string;
status: "success" | "error";
error?: string;
}) => void;
"volume:mounted": (data: { volumeName: string }) => void;
"volume:unmounted": (data: { volumeName: string }) => void;

View File

@@ -0,0 +1,180 @@
import { logger } from "../utils/logger";
export type LockType = "shared" | "exclusive";
interface LockHolder {
id: string;
operation: string;
acquiredAt: number;
}
interface RepositoryLockState {
sharedHolders: Map<string, LockHolder>;
exclusiveHolder: LockHolder | null;
waitQueue: Array<{
type: LockType;
operation: string;
resolve: (lockId: string) => void;
}>;
}
class RepositoryMutex {
private locks = new Map<string, RepositoryLockState>();
private lockIdCounter = 0;
private getOrCreateState(repositoryId: string): RepositoryLockState {
let state = this.locks.get(repositoryId);
if (!state) {
state = {
sharedHolders: new Map(),
exclusiveHolder: null,
waitQueue: [],
};
this.locks.set(repositoryId, state);
}
return state;
}
private generateLockId(): string {
return `lock_${++this.lockIdCounter}_${Date.now()}`;
}
private cleanupStateIfEmpty(repositoryId: string): void {
const state = this.locks.get(repositoryId);
if (state && state.sharedHolders.size === 0 && !state.exclusiveHolder && state.waitQueue.length === 0) {
this.locks.delete(repositoryId);
}
}
async acquireShared(repositoryId: string, operation: string): Promise<() => void> {
const state = this.getOrCreateState(repositoryId);
if (!state.exclusiveHolder) {
const lockId = this.generateLockId();
state.sharedHolders.set(lockId, {
id: lockId,
operation,
acquiredAt: Date.now(),
});
return () => this.releaseShared(repositoryId, lockId);
}
logger.debug(
`[Mutex] Waiting for shared lock on repo ${repositoryId}: ${operation} (exclusive held by: ${state.exclusiveHolder.operation})`,
);
const lockId = await new Promise<string>((resolve) => {
state.waitQueue.push({ type: "shared", operation, resolve });
});
return () => this.releaseShared(repositoryId, lockId);
}
async acquireExclusive(repositoryId: string, operation: string): Promise<() => void> {
const state = this.getOrCreateState(repositoryId);
if (!state.exclusiveHolder && state.sharedHolders.size === 0 && state.waitQueue.length === 0) {
const lockId = this.generateLockId();
state.exclusiveHolder = {
id: lockId,
operation,
acquiredAt: Date.now(),
};
return () => this.releaseExclusive(repositoryId, lockId);
}
logger.debug(
`[Mutex] Waiting for exclusive lock on repo ${repositoryId}: ${operation} (shared: ${state.sharedHolders.size}, exclusive: ${state.exclusiveHolder ? "yes" : "no"}, queue: ${state.waitQueue.length})`,
);
const lockId = await new Promise<string>((resolve) => {
state.waitQueue.push({ type: "exclusive", operation, resolve });
});
logger.debug(`[Mutex] Acquired exclusive lock for repo ${repositoryId}: ${operation} (${lockId})`);
return () => this.releaseExclusive(repositoryId, lockId);
}
private releaseShared(repositoryId: string, lockId: string): void {
const state = this.locks.get(repositoryId);
if (!state) {
return;
}
const holder = state.sharedHolders.get(lockId);
if (!holder) {
return;
}
state.sharedHolders.delete(lockId);
const duration = Date.now() - holder.acquiredAt;
logger.debug(`[Mutex] Released shared lock for repo ${repositoryId}: ${holder.operation} (held for ${duration}ms)`);
this.processWaitQueue(repositoryId);
this.cleanupStateIfEmpty(repositoryId);
}
private releaseExclusive(repositoryId: string, lockId: string): void {
const state = this.locks.get(repositoryId);
if (!state) {
return;
}
if (!state.exclusiveHolder || state.exclusiveHolder.id !== lockId) {
return;
}
const duration = Date.now() - state.exclusiveHolder.acquiredAt;
logger.debug(
`[Mutex] Released exclusive lock for repo ${repositoryId}: ${state.exclusiveHolder.operation} (held for ${duration}ms)`,
);
state.exclusiveHolder = null;
this.processWaitQueue(repositoryId);
this.cleanupStateIfEmpty(repositoryId);
}
private processWaitQueue(repositoryId: string): void {
const state = this.locks.get(repositoryId);
if (!state || state.waitQueue.length === 0) {
return;
}
if (state.exclusiveHolder) {
return;
}
const firstWaiter = state.waitQueue[0];
if (firstWaiter.type === "exclusive") {
if (state.sharedHolders.size === 0) {
state.waitQueue.shift();
const lockId = this.generateLockId();
state.exclusiveHolder = {
id: lockId,
operation: firstWaiter.operation,
acquiredAt: Date.now(),
};
firstWaiter.resolve(lockId);
}
} else {
while (state.waitQueue.length > 0 && state.waitQueue[0].type === "shared") {
const waiter = state.waitQueue.shift();
if (!waiter) break;
const lockId = this.generateLockId();
state.sharedHolders.set(lockId, {
id: lockId,
operation: waiter.operation,
acquiredAt: Date.now(),
});
waiter.resolve(lockId);
}
}
}
isLocked(repositoryId: string): boolean {
const state = this.locks.get(repositoryId);
if (!state) return false;
return state.exclusiveHolder !== null || state.sharedHolders.size > 0;
}
}
export const repoMutex = new RepositoryMutex();

View File

@@ -6,21 +6,21 @@ import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { DATABASE_URL } from "../core/constants";
import * as schema from "./schema";
import fs from "node:fs/promises";
import { config } from "../core/config";
await fs.mkdir(path.dirname(DATABASE_URL), { recursive: true });
const sqlite = new Database(DATABASE_URL);
sqlite.run("PRAGMA foreign_keys = ON;");
export const db = drizzle({ client: sqlite, schema });
export const runDbMigrations = () => {
let migrationsFolder = path.join("/app", "assets", "migrations");
const { NODE_ENV } = process.env;
if (NODE_ENV !== "production") {
if (!config.__prod__) {
migrationsFolder = path.join("/app", "app", "drizzle");
}
migrate(db, { migrationsFolder });
sqlite.run("PRAGMA foreign_keys = ON;");
};

View File

@@ -1,5 +1,5 @@
import { relations, sql } from "drizzle-orm";
import { int, integer, sqliteTable, text, primaryKey } from "drizzle-orm/sqlite-core";
import { int, integer, sqliteTable, text, primaryKey, unique } from "drizzle-orm/sqlite-core";
import type { CompressionMode, RepositoryBackend, repositoryConfigSchema, RepositoryStatus } from "~/schemas/restic";
import type { BackendStatus, BackendType, volumeConfigSchema } from "~/schemas/volumes";
import type { NotificationType, notificationConfigSchema } from "~/schemas/notifications";
@@ -9,13 +9,14 @@ import type { NotificationType, notificationConfigSchema } from "~/schemas/notif
*/
export const volumesTable = sqliteTable("volumes_table", {
id: int().primaryKey({ autoIncrement: true }),
shortId: text("short_id").notNull().unique(),
name: text().notNull().unique(),
type: text().$type<BackendType>().notNull(),
status: text().$type<BackendStatus>().notNull().default("unmounted"),
lastError: text("last_error"),
lastHealthCheck: integer("last_health_check", { mode: "number" }).notNull().default(sql`(unixepoch())`),
createdAt: integer("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
lastHealthCheck: integer("last_health_check", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
createdAt: integer("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
updatedAt: integer("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(),
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
});
@@ -29,8 +30,8 @@ export const usersTable = sqliteTable("users_table", {
username: text().notNull().unique(),
passwordHash: text("password_hash").notNull(),
hasDownloadedResticPassword: int("has_downloaded_restic_password", { mode: "boolean" }).notNull().default(false),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
});
export type User = typeof usersTable.$inferSelect;
export const sessionsTable = sqliteTable("sessions_table", {
@@ -39,7 +40,7 @@ export const sessionsTable = sqliteTable("sessions_table", {
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
expiresAt: int("expires_at", { mode: "number" }).notNull(),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
});
export type Session = typeof sessionsTable.$inferSelect;
@@ -48,6 +49,7 @@ export type Session = typeof sessionsTable.$inferSelect;
*/
export const repositoriesTable = sqliteTable("repositories_table", {
id: text().primaryKey(),
shortId: text("short_id").notNull().unique(),
name: text().notNull().unique(),
type: text().$type<RepositoryBackend>().notNull(),
config: text("config", { mode: "json" }).$type<typeof repositoryConfigSchema.inferOut>().notNull(),
@@ -55,8 +57,8 @@ export const repositoriesTable = sqliteTable("repositories_table", {
status: text().$type<RepositoryStatus>().default("unknown"),
lastChecked: int("last_checked", { mode: "number" }),
lastError: text("last_error"),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
});
export type Repository = typeof repositoriesTable.$inferSelect;
@@ -65,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" }),
@@ -83,14 +86,16 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
keepWithinDuration?: string;
}>(),
excludePatterns: text("exclude_patterns", { mode: "json" }).$type<string[]>().default([]),
excludeIfPresent: text("exclude_if_present", { mode: "json" }).$type<string[]>().default([]),
includePatterns: text("include_patterns", { mode: "json" }).$type<string[]>().default([]),
lastBackupAt: int("last_backup_at", { mode: "number" }),
lastBackupStatus: text("last_backup_status").$type<"success" | "error" | "in_progress">(),
lastBackupStatus: text("last_backup_status").$type<"success" | "error" | "in_progress" | "warning">(),
lastBackupError: text("last_backup_error"),
nextBackupAt: int("next_backup_at", { mode: "number" }),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
});
export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, many }) => ({
volume: one(volumesTable, {
fields: [backupSchedulesTable.volumeId],
@@ -101,6 +106,7 @@ export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, m
references: [repositoriesTable.id],
}),
notifications: many(backupScheduleNotificationsTable),
mirrors: many(backupScheduleMirrorsTable),
}));
export type BackupSchedule = typeof backupSchedulesTable.$inferSelect;
@@ -113,8 +119,8 @@ export const notificationDestinationsTable = sqliteTable("notification_destinati
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
type: text().$type<NotificationType>().notNull(),
config: text("config", { mode: "json" }).$type<typeof notificationConfigSchema.inferOut>().notNull(),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
});
export const notificationDestinationRelations = relations(notificationDestinationsTable, ({ many }) => ({
schedules: many(backupScheduleNotificationsTable),
@@ -136,7 +142,7 @@ export const backupScheduleNotificationsTable = sqliteTable(
notifyOnStart: int("notify_on_start", { mode: "boolean" }).notNull().default(false),
notifyOnSuccess: int("notify_on_success", { mode: "boolean" }).notNull().default(false),
notifyOnFailure: int("notify_on_failure", { mode: "boolean" }).notNull().default(true),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
},
(table) => [primaryKey({ columns: [table.scheduleId, table.destinationId] })],
);
@@ -151,3 +157,50 @@ export const backupScheduleNotificationRelations = relations(backupScheduleNotif
}),
}));
export type BackupScheduleNotification = typeof backupScheduleNotificationsTable.$inferSelect;
/**
* Backup Schedule Mirrors Junction Table (Many-to-Many)
* Allows copying snapshots to secondary repositories after backup completes
*/
export const backupScheduleMirrorsTable = sqliteTable(
"backup_schedule_mirrors_table",
{
id: int().primaryKey({ autoIncrement: true }),
scheduleId: int("schedule_id")
.notNull()
.references(() => backupSchedulesTable.id, { onDelete: "cascade" }),
repositoryId: text("repository_id")
.notNull()
.references(() => repositoriesTable.id, { onDelete: "cascade" }),
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
lastCopyAt: int("last_copy_at", { mode: "number" }),
lastCopyStatus: text("last_copy_status").$type<"success" | "error">(),
lastCopyError: text("last_copy_error"),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
},
(table) => [unique().on(table.scheduleId, table.repositoryId)],
);
export const backupScheduleMirrorRelations = relations(backupScheduleMirrorsTable, ({ one }) => ({
schedule: one(backupSchedulesTable, {
fields: [backupScheduleMirrorsTable.scheduleId],
references: [backupSchedulesTable.id],
}),
repository: one(repositoriesTable, {
fields: [backupScheduleMirrorsTable.repositoryId],
references: [repositoriesTable.id],
}),
}));
export type BackupScheduleMirror = typeof backupScheduleMirrorsTable.$inferSelect;
/**
* App Metadata Table
* Used for storing key-value pairs like migration checkpoints
*/
export const appMetadataTable = sqliteTable("app_metadata", {
key: text().primaryKey(),
value: text().notNull(),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
});
export type AppMetadata = typeof appMetadataTable.$inferSelect;

View File

@@ -10,6 +10,7 @@ import { authController } from "./modules/auth/auth.controller";
import { requireAuth } from "./modules/auth/auth.middleware";
import { driverController } from "./modules/driver/driver.controller";
import { startup } from "./modules/lifecycle/startup";
import { migrateToShortIds } from "./modules/lifecycle/migration";
import { repositoriesController } from "./modules/repositories/repositories.controller";
import { systemController } from "./modules/system/system.controller";
import { volumeController } from "./modules/volumes/volume.controller";
@@ -19,7 +20,8 @@ import { notificationsController } from "./modules/notifications/notifications.c
import { handleServiceError } from "./utils/errors";
import { logger } from "./utils/logger";
import { shutdown } from "./modules/lifecycle/shutdown";
import { SOCKET_PATH } from "./core/constants";
import { REQUIRED_MIGRATIONS, SOCKET_PATH } from "./core/constants";
import { validateRequiredMigrations } from "./modules/lifecycle/checkpoint";
export const generalDescriptor = (app: Hono) =>
openAPIRouteHandler(app, {
@@ -68,6 +70,9 @@ app.onError((err, c) => {
runDbMigrations();
await migrateToShortIds();
await validateRequiredMigrations(REQUIRED_MIGRATIONS);
const { docker } = await getCapabilities();
if (docker) {

View File

@@ -4,6 +4,7 @@ import { logger } from "../utils/logger";
import { db } from "../db/db";
import { eq, or } from "drizzle-orm";
import { repositoriesTable } from "../db/schema";
import { repoMutex } from "../core/repository-mutex";
export class RepositoryHealthCheckJob extends Job {
async run() {
@@ -14,6 +15,11 @@ export class RepositoryHealthCheckJob extends Job {
});
for (const repository of repositories) {
if (repoMutex.isLocked(repository.id)) {
logger.debug(`Skipping health check for repository ${repository.name}: currently locked`);
continue;
}
try {
await repositoriesService.checkHealth(repository.id);
} catch (error) {

View File

@@ -3,7 +3,7 @@ import { db } from "../../db/db";
import { sessionsTable, usersTable } from "../../db/schema";
import { logger } from "../../utils/logger";
const SESSION_DURATION = 1000 * 60 * 60 * 24 * 30; // 30 days
const SESSION_DURATION = 60 * 60 * 24 * 30 * 1000; // 30 days in milliseconds
export class AuthService {
/**
@@ -30,7 +30,7 @@ export class AuthService {
logger.info(`User registered: ${username}`);
const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + SESSION_DURATION).getTime();
const expiresAt = Date.now() + SESSION_DURATION;
await db.insert(sessionsTable).values({
id: sessionId,
@@ -66,7 +66,7 @@ export class AuthService {
}
const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + SESSION_DURATION).getTime();
const expiresAt = Date.now() + SESSION_DURATION;
await db.insert(sessionsTable).values({
id: sessionId,

View File

@@ -12,6 +12,10 @@ import {
stopBackupDto,
updateBackupScheduleDto,
updateBackupScheduleBody,
getScheduleMirrorsDto,
updateScheduleMirrorsDto,
updateScheduleMirrorsBody,
getMirrorCompatibilityDto,
type CreateBackupScheduleDto,
type DeleteBackupScheduleDto,
type GetBackupScheduleDto,
@@ -21,6 +25,9 @@ import {
type RunForgetDto,
type StopBackupDto,
type UpdateBackupScheduleDto,
type GetScheduleMirrorsDto,
type UpdateScheduleMirrorsDto,
type GetMirrorCompatibilityDto,
} from "./backups.dto";
import { backupsService } from "./backups.service";
import {
@@ -113,4 +120,23 @@ export const backupScheduleController = new Hono()
return c.json<UpdateScheduleNotificationsDto>(assignments, 200);
},
);
)
.get("/:scheduleId/mirrors", getScheduleMirrorsDto, async (c) => {
const scheduleId = Number.parseInt(c.req.param("scheduleId"), 10);
const mirrors = await backupsService.getMirrors(scheduleId);
return c.json<GetScheduleMirrorsDto>(mirrors, 200);
})
.put("/:scheduleId/mirrors", updateScheduleMirrorsDto, validator("json", updateScheduleMirrorsBody), async (c) => {
const scheduleId = Number.parseInt(c.req.param("scheduleId"), 10);
const body = c.req.valid("json");
const mirrors = await backupsService.updateMirrors(scheduleId, body);
return c.json<UpdateScheduleMirrorsDto>(mirrors, 200);
})
.get("/:scheduleId/mirrors/compatibility", getMirrorCompatibilityDto, async (c) => {
const scheduleId = Number.parseInt(c.req.param("scheduleId"), 10);
const compatibility = await backupsService.getMirrorCompatibility(scheduleId);
return c.json<GetMirrorCompatibilityDto>(compatibility, 200);
});

View File

@@ -17,15 +17,17 @@ export type RetentionPolicy = typeof retentionPolicySchema.infer;
const backupScheduleSchema = type({
id: "number",
name: "string",
volumeId: "number",
repositoryId: "string",
enabled: "boolean",
cronExpression: "string",
retentionPolicy: retentionPolicySchema.or("null"),
excludePatterns: "string[] | null",
excludeIfPresent: "string[] | null",
includePatterns: "string[] | null",
lastBackupAt: "number | null",
lastBackupStatus: "'success' | 'error' | 'in_progress' | null",
lastBackupStatus: "'success' | 'error' | 'in_progress' | 'warning' | null",
lastBackupError: "string | null",
nextBackupAt: "number | null",
createdAt: "number",
@@ -37,6 +39,19 @@ const backupScheduleSchema = type({
}),
);
const scheduleMirrorSchema = type({
scheduleId: "number",
repositoryId: "string",
enabled: "boolean",
lastCopyAt: "number | null",
lastCopyStatus: "'success' | 'error' | null",
lastCopyError: "string | null",
createdAt: "number",
repository: repositorySchema,
});
export type ScheduleMirrorDto = typeof scheduleMirrorSchema.infer;
/**
* List all backup schedules
*/
@@ -107,12 +122,14 @@ export const getBackupScheduleForVolumeDto = describeRoute({
* Create a new backup schedule
*/
export const createBackupScheduleBody = type({
name: "1 <= string <= 32",
volumeId: "number",
repositoryId: "string",
enabled: "boolean",
cronExpression: "string",
retentionPolicy: retentionPolicySchema.optional(),
excludePatterns: "string[]?",
excludeIfPresent: "string[]?",
includePatterns: "string[]?",
tags: "string[]?",
});
@@ -143,11 +160,13 @@ export const createBackupScheduleDto = describeRoute({
* Update a backup schedule
*/
export const updateBackupScheduleBody = type({
name: "(1 <= string <= 32)?",
repositoryId: "string",
enabled: "boolean?",
cronExpression: "string",
retentionPolicy: retentionPolicySchema.optional(),
excludePatterns: "string[]?",
excludeIfPresent: "string[]?",
includePatterns: "string[]?",
tags: "string[]?",
});
@@ -276,3 +295,75 @@ export const runForgetDto = describeRoute({
},
},
});
export const getScheduleMirrorsResponse = scheduleMirrorSchema.array();
export type GetScheduleMirrorsDto = typeof getScheduleMirrorsResponse.infer;
export const getScheduleMirrorsDto = describeRoute({
description: "Get mirror repository assignments for a backup schedule",
operationId: "getScheduleMirrors",
tags: ["Backups"],
responses: {
200: {
description: "List of mirror repository assignments for the schedule",
content: {
"application/json": {
schema: resolver(getScheduleMirrorsResponse),
},
},
},
},
});
export const updateScheduleMirrorsBody = type({
mirrors: type({
repositoryId: "string",
enabled: "boolean",
}).array(),
});
export type UpdateScheduleMirrorsBody = typeof updateScheduleMirrorsBody.infer;
export const updateScheduleMirrorsResponse = scheduleMirrorSchema.array();
export type UpdateScheduleMirrorsDto = typeof updateScheduleMirrorsResponse.infer;
export const updateScheduleMirrorsDto = describeRoute({
description: "Update mirror repository assignments for a backup schedule",
operationId: "updateScheduleMirrors",
tags: ["Backups"],
responses: {
200: {
description: "Mirror assignments updated successfully",
content: {
"application/json": {
schema: resolver(updateScheduleMirrorsResponse),
},
},
},
},
});
const mirrorCompatibilitySchema = type({
repositoryId: "string",
compatible: "boolean",
reason: "string | null",
});
export const getMirrorCompatibilityResponse = mirrorCompatibilitySchema.array();
export type GetMirrorCompatibilityDto = typeof getMirrorCompatibilityResponse.infer;
export const getMirrorCompatibilityDto = describeRoute({
description: "Get mirror compatibility info for all repositories relative to a backup schedule's primary repository",
operationId: "getMirrorCompatibility",
tags: ["Backups"],
responses: {
200: {
description: "List of repositories with their mirror compatibility status",
content: {
"application/json": {
schema: resolver(getMirrorCompatibilityResponse),
},
},
},
},
});

View File

@@ -1,16 +1,18 @@
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";
import { db } from "../../db/db";
import { backupSchedulesTable, repositoriesTable, volumesTable } from "../../db/schema";
import { backupSchedulesTable, backupScheduleMirrorsTable, repositoriesTable, volumesTable } from "../../db/schema";
import { restic } from "../../utils/restic";
import { logger } from "../../utils/logger";
import { getVolumePath } from "../volumes/helpers";
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto";
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody, UpdateScheduleMirrorsBody } from "./backups.dto";
import { toMessage } from "../../utils/errors";
import { serverEvents } from "../../core/events";
import { notificationsService } from "../notifications/notifications.service";
import { repoMutex } from "../../core/repository-mutex";
import { checkMirrorCompatibility, getIncompatibleMirrorError } from "~/server/utils/backend-compatibility";
const runningBackups = new Map<number, AbortController>();
@@ -42,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,
@@ -61,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),
});
@@ -82,12 +92,14 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
const [newSchedule] = await db
.insert(backupSchedulesTable)
.values({
name: data.name,
volumeId: data.volumeId,
repositoryId: data.repositoryId,
enabled: data.enabled,
cronExpression: data.cronExpression,
retentionPolicy: data.retentionPolicy ?? null,
excludePatterns: data.excludePatterns ?? [],
excludeIfPresent: data.excludeIfPresent ?? [],
includePatterns: data.includePatterns ?? [],
nextBackupAt: nextBackupAt,
})
@@ -113,6 +125,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),
});
@@ -209,7 +231,12 @@ const executeBackup = async (scheduleId: number, manual = false) => {
await db
.update(backupSchedulesTable)
.set({ lastBackupStatus: "in_progress", updatedAt: Date.now(), lastBackupError: null, nextBackupAt })
.set({
lastBackupStatus: "in_progress",
updatedAt: Date.now(),
lastBackupError: null,
nextBackupAt,
})
.where(eq(backupSchedulesTable.id, scheduleId));
const abortController = new AbortController();
@@ -220,6 +247,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
const backupOptions: {
exclude?: string[];
excludeIfPresent?: string[];
include?: string[];
tags?: string[];
signal?: AbortSignal;
@@ -232,50 +260,71 @@ const executeBackup = async (scheduleId: number, manual = false) => {
backupOptions.exclude = schedule.excludePatterns;
}
if (schedule.excludeIfPresent && schedule.excludeIfPresent.length > 0) {
backupOptions.excludeIfPresent = schedule.excludeIfPresent;
}
if (schedule.includePatterns && schedule.includePatterns.length > 0) {
backupOptions.include = schedule.includePatterns;
}
await restic.backup(repository.config, volumePath, {
...backupOptions,
compressionMode: repository.compressionMode ?? "auto",
onProgress: (progress) => {
serverEvents.emit("backup:progress", {
scheduleId,
volumeName: volume.name,
repositoryName: repository.name,
...progress,
});
},
});
const releaseBackupLock = await repoMutex.acquireShared(repository.id, `backup:${volume.name}`);
let exitCode: number;
try {
const result = await restic.backup(repository.config, volumePath, {
...backupOptions,
compressionMode: repository.compressionMode ?? "auto",
onProgress: (progress) => {
serverEvents.emit("backup:progress", {
scheduleId,
volumeName: volume.name,
repositoryName: repository.name,
...progress,
});
},
});
exitCode = result.exitCode;
} finally {
releaseBackupLock();
}
if (schedule.retentionPolicy) {
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
void runForget(schedule.id);
}
copyToMirrors(scheduleId, repository, schedule.retentionPolicy).catch((error) => {
logger.error(`Background mirror copy failed for schedule ${scheduleId}: ${toMessage(error)}`);
});
const finalStatus = exitCode === 0 ? "success" : "warning";
const nextBackupAt = calculateNextRun(schedule.cronExpression);
await db
.update(backupSchedulesTable)
.set({
lastBackupAt: Date.now(),
lastBackupStatus: "success",
lastBackupStatus: finalStatus,
lastBackupError: null,
nextBackupAt: nextBackupAt,
updatedAt: Date.now(),
})
.where(eq(backupSchedulesTable.id, scheduleId));
logger.info(`Backup completed successfully for volume ${volume.name} to repository ${repository.name}`);
if (finalStatus === "warning") {
logger.warn(`Backup completed with warnings for volume ${volume.name} to repository ${repository.name}`);
} else {
logger.info(`Backup completed successfully for volume ${volume.name} to repository ${repository.name}`);
}
serverEvents.emit("backup:completed", {
scheduleId,
volumeName: volume.name,
repositoryName: repository.name,
status: "success",
status: finalStatus,
});
notificationsService
.sendBackupNotification(scheduleId, "success", {
.sendBackupNotification(scheduleId, finalStatus === "success" ? "success" : "warning", {
volumeName: volume.name,
repositoryName: repository.name,
})
@@ -393,11 +442,185 @@ const runForget = async (scheduleId: number) => {
throw new NotFoundError("Repository not found");
}
logger.info(`Manually running retention policy (forget) for schedule ${scheduleId}`);
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
logger.info(`running retention policy (forget) for schedule ${scheduleId}`);
const releaseLock = await repoMutex.acquireExclusive(repository.id, `forget:manual:${scheduleId}`);
try {
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
} finally {
releaseLock();
}
logger.info(`Retention policy applied successfully for schedule ${scheduleId}`);
};
const getMirrors = async (scheduleId: number) => {
const schedule = await db.query.backupSchedulesTable.findFirst({
where: eq(backupSchedulesTable.id, scheduleId),
});
if (!schedule) {
throw new NotFoundError("Backup schedule not found");
}
const mirrors = await db.query.backupScheduleMirrorsTable.findMany({
where: eq(backupScheduleMirrorsTable.scheduleId, scheduleId),
with: { repository: true },
});
return mirrors;
};
const updateMirrors = async (scheduleId: number, data: UpdateScheduleMirrorsBody) => {
const schedule = await db.query.backupSchedulesTable.findFirst({
where: eq(backupSchedulesTable.id, scheduleId),
with: { repository: true },
});
if (!schedule) {
throw new NotFoundError("Backup schedule not found");
}
for (const mirror of data.mirrors) {
if (mirror.repositoryId === schedule.repositoryId) {
throw new BadRequestError("Cannot add the primary repository as a mirror");
}
const repo = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.id, mirror.repositoryId),
});
if (!repo) {
throw new NotFoundError(`Repository ${mirror.repositoryId} not found`);
}
const compatibility = await checkMirrorCompatibility(schedule.repository.config, repo.config, repo.id);
if (!compatibility.compatible) {
throw new BadRequestError(
getIncompatibleMirrorError(repo.name, schedule.repository.config.backend, repo.config.backend),
);
}
}
await db.delete(backupScheduleMirrorsTable).where(eq(backupScheduleMirrorsTable.scheduleId, scheduleId));
if (data.mirrors.length > 0) {
await db.insert(backupScheduleMirrorsTable).values(
data.mirrors.map((mirror) => ({
scheduleId,
repositoryId: mirror.repositoryId,
enabled: mirror.enabled,
})),
);
}
return getMirrors(scheduleId);
};
const copyToMirrors = async (
scheduleId: number,
sourceRepository: { id: string; config: (typeof repositoriesTable.$inferSelect)["config"] },
retentionPolicy: (typeof backupSchedulesTable.$inferSelect)["retentionPolicy"],
) => {
const mirrors = await db.query.backupScheduleMirrorsTable.findMany({
where: eq(backupScheduleMirrorsTable.scheduleId, scheduleId),
with: { repository: true },
});
const enabledMirrors = mirrors.filter((m) => m.enabled);
if (enabledMirrors.length === 0) {
return;
}
logger.info(
`[Background] Copying snapshots to ${enabledMirrors.length} mirror repositories for schedule ${scheduleId}`,
);
for (const mirror of enabledMirrors) {
try {
logger.info(`[Background] Copying to mirror repository: ${mirror.repository.name}`);
serverEvents.emit("mirror:started", {
scheduleId,
repositoryId: mirror.repositoryId,
repositoryName: mirror.repository.name,
});
const releaseSource = await repoMutex.acquireShared(sourceRepository.id, `mirror_source:${scheduleId}`);
const releaseMirror = await repoMutex.acquireShared(mirror.repository.id, `mirror:${scheduleId}`);
try {
await restic.copy(sourceRepository.config, mirror.repository.config, { tag: scheduleId.toString() });
} finally {
releaseSource();
releaseMirror();
}
if (retentionPolicy) {
const releaseForget = await repoMutex.acquireExclusive(mirror.repository.id, `forget:mirror:${scheduleId}`);
try {
logger.info(`[Background] Applying retention policy to mirror repository: ${mirror.repository.name}`);
await restic.forget(mirror.repository.config, retentionPolicy, { tag: scheduleId.toString() });
} finally {
releaseForget();
}
}
await db
.update(backupScheduleMirrorsTable)
.set({ lastCopyAt: Date.now(), lastCopyStatus: "success", lastCopyError: null })
.where(eq(backupScheduleMirrorsTable.id, mirror.id));
logger.info(`[Background] Successfully copied to mirror repository: ${mirror.repository.name}`);
serverEvents.emit("mirror:completed", {
scheduleId,
repositoryId: mirror.repositoryId,
repositoryName: mirror.repository.name,
status: "success",
});
} catch (error) {
const errorMessage = toMessage(error);
logger.error(`[Background] Failed to copy to mirror repository ${mirror.repository.name}: ${errorMessage}`);
await db
.update(backupScheduleMirrorsTable)
.set({ lastCopyAt: Date.now(), lastCopyStatus: "error", lastCopyError: errorMessage })
.where(eq(backupScheduleMirrorsTable.id, mirror.id));
serverEvents.emit("mirror:completed", {
scheduleId,
repositoryId: mirror.repositoryId,
repositoryName: mirror.repository.name,
status: "error",
error: errorMessage,
});
}
}
};
const getMirrorCompatibility = async (scheduleId: number) => {
const schedule = await db.query.backupSchedulesTable.findFirst({
where: eq(backupSchedulesTable.id, scheduleId),
with: { repository: true },
});
if (!schedule) {
throw new NotFoundError("Backup schedule not found");
}
const allRepositories = await db.query.repositoriesTable.findMany();
const repos = allRepositories.filter((repo) => repo.id !== schedule.repositoryId);
const compatibility = await Promise.all(
repos.map((repo) => checkMirrorCompatibility(schedule.repository.config, repo.config, repo.id)),
);
return compatibility;
};
export const backupsService = {
listSchedules,
getSchedule,
@@ -409,4 +632,7 @@ export const backupsService = {
getScheduleForVolume,
stopBackup,
runForget,
getMirrors,
updateMirrors,
getMirrorCompatibility,
};

View File

@@ -1,6 +1,9 @@
import { Hono } from "hono";
import { volumeService } from "../volumes/volume.service";
import { getVolumePath } from "../volumes/helpers";
import { eq } from "drizzle-orm";
import { db } from "../../db/db";
import { volumesTable } from "../../db/schema";
export const driverController = new Hono()
.post("/VolumeDriver.Capabilities", (c) => {
@@ -30,10 +33,18 @@ export const driverController = new Hono()
return c.json({ Err: "Volume name is required" }, 400);
}
const volumeName = body.Name.replace(/^zb-/, "");
const shortId = body.Name.replace(/^zb-/, "");
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.shortId, shortId),
});
if (!volume) {
return c.json({ Err: `Volume with shortId ${shortId} not found` }, 404);
}
return c.json({
Mountpoint: getVolumePath(volumeName),
Mountpoint: getVolumePath(volume),
});
})
.post("/VolumeDriver.Unmount", (c) => {
@@ -48,7 +59,15 @@ export const driverController = new Hono()
return c.json({ Err: "Volume name is required" }, 400);
}
const { volume } = await volumeService.getVolume(body.Name.replace(/^zb-/, ""));
const shortId = body.Name.replace(/^zb-/, "");
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.shortId, shortId),
});
if (!volume) {
return c.json({ Err: `Volume with shortId ${shortId} not found` }, 404);
}
return c.json({
Mountpoint: getVolumePath(volume),
@@ -61,11 +80,19 @@ export const driverController = new Hono()
return c.json({ Err: "Volume name is required" }, 400);
}
const { volume } = await volumeService.getVolume(body.Name.replace(/^zb-/, ""));
const shortId = body.Name.replace(/^zb-/, "");
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.shortId, shortId),
});
if (!volume) {
return c.json({ Err: `Volume with shortId ${shortId} not found` }, 404);
}
return c.json({
Volume: {
Name: `zb-${volume.name}`,
Name: `zb-${volume.shortId}`,
Mountpoint: getVolumePath(volume),
Status: {},
},
@@ -76,7 +103,7 @@ export const driverController = new Hono()
const volumes = await volumeService.listVolumes();
const res = volumes.map((volume) => ({
Name: `zb-${volume.name}`,
Name: `zb-${volume.shortId}`,
Mountpoint: getVolumePath(volume),
Status: {},
}));

View File

@@ -41,7 +41,7 @@ export const eventsController = new Hono().get("/", (c) => {
scheduleId: number;
volumeName: string;
repositoryName: string;
status: "success" | "error" | "stopped";
status: "success" | "error" | "stopped" | "warning";
}) => {
stream.writeSSE({
data: JSON.stringify(data),
@@ -70,12 +70,34 @@ export const eventsController = new Hono().get("/", (c) => {
});
};
const onMirrorStarted = (data: { scheduleId: number; repositoryId: string; repositoryName: string }) => {
stream.writeSSE({
data: JSON.stringify(data),
event: "mirror:started",
});
};
const onMirrorCompleted = (data: {
scheduleId: number;
repositoryId: string;
repositoryName: string;
status: "success" | "error";
error?: string;
}) => {
stream.writeSSE({
data: JSON.stringify(data),
event: "mirror:completed",
});
};
serverEvents.on("backup:started", onBackupStarted);
serverEvents.on("backup:progress", onBackupProgress);
serverEvents.on("backup:completed", onBackupCompleted);
serverEvents.on("volume:mounted", onVolumeMounted);
serverEvents.on("volume:unmounted", onVolumeUnmounted);
serverEvents.on("volume:updated", onVolumeUpdated);
serverEvents.on("mirror:started", onMirrorStarted);
serverEvents.on("mirror:completed", onMirrorCompleted);
let keepAlive = true;
@@ -88,6 +110,8 @@ export const eventsController = new Hono().get("/", (c) => {
serverEvents.off("volume:mounted", onVolumeMounted);
serverEvents.off("volume:unmounted", onVolumeUnmounted);
serverEvents.off("volume:updated", onVolumeUpdated);
serverEvents.off("mirror:started", onMirrorStarted);
serverEvents.off("mirror:completed", onMirrorCompleted);
});
while (keepAlive) {

View File

@@ -0,0 +1,88 @@
import { eq, sql } from "drizzle-orm";
import { db } from "../../db/db";
import { appMetadataTable, usersTable } from "../../db/schema";
import { logger } from "../../utils/logger";
const MIGRATION_KEY_PREFIX = "migration:";
export const recordMigrationCheckpoint = async (version: string): Promise<void> => {
const key = `${MIGRATION_KEY_PREFIX}${version}`;
const now = Date.now();
await db
.insert(appMetadataTable)
.values({
key,
value: JSON.stringify({ completedAt: new Date().toISOString() }),
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: appMetadataTable.key,
set: {
value: JSON.stringify({ completedAt: new Date().toISOString() }),
updatedAt: now,
},
});
logger.info(`Recorded migration checkpoint for ${version}`);
};
export const hasMigrationCheckpoint = async (version: string): Promise<boolean> => {
const key = `${MIGRATION_KEY_PREFIX}${version}`;
const result = await db.query.appMetadataTable.findFirst({
where: eq(appMetadataTable.key, key),
});
return result !== undefined;
};
export const validateRequiredMigrations = async (requiredVersions: string[]): Promise<void> => {
const userCount = await db.select({ count: sql<number>`count(*)` }).from(usersTable);
const isFreshInstall = userCount[0]?.count === 0;
if (isFreshInstall) {
logger.info("Fresh installation detected, skipping migration checkpoint validation.");
for (const version of requiredVersions) {
const hasCheckpoint = await hasMigrationCheckpoint(version);
if (!hasCheckpoint) {
await recordMigrationCheckpoint(version);
}
}
return;
}
for (const version of requiredVersions) {
const hasCheckpoint = await hasMigrationCheckpoint(version);
if (!hasCheckpoint) {
logger.error(`
================================================================================
MIGRATION ERROR: Required migration ${version} has not been run.
You are attempting to start a version of Zerobyte that requires migration
checkpoints from previous versions. This typically happens when you skip
versions during an upgrade.
To fix this:
1. First upgrade to version ${version} and run the application once
2. Validate that everything is still working correctly
3. Then upgrade to the current version
================================================================================
`);
process.exit(1);
}
}
};
export const getMigrationCheckpoints = async (): Promise<{ version: string; completedAt: string }[]> => {
const results = await db.query.appMetadataTable.findMany({
where: (table, { like }) => like(table.key, `${MIGRATION_KEY_PREFIX}%`),
});
return results.map((r) => ({
version: r.key.replace(MIGRATION_KEY_PREFIX, ""),
completedAt: JSON.parse(r.value).completedAt,
}));
};

View File

@@ -0,0 +1,198 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { eq } from "drizzle-orm";
import { db } from "../../db/db";
import { repositoriesTable } from "../../db/schema";
import { VOLUME_MOUNT_BASE, REPOSITORY_BASE } from "../../core/constants";
import { logger } from "../../utils/logger";
import { hasMigrationCheckpoint, recordMigrationCheckpoint } from "./checkpoint";
import type { RepositoryConfig } from "~/schemas/restic";
const MIGRATION_VERSION = "v0.14.0";
interface MigrationResult {
success: boolean;
errors: Array<{ name: string; error: string }>;
}
export class MigrationError extends Error {
version: string;
failedItems: Array<{ name: string; error: string }>;
constructor(version: string, failedItems: Array<{ name: string; error: string }>) {
const itemNames = failedItems.map((e) => e.name).join(", ");
super(`Migration ${version} failed for: ${itemNames}`);
this.version = version;
this.failedItems = failedItems;
this.name = "MigrationError";
}
}
export const migrateToShortIds = async () => {
const alreadyMigrated = await hasMigrationCheckpoint(MIGRATION_VERSION);
if (alreadyMigrated) {
logger.debug(`Migration ${MIGRATION_VERSION} already completed, skipping.`);
return;
}
logger.info(`Starting short ID migration (${MIGRATION_VERSION})...`);
const volumeResult = await migrateVolumeFolders();
const repoResult = await migrateRepositoryFolders();
const allErrors = [...volumeResult.errors, ...repoResult.errors];
if (allErrors.length > 0) {
for (const err of allErrors) {
logger.error(`Migration failure - ${err.name}: ${err.error}`);
}
throw new MigrationError(MIGRATION_VERSION, allErrors);
}
await recordMigrationCheckpoint(MIGRATION_VERSION);
logger.info(`Short ID migration (${MIGRATION_VERSION}) complete.`);
};
const migrateVolumeFolders = async (): Promise<MigrationResult> => {
const errors: Array<{ name: string; error: string }> = [];
const volumes = await db.query.volumesTable.findMany({});
for (const volume of volumes) {
if (volume.config.backend === "directory") {
continue;
}
const oldPath = path.join(VOLUME_MOUNT_BASE, volume.name);
const newPath = path.join(VOLUME_MOUNT_BASE, volume.shortId);
const oldExists = await pathExists(oldPath);
const newExists = await pathExists(newPath);
if (oldExists && !newExists) {
try {
logger.info(`Migrating volume folder: ${oldPath} -> ${newPath}`);
await fs.rename(oldPath, newPath);
logger.info(`Successfully migrated volume folder for "${volume.name}"`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
errors.push({ name: `volume:${volume.name}`, error: errorMessage });
}
} else if (oldExists && newExists) {
logger.warn(
`Both old (${oldPath}) and new (${newPath}) paths exist for volume "${volume.name}". Manual intervention may be required.`,
);
}
}
return { success: errors.length === 0, errors };
};
const migrateRepositoryFolders = async (): Promise<MigrationResult> => {
const errors: Array<{ name: string; error: string }> = [];
const repositories = await db.query.repositoriesTable.findMany({});
for (const repo of repositories) {
if (repo.config.backend !== "local") {
continue;
}
const config = repo.config as Extract<RepositoryConfig, { backend: "local" }>;
if (config.isExistingRepository) {
logger.debug(`Skipping imported repository "${repo.name}" - folder path is user-defined`);
continue;
}
if (config.name === repo.shortId) {
continue;
}
const basePath = config.path || REPOSITORY_BASE;
const oldPath = path.join(basePath, config.name);
const newPath = path.join(basePath, repo.shortId);
const oldExists = await pathExists(oldPath);
const newExists = await pathExists(newPath);
if (oldExists && !newExists) {
try {
logger.info(`Migrating repository folder: ${oldPath} -> ${newPath}`);
await fs.rename(oldPath, newPath);
const updatedConfig: RepositoryConfig = {
...config,
name: repo.shortId,
};
await db
.update(repositoriesTable)
.set({
config: updatedConfig,
updatedAt: Date.now(),
})
.where(eq(repositoriesTable.id, repo.id));
logger.info(`Successfully migrated repository folder and config for "${repo.name}"`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
errors.push({ name: `repository:${repo.name}`, error: errorMessage });
}
} else if (oldExists && newExists) {
logger.warn(
`Both old (${oldPath}) and new (${newPath}) paths exist for repository "${repo.name}". Manual intervention may be required.`,
);
} else if (!oldExists && !newExists) {
try {
logger.info(`Updating config.name for repository "${repo.name}" (no folder exists yet)`);
const updatedConfig: RepositoryConfig = {
...config,
name: repo.shortId,
};
await db
.update(repositoriesTable)
.set({
config: updatedConfig,
updatedAt: Date.now(),
})
.where(eq(repositoriesTable.id, repo.id));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
errors.push({ name: `repository:${repo.name}`, error: errorMessage });
}
} else if (newExists && !oldExists && config.name !== repo.shortId) {
try {
logger.info(`Folder already at new path, updating config.name for repository "${repo.name}"`);
const updatedConfig: RepositoryConfig = {
...config,
name: repo.shortId,
};
await db
.update(repositoriesTable)
.set({
config: updatedConfig,
updatedAt: Date.now(),
})
.where(eq(repositoriesTable.id, repo.id));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
errors.push({ name: `repository:${repo.name}`, error: errorMessage });
}
}
}
return { success: errors.length === 0, errors };
};
const pathExists = async (p: string): Promise<boolean> => {
try {
await fs.access(p);
return true;
} catch {
return false;
}
};

View File

@@ -34,7 +34,7 @@ export const startup = async () => {
Scheduler.build(CleanupDanglingMountsJob).schedule("0 * * * *");
Scheduler.build(VolumeHealthCheckJob).schedule("*/30 * * * *");
Scheduler.build(RepositoryHealthCheckJob).schedule("0 * * * *");
Scheduler.build(RepositoryHealthCheckJob).schedule("50 12 * * *");
Scheduler.build(BackupExecutionJob).schedule("* * * * *");
Scheduler.build(CleanupSessionsJob).schedule("0 0 * * *");
};

View File

@@ -17,7 +17,10 @@ export function buildDiscordShoutrrrUrl(config: Extract<NotificationConfig, { ty
params.append("username", config.username);
}
if (config.avatarUrl) {
params.append("avatar_url", config.avatarUrl);
params.append("avatarurl", config.avatarUrl);
}
if (config.threadId) {
params.append("thread_id", config.threadId);
}
if (params.toString()) {

View File

@@ -1,10 +1,10 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildEmailShoutrrrUrl(config: Extract<NotificationConfig, { type: "email" }>): string {
const protocol = config.useTLS ? "smtps" : "smtp";
const auth = `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}`;
const host = `${config.smtpHost}:${config.smtpPort}`;
const toRecipients = config.to.map((email) => encodeURIComponent(email)).join(",");
const useStartTLS = config.useTLS ? "yes" : "no";
return `${protocol}://${auth}@${host}/?from=${encodeURIComponent(config.from)}&to=${toRecipients}`;
return `smtp://${auth}@${host}/?from=${encodeURIComponent(config.from)}&to=${toRecipients}&starttls=${useStartTLS}`;
}

View File

@@ -4,8 +4,9 @@ export function buildGotifyShoutrrrUrl(config: Extract<NotificationConfig, { typ
const url = new URL(config.serverUrl);
const hostname = url.hostname;
const port = url.port ? `:${url.port}` : "";
const path = config.path ? `/${config.path.replace(/^\/+|\/+$/g, "")}` : "";
let shoutrrrUrl = `gotify://${hostname}${port}/${config.token}`;
let shoutrrrUrl = `gotify://${hostname}${port}${path}/${config.token}`;
if (config.priority !== undefined) {
shoutrrrUrl += `?priority=${config.priority}`;

View File

@@ -5,6 +5,7 @@ import { buildDiscordShoutrrrUrl } from "./discord";
import { buildGotifyShoutrrrUrl } from "./gotify";
import { buildNtfyShoutrrrUrl } from "./ntfy";
import { buildPushoverShoutrrrUrl } from "./pushover";
import { buildTelegramShoutrrrUrl } from "./telegram";
import { buildCustomShoutrrrUrl } from "./custom";
export function buildShoutrrrUrl(config: NotificationConfig): string {
@@ -21,6 +22,8 @@ export function buildShoutrrrUrl(config: NotificationConfig): string {
return buildNtfyShoutrrrUrl(config);
case "pushover":
return buildPushoverShoutrrrUrl(config);
case "telegram":
return buildTelegramShoutrrrUrl(config);
case "custom":
return buildCustomShoutrrrUrl(config);
default: {

View File

@@ -3,19 +3,26 @@ import type { NotificationConfig } from "~/schemas/notifications";
export function buildNtfyShoutrrrUrl(config: Extract<NotificationConfig, { type: "ntfy" }>): string {
let shoutrrrUrl: string;
const params = new URLSearchParams();
const auth =
config.username && config.password
? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password)}@`
: "";
if (config.serverUrl) {
const url = new URL(config.serverUrl);
const hostname = url.hostname;
const port = url.port ? `:${url.port}` : "";
shoutrrrUrl = `ntfy://${hostname}${port}/${config.topic}`;
const scheme = url.protocol === "https:" ? "https" : "http";
params.append("scheme", scheme);
shoutrrrUrl = `ntfy://${auth}${hostname}${port}/${config.topic}`;
} else {
shoutrrrUrl = `ntfy://ntfy.sh/${config.topic}`;
shoutrrrUrl = `ntfy://${auth}ntfy.sh/${config.topic}`;
}
const params = new URLSearchParams();
if (config.token) {
params.append("token", config.token);
}
if (config.priority) {
params.append("priority", config.priority);
}

View File

@@ -1,8 +1,6 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildPushoverShoutrrrUrl(
config: Extract<NotificationConfig, { type: "pushover" }>,
): string {
export function buildPushoverShoutrrrUrl(config: Extract<NotificationConfig, { type: "pushover" }>): string {
const params = new URLSearchParams();
if (config.devices) {

View File

@@ -20,7 +20,7 @@ export function buildSlackShoutrrrUrl(config: Extract<NotificationConfig, { type
params.append("username", config.username);
}
if (config.iconEmoji) {
params.append("icon", config.iconEmoji);
params.append("icon_emoji", config.iconEmoji);
}
if (params.toString()) {

View File

@@ -0,0 +1,5 @@
import type { NotificationConfig } from "~/schemas/notifications";
export function buildTelegramShoutrrrUrl(config: Extract<NotificationConfig, { type: "telegram" }>): string {
return `telegram://${config.botToken}@telegram?channels=${config.chatId}`;
}

View File

@@ -1,4 +1,4 @@
import { eq, and } from "drizzle-orm";
import { eq, and, ne } from "drizzle-orm";
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
import slugify from "slugify";
import { db } from "../../db/db";
@@ -58,13 +58,18 @@ async function encryptSensitiveFields(config: NotificationConfig): Promise<Notif
case "ntfy":
return {
...config,
token: config.token ? await cryptoUtils.encrypt(config.token) : undefined,
password: config.password ? await cryptoUtils.encrypt(config.password) : undefined,
};
case "pushover":
return {
...config,
apiToken: await cryptoUtils.encrypt(config.apiToken),
};
case "telegram":
return {
...config,
botToken: await cryptoUtils.encrypt(config.botToken),
};
case "custom":
return {
...config,
@@ -100,13 +105,18 @@ async function decryptSensitiveFields(config: NotificationConfig): Promise<Notif
case "ntfy":
return {
...config,
token: config.token ? await cryptoUtils.decrypt(config.token) : undefined,
password: config.password ? await cryptoUtils.decrypt(config.password) : undefined,
};
case "pushover":
return {
...config,
apiToken: await cryptoUtils.decrypt(config.apiToken),
};
case "telegram":
return {
...config,
botToken: await cryptoUtils.decrypt(config.botToken),
};
case "custom":
return {
...config,
@@ -157,17 +167,17 @@ const updateDestination = async (
}
const updateData: Partial<NotificationDestination> = {
updatedAt: Math.floor(Date.now() / 1000),
updatedAt: Date.now(),
};
if (updates.name !== undefined) {
const slug = slugify(updates.name, { lower: true, strict: true });
const conflict = await db.query.notificationDestinationsTable.findFirst({
where: and(eq(notificationDestinationsTable.name, slug), eq(notificationDestinationsTable.id, id)),
where: and(eq(notificationDestinationsTable.name, slug), ne(notificationDestinationsTable.id, id)),
});
if (conflict && conflict.id !== id) {
if (conflict) {
throw new ConflictError("Notification destination with this name already exists");
}
updateData.name = slug;
@@ -291,6 +301,7 @@ const sendBackupNotification = async (
case "success":
return assignment.notifyOnSuccess;
case "failure":
case "warning":
return assignment.notifyOnFailure;
default:
return false;
@@ -367,7 +378,7 @@ function buildNotificationMessage(
case "success":
return {
title: "✅ Backup Completed Successfully",
title: "✅ Backup Completed successfully",
body: [
`Volume: ${context.volumeName}`,
`Repository: ${context.repositoryName}`,
@@ -381,9 +392,26 @@ function buildNotificationMessage(
.join("\n"),
};
case "warning":
return {
title: "! Backup completed with warnings",
body: [
`Volume: ${context.volumeName}`,
`Repository: ${context.repositoryName}`,
context.duration ? `Duration: ${Math.round(context.duration / 1000)}s` : null,
context.filesProcessed !== undefined ? `Files: ${context.filesProcessed}` : null,
context.bytesProcessed ? `Size: ${context.bytesProcessed}` : null,
context.snapshotId ? `Snapshot: ${context.snapshotId}` : null,
context.error ? `Warning: ${context.error}` : null,
`Time: ${date} - ${time}`,
]
.filter(Boolean)
.join("\n"),
};
case "failure":
return {
title: "❌ Backup Failed",
title: "❌ Backup failed",
body: [
`Volume: ${context.volumeName}`,
`Repository: ${context.repositoryName}`,

View File

@@ -16,6 +16,8 @@ import {
listSnapshotsFilters,
restoreSnapshotBody,
restoreSnapshotDto,
updateRepositoryBody,
updateRepositoryDto,
type DeleteRepositoryDto,
type DeleteSnapshotDto,
type DoctorRepositoryDto,
@@ -25,6 +27,7 @@ import {
type ListSnapshotFilesDto,
type ListSnapshotsDto,
type RestoreSnapshotDto,
type UpdateRepositoryDto,
} from "./repositories.dto";
import { repositoriesService } from "./repositories.service";
import { getRcloneRemoteInfo, listRcloneRemotes } from "../../utils/rclone";
@@ -87,6 +90,7 @@ export const repositoriesController = new Hono()
short_id: snapshot.short_id,
duration,
paths: snapshot.paths,
tags: snapshot.tags ?? [],
size: summary?.total_bytes_processed || 0,
time: new Date(snapshot.time).getTime(),
};
@@ -110,6 +114,7 @@ export const repositoriesController = new Hono()
time: new Date(snapshot.time).getTime(),
paths: snapshot.paths,
size: snapshot.summary?.total_bytes_processed || 0,
tags: snapshot.tags ?? [],
summary: snapshot.summary,
};
@@ -152,4 +157,12 @@ export const repositoriesController = new Hono()
await repositoriesService.deleteSnapshot(name, snapshotId);
return c.json<DeleteSnapshotDto>({ message: "Snapshot deleted" }, 200);
})
.patch("/:name", updateRepositoryDto, validator("json", updateRepositoryBody), async (c) => {
const { name } = c.req.param();
const body = c.req.valid("json");
const res = await repositoriesService.updateRepository(name, body);
return c.json<UpdateRepositoryDto>(res.repository, 200);
});

View File

@@ -1,9 +1,16 @@
import { type } from "arktype";
import { describeRoute, resolver } from "hono-openapi";
import { COMPRESSION_MODES, REPOSITORY_BACKENDS, REPOSITORY_STATUS, repositoryConfigSchema } from "~/schemas/restic";
import {
COMPRESSION_MODES,
OVERWRITE_MODES,
REPOSITORY_BACKENDS,
REPOSITORY_STATUS,
repositoryConfigSchema,
} from "~/schemas/restic";
export const repositorySchema = type({
id: "string",
shortId: "string",
name: "string",
type: type.valueOf(REPOSITORY_BACKENDS),
config: repositoryConfigSchema,
@@ -123,6 +130,41 @@ export const deleteRepositoryDto = describeRoute({
},
});
/**
* Update a repository
*/
export const updateRepositoryBody = type({
name: "string?",
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
});
export type UpdateRepositoryBody = typeof updateRepositoryBody.infer;
export const updateRepositoryResponse = repositorySchema;
export type UpdateRepositoryDto = typeof updateRepositoryResponse.infer;
export const updateRepositoryDto = describeRoute({
description: "Update a repository's name or settings",
tags: ["Repositories"],
operationId: "updateRepository",
responses: {
200: {
description: "Repository updated successfully",
content: {
"application/json": {
schema: resolver(updateRepositoryResponse),
},
},
},
404: {
description: "Repository not found",
},
409: {
description: "Repository with this name already exists",
},
},
});
/**
* List snapshots in a repository
*/
@@ -132,6 +174,7 @@ export const snapshotSchema = type({
paths: "string[]",
size: "number",
duration: "number",
tags: "string[]",
});
const listSnapshotsResponse = snapshotSchema.array();
@@ -233,12 +276,16 @@ export const listSnapshotFilesDto = describeRoute({
/**
* Restore a snapshot
*/
export const overwriteModeSchema = type.valueOf(OVERWRITE_MODES);
export const restoreSnapshotBody = type({
snapshotId: "string",
include: "string[]?",
exclude: "string[]?",
excludeXattr: "string[]?",
delete: "boolean?",
targetPath: "string?",
overwrite: overwriteModeSchema.optional(),
});
export type RestoreSnapshotBody = typeof restoreSnapshotBody.infer;

View File

@@ -1,13 +1,15 @@
import crypto from "node:crypto";
import { eq } from "drizzle-orm";
import { and, eq, ne } from "drizzle-orm";
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
import slugify from "slugify";
import { db } from "../../db/db";
import { repositoriesTable } from "../../db/schema";
import { toMessage } from "../../utils/errors";
import { generateShortId } from "../../utils/id";
import { restic } from "../../utils/restic";
import { cryptoUtils } from "../../utils/crypto";
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
import { repoMutex } from "../../core/repository-mutex";
import type { CompressionMode, OverwriteMode, RepositoryConfig } from "~/schemas/restic";
const listRepositories = async () => {
const repositories = await db.query.repositoriesTable.findMany({});
@@ -61,13 +63,20 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
}
const id = crypto.randomUUID();
const shortId = generateShortId();
const encryptedConfig = await encryptConfig(config);
let processedConfig = config;
if (config.backend === "local") {
processedConfig = { ...config, name: shortId };
}
const encryptedConfig = await encryptConfig(processedConfig);
const [created] = await db
.insert(repositoriesTable)
.values({
id,
shortId,
name: slug,
type: config.backend,
config: encryptedConfig,
@@ -152,15 +161,20 @@ const listSnapshots = async (name: string, backupId?: string) => {
throw new NotFoundError("Repository not found");
}
let snapshots = [];
const releaseLock = await repoMutex.acquireShared(repository.id, "snapshots");
try {
let snapshots = [];
if (backupId) {
snapshots = await restic.snapshots(repository.config, { tags: [backupId.toString()] });
} else {
snapshots = await restic.snapshots(repository.config);
if (backupId) {
snapshots = await restic.snapshots(repository.config, { tags: [backupId.toString()] });
} else {
snapshots = await restic.snapshots(repository.config);
}
return snapshots;
} finally {
releaseLock();
}
return snapshots;
};
const listSnapshotFiles = async (name: string, snapshotId: string, path?: string) => {
@@ -172,28 +186,40 @@ const listSnapshotFiles = async (name: string, snapshotId: string, path?: string
throw new NotFoundError("Repository not found");
}
const result = await restic.ls(repository.config, snapshotId, path);
const releaseLock = await repoMutex.acquireShared(repository.id, `ls:${snapshotId}`);
try {
const result = await restic.ls(repository.config, snapshotId, path);
if (!result.snapshot) {
throw new NotFoundError("Snapshot not found or empty");
if (!result.snapshot) {
throw new NotFoundError("Snapshot not found or empty");
}
return {
snapshot: {
id: result.snapshot.id,
short_id: result.snapshot.short_id,
time: result.snapshot.time,
hostname: result.snapshot.hostname,
paths: result.snapshot.paths,
},
files: result.nodes,
};
} finally {
releaseLock();
}
return {
snapshot: {
id: result.snapshot.id,
short_id: result.snapshot.short_id,
time: result.snapshot.time,
hostname: result.snapshot.hostname,
paths: result.snapshot.paths,
},
files: result.nodes,
};
};
const restoreSnapshot = async (
name: string,
snapshotId: string,
options?: { include?: string[]; exclude?: string[]; excludeXattr?: string[]; delete?: boolean },
options?: {
include?: string[];
exclude?: string[];
excludeXattr?: string[];
delete?: boolean;
targetPath?: string;
overwrite?: OverwriteMode;
},
) => {
const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
@@ -203,14 +229,21 @@ const restoreSnapshot = async (
throw new NotFoundError("Repository not found");
}
const result = await restic.restore(repository.config, snapshotId, "/", options);
const target = options?.targetPath || "/";
return {
success: true,
message: "Snapshot restored successfully",
filesRestored: result.files_restored,
filesSkipped: result.files_skipped,
};
const releaseLock = await repoMutex.acquireShared(repository.id, `restore:${snapshotId}`);
try {
const result = await restic.restore(repository.config, snapshotId, target, options);
return {
success: true,
message: "Snapshot restored successfully",
filesRestored: result.files_restored,
filesSkipped: result.files_skipped,
};
} finally {
releaseLock();
}
};
const getSnapshotDetails = async (name: string, snapshotId: string) => {
@@ -222,14 +255,19 @@ const getSnapshotDetails = async (name: string, snapshotId: string) => {
throw new NotFoundError("Repository not found");
}
const snapshots = await restic.snapshots(repository.config);
const snapshot = snapshots.find((snap) => snap.id === snapshotId || snap.short_id === snapshotId);
const releaseLock = await repoMutex.acquireShared(repository.id, `snapshot_details:${snapshotId}`);
try {
const snapshots = await restic.snapshots(repository.config);
const snapshot = snapshots.find((snap) => snap.id === snapshotId || snap.short_id === snapshotId);
if (!snapshot) {
throw new NotFoundError("Snapshot not found");
if (!snapshot) {
throw new NotFoundError("Snapshot not found");
}
return snapshot;
} finally {
releaseLock();
}
return snapshot;
};
const checkHealth = async (repositoryId: string) => {
@@ -241,21 +279,23 @@ const checkHealth = async (repositoryId: string) => {
throw new NotFoundError("Repository not found");
}
const { error, status } = await restic
.snapshots(repository.config)
.then(() => ({ error: null, status: "healthy" as const }))
.catch((error) => ({ error: toMessage(error), status: "error" as const }));
const releaseLock = await repoMutex.acquireExclusive(repository.id, "check");
try {
const { hasErrors, error } = await restic.check(repository.config);
await db
.update(repositoriesTable)
.set({
status,
lastChecked: Date.now(),
lastError: error,
})
.where(eq(repositoriesTable.id, repository.id));
await db
.update(repositoriesTable)
.set({
status: hasErrors ? "error" : "healthy",
lastChecked: Date.now(),
lastError: error,
})
.where(eq(repositoriesTable.id, repository.id));
return { status, lastError: error };
return { lastError: error };
} finally {
releaseLock();
}
};
const doctorRepository = async (name: string) => {
@@ -281,48 +321,51 @@ const doctorRepository = async (name: string) => {
error: unlockResult.error,
});
const checkResult = await restic.check(repository.config, { readData: false }).then(
(result) => result,
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
);
steps.push({
step: "check",
success: checkResult.success,
output: checkResult.output,
error: checkResult.error,
});
if (checkResult.hasErrors) {
const repairResult = await restic.repairIndex(repository.config).then(
(result) => ({ success: true, output: result.output, error: null }),
(error) => ({ success: false, output: null, error: toMessage(error) }),
);
steps.push({
step: "repair_index",
success: repairResult.success,
output: repairResult.output,
error: repairResult.error,
});
const recheckResult = await restic.check(repository.config, { readData: false }).then(
const releaseLock = await repoMutex.acquireExclusive(repository.id, "doctor");
try {
const checkResult = await restic.check(repository.config, { readData: false }).then(
(result) => result,
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
);
steps.push({
step: "recheck",
success: recheckResult.success,
output: recheckResult.output,
error: recheckResult.error,
step: "check",
success: checkResult.success,
output: checkResult.output,
error: checkResult.error,
});
if (checkResult.hasErrors) {
const repairResult = await restic.repairIndex(repository.config).then(
(result) => ({ success: true, output: result.output, error: null }),
(error) => ({ success: false, output: null, error: toMessage(error) }),
);
steps.push({
step: "repair_index",
success: repairResult.success,
output: repairResult.output,
error: repairResult.error,
});
const recheckResult = await restic.check(repository.config, { readData: false }).then(
(result) => result,
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
);
steps.push({
step: "recheck",
success: recheckResult.success,
output: recheckResult.output,
error: recheckResult.error,
});
}
} finally {
releaseLock();
}
const allSuccessful = steps.every((s) => s.success);
console.log("Doctor steps:", steps);
await db
.update(repositoriesTable)
.set({
@@ -347,7 +390,62 @@ const deleteSnapshot = async (name: string, snapshotId: string) => {
throw new NotFoundError("Repository not found");
}
await restic.deleteSnapshot(repository.config, snapshotId);
const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:${snapshotId}`);
try {
await restic.deleteSnapshot(repository.config, snapshotId);
} finally {
releaseLock();
}
};
const updateRepository = async (name: string, updates: { name?: string; compressionMode?: CompressionMode }) => {
const existing = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name),
});
if (!existing) {
throw new NotFoundError("Repository not found");
}
if (
updates.name !== undefined &&
updates.name !== existing.name &&
existing.config.backend === "local" &&
existing.config.isExistingRepository
) {
throw new ConflictError("Cannot rename an imported local repository");
}
let newName = existing.name;
if (updates.name !== undefined && updates.name !== existing.name) {
const newSlug = slugify(updates.name, { lower: true, strict: true });
const conflict = await db.query.repositoriesTable.findFirst({
where: and(eq(repositoriesTable.name, newSlug), ne(repositoriesTable.id, existing.id)),
});
if (conflict) {
throw new ConflictError("A repository with this name already exists");
}
newName = newSlug;
}
const [updated] = await db
.update(repositoriesTable)
.set({
name: newName,
compressionMode: updates.compressionMode ?? existing.compressionMode,
updatedAt: Date.now(),
})
.where(eq(repositoriesTable.id, existing.id))
.returning();
if (!updated) {
throw new InternalServerError("Failed to update repository");
}
return { repository: updated };
};
export const repositoriesService = {
@@ -355,6 +453,7 @@ export const repositoriesService = {
createRepository,
getRepository,
deleteRepository,
updateRepository,
listSnapshots,
listSnapshotFiles,
restoreSnapshot,

View File

@@ -6,5 +6,5 @@ export const getVolumePath = (volume: Volume) => {
return volume.config.path;
}
return `${VOLUME_MOUNT_BASE}/${volume.name}/_data`;
return `${VOLUME_MOUNT_BASE}/${volume.shortId}/_data`;
};

View File

@@ -4,6 +4,7 @@ import { BACKEND_STATUS, BACKEND_TYPES, volumeConfigSchema } from "~/schemas/vol
export const volumeSchema = type({
id: "number",
shortId: "string",
name: "string",
type: type.valueOf(BACKEND_TYPES),
status: type.valueOf(BACKEND_STATUS),
@@ -128,6 +129,7 @@ export const getVolumeDto = describeRoute({
* Update a volume
*/
export const updateVolumeBody = type({
name: "string?",
autoRemount: "boolean?",
config: volumeConfigSchema.optional(),
});

View File

@@ -2,13 +2,14 @@ import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import Docker from "dockerode";
import { eq } from "drizzle-orm";
import { and, eq, ne } from "drizzle-orm";
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
import slugify from "slugify";
import { getCapabilities } from "../../core/capabilities";
import { getCapabilities, parseDockerHost } from "../../core/capabilities";
import { db } from "../../db/db";
import { volumesTable } from "../../db/schema";
import { toMessage } from "../../utils/errors";
import { generateShortId } from "../../utils/id";
import { getStatFs, type StatFs } from "../../utils/mountinfo";
import { withTimeout } from "../../utils/timeout";
import { createVolumeBackend } from "../backends/backend";
@@ -35,9 +36,12 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
throw new ConflictError("Volume already exists");
}
const shortId = generateShortId();
const [created] = await db
.insert(volumesTable)
.values({
shortId,
name: slug,
config: backendConfig,
type: backendConfig.backend,
@@ -147,6 +151,21 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
throw new NotFoundError("Volume not found");
}
let newName = existing.name;
if (volumeData.name !== undefined && volumeData.name !== existing.name) {
const newSlug = slugify(volumeData.name, { lower: true, strict: true });
const conflict = await db.query.volumesTable.findFirst({
where: and(eq(volumesTable.name, newSlug), ne(volumesTable.id, existing.id)),
});
if (conflict) {
throw new ConflictError("A volume with this name already exists");
}
newName = newSlug;
}
const configChanged =
JSON.stringify(existing.config) !== JSON.stringify(volumeData.config) && volumeData.config !== undefined;
@@ -159,12 +178,13 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
const [updated] = await db
.update(volumesTable)
.set({
name: newName,
config: volumeData.config,
type: volumeData.config?.backend,
autoRemount: volumeData.autoRemount,
updatedAt: Date.now(),
})
.where(eq(volumesTable.name, name))
.where(eq(volumesTable.id, existing.id))
.returning();
if (!updated) {
@@ -177,9 +197,9 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
await db
.update(volumesTable)
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
.where(eq(volumesTable.name, name));
.where(eq(volumesTable.id, existing.id));
serverEvents.emit("volume:updated", { volumeName: name });
serverEvents.emit("volume:updated", { volumeName: updated.name });
}
return { volume: updated };
@@ -190,6 +210,7 @@ const testConnection = async (backendConfig: BackendConfig) => {
const mockVolume = {
id: 0,
shortId: "test",
name: "test-connection",
path: tempDir,
config: backendConfig,
@@ -256,7 +277,8 @@ const getContainersUsingVolume = async (name: string) => {
}
try {
const docker = new Docker();
const docker = new Docker(parseDockerHost(process.env.DOCKER_HOST));
const containers = await docker.listContainers({ all: true });
const usingContainers = [];
@@ -264,7 +286,7 @@ const getContainersUsingVolume = async (name: string) => {
const container = docker.getContainer(info.Id);
const inspect = await container.inspect();
const mounts = inspect.Mounts || [];
const usesVolume = mounts.some((mount) => mount.Type === "volume" && mount.Name === `im-${volume.name}`);
const usesVolume = mounts.some((mount) => mount.Type === "volume" && mount.Name === `zb-${volume.shortId}`);
if (usesVolume) {
usingContainers.push({
id: inspect.Id,

View File

@@ -0,0 +1,148 @@
import type { RepositoryConfig } from "~/schemas/restic";
import { cryptoUtils } from "./crypto";
type BackendConflictGroup = "s3" | "gcs" | "azure" | "rest" | "sftp" | null;
export const getBackendConflictGroup = (backend: string): BackendConflictGroup => {
switch (backend) {
case "s3":
case "r2":
return "s3";
case "gcs":
return "gcs";
case "azure":
return "azure";
case "rest":
return "rest";
case "sftp":
return "sftp";
case "local":
case "rclone":
return null;
default:
return null;
}
};
export const hasCompatibleCredentials = async (
config1: RepositoryConfig,
config2: RepositoryConfig,
): Promise<boolean> => {
const group1 = getBackendConflictGroup(config1.backend);
const group2 = getBackendConflictGroup(config2.backend);
if (!group1 || !group2 || group1 !== group2) {
return true;
}
switch (group1) {
case "s3": {
if (
(config1.backend === "s3" || config1.backend === "r2") &&
(config2.backend === "s3" || config2.backend === "r2")
) {
const accessKey1 = await cryptoUtils.decrypt(config1.accessKeyId);
const secretKey1 = await cryptoUtils.decrypt(config1.secretAccessKey);
const accessKey2 = await cryptoUtils.decrypt(config2.accessKeyId);
const secretKey2 = await cryptoUtils.decrypt(config2.secretAccessKey);
return accessKey1 === accessKey2 && secretKey1 === secretKey2;
}
return false;
}
case "gcs": {
if (config1.backend === "gcs" && config2.backend === "gcs") {
const credentials1 = await cryptoUtils.decrypt(config1.credentialsJson);
const credentials2 = await cryptoUtils.decrypt(config2.credentialsJson);
return credentials1 === credentials2 && config1.projectId === config2.projectId;
}
return false;
}
case "azure": {
if (config1.backend === "azure" && config2.backend === "azure") {
const config1Accountkey = await cryptoUtils.decrypt(config1.accountKey);
const config2Accountkey = await cryptoUtils.decrypt(config2.accountKey);
return config1.accountName === config2.accountName && config1Accountkey === config2Accountkey;
}
return false;
}
case "rest": {
if (config1.backend === "rest" && config2.backend === "rest") {
if (!config1.username && !config2.username && !config1.password && !config2.password) {
return true;
}
const config1Username = await cryptoUtils.decrypt(config1.username || "");
const config1Password = await cryptoUtils.decrypt(config1.password || "");
const config2Username = await cryptoUtils.decrypt(config2.username || "");
const config2Password = await cryptoUtils.decrypt(config2.password || "");
return config1Username === config2Username && config1Password === config2Password;
}
return false;
}
case "sftp": {
return false;
}
default:
return false;
}
};
export interface CompatibilityResult {
repositoryId: string;
compatible: boolean;
reason: string | null;
}
export const checkMirrorCompatibility = async (
primaryConfig: RepositoryConfig,
mirrorConfig: RepositoryConfig,
mirrorRepositoryId: string,
): Promise<CompatibilityResult> => {
const primaryConflictGroup = getBackendConflictGroup(primaryConfig.backend);
const mirrorConflictGroup = getBackendConflictGroup(mirrorConfig.backend);
if (!primaryConflictGroup || !mirrorConflictGroup) {
return {
repositoryId: mirrorRepositoryId,
compatible: true,
reason: null,
};
}
if (primaryConflictGroup !== mirrorConflictGroup) {
return {
repositoryId: mirrorRepositoryId,
compatible: true,
reason: null,
};
}
const compatible = await hasCompatibleCredentials(primaryConfig, mirrorConfig);
if (compatible) {
return {
repositoryId: mirrorRepositoryId,
compatible: true,
reason: null,
};
}
return {
repositoryId: mirrorRepositoryId,
compatible: false,
reason: `Both use ${primaryConflictGroup.toUpperCase()} backends with different credentials`,
};
};
export const getIncompatibleMirrorError = (mirrorRepoName: string, primaryBackend: string, mirrorBackend: string) => {
return (
`Cannot mirror to ${mirrorRepoName}: both repositories use the same backend type (${primaryBackend}/${mirrorBackend}) with different credentials. ` +
"Restic cannot use different credentials for the same backend in a copy operation. " +
"Consider creating a new backup scheduler with the desired destination instead."
);
};

View File

@@ -17,3 +17,25 @@ export const toMessage = (err: unknown): string => {
const message = err instanceof Error ? err.message : String(err);
return sanitizeSensitiveData(message);
};
const resticErrorCodes: Record<number, string> = {
1: "Command failed: An error occurred while executing the command.",
2: "Go runtime error: A runtime error occurred in the Go program.",
3: "Backup could not read all files: Some files could not be read during backup.",
10: "Repository not found: The specified repository could not be found.",
11: "Failed to lock repository: Unable to acquire a lock on the repository. Try to run doctor on the repository.",
12: "Wrong repository password: The provided password for the repository is incorrect.",
130: "Backup interrupted: The backup process was interrupted.",
};
export class ResticError extends Error {
code: number;
constructor(code: number, stderr: string) {
const message = resticErrorCodes[code] || `Unknown restic error with code ${code}`;
super(`${message}\n${stderr}`);
this.code = code;
this.name = "ResticError";
}
}

6
app/server/utils/id.ts Normal file
View File

@@ -0,0 +1,6 @@
import crypto from "node:crypto";
export const generateShortId = (length = 5): string => {
const bytesNeeded = Math.ceil((length * 3) / 4);
return crypto.randomBytes(bytesNeeded).toString("base64url").slice(0, length);
};

View File

@@ -1,15 +1,17 @@
import { createLogger, format, transports } from "winston";
import { sanitizeSensitiveData } from "./sanitize";
import { config } from "../core/config";
const { printf, combine, colorize } = format;
const printConsole = printf((info) => `${info.level} > ${info.message}`);
const consoleFormat = combine(colorize(), printConsole);
const defaultLevel = config.__prod__ ? "info" : "debug";
const winstonLogger = createLogger({
level: "debug",
level: process.env.LOG_LEVEL || defaultLevel,
format: format.json(),
transports: [new transports.Console({ level: "debug", format: consoleFormat })],
transports: [new transports.Console({ level: process.env.LOG_LEVEL || defaultLevel, format: consoleFormat })],
});
const log = (level: "info" | "warn" | "error" | "debug", messages: unknown[]) => {

View File

@@ -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";
@@ -9,7 +10,8 @@ import { logger } from "./logger";
import { cryptoUtils } from "./crypto";
import type { RetentionPolicy } from "../modules/backups/backups.dto";
import { safeSpawn } from "./spawn";
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
import type { CompressionMode, RepositoryConfig, OverwriteMode } from "~/schemas/restic";
import { ResticError } from "./errors";
const backupOutputSchema = type({
message_type: "'summary'",
@@ -39,6 +41,7 @@ const snapshotInfoSchema = type({
time: "string",
uid: "number?",
username: "string",
tags: "string[]?",
summary: type({
backup_end: "string",
backup_start: "string",
@@ -199,8 +202,8 @@ const init = async (config: RepositoryConfig) => {
const env = await buildEnv(config);
const args = ["init", "--repo", repoUrl, "--json"];
addRepoSpecificArgs(args, config, env);
const args = ["init", "--repo", repoUrl];
addCommonArgs(args, env);
const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env);
@@ -232,6 +235,7 @@ const backup = async (
source: string,
options?: {
exclude?: string[];
excludeIfPresent?: string[];
include?: string[];
tags?: string[];
compressionMode?: CompressionMode;
@@ -259,8 +263,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");
@@ -276,8 +281,13 @@ const backup = async (
}
}
addRepoSpecificArgs(args, config, env);
args.push("--json");
if (options?.excludeIfPresent && options.excludeIfPresent.length > 0) {
for (const filename of options.excludeIfPresent) {
args.push("--exclude-if-present", filename);
}
}
addCommonArgs(args, env);
const logData = throttle((data: string) => {
logger.info(data.trim());
@@ -313,39 +323,46 @@ const backup = async (
streamProgress(data);
}
},
onStderr: (error) => {
logger.error(error.trim());
},
finally: async () => {
includeFile && (await fs.unlink(includeFile).catch(() => {}));
await cleanupTemporaryKeys(config, env);
},
});
if (res.exitCode !== 0) {
logger.error(`Restic backup failed: ${res.stderr}`);
if (res.exitCode === 3) {
logger.error(`Restic backup encountered read errors: ${res.stderr.toString()}`);
}
if (res.exitCode !== 0 && res.exitCode !== 3) {
logger.error(`Restic backup failed: ${res.stderr.toString()}`);
logger.error(`Command executed: restic ${args.join(" ")}`);
throw new Error(`Restic backup failed: ${res.stderr}`);
throw new ResticError(res.exitCode, res.stderr.toString());
}
const lastLine = stdout.trim();
const resSummary = JSON.parse(lastLine ?? "{}");
let summaryLine = "";
try {
const resSummary = JSON.parse(lastLine ?? "{}");
summaryLine = resSummary;
} catch (_) {
logger.warn("Failed to parse restic backup output JSON summary.", lastLine);
summaryLine = "{}";
}
const result = backupOutputSchema(resSummary);
const result = backupOutputSchema(summaryLine);
if (result instanceof type.errors) {
logger.error(`Restic backup output validation failed: ${result}`);
throw new Error(`Restic backup output validation failed: ${result}`);
return { result: null, exitCode: res.exitCode };
}
return result;
return { result, exitCode: res.exitCode };
};
const restoreOutputSchema = type({
message_type: "'summary'",
total_files: "number",
total_files: "number?",
files_restored: "number",
files_skipped: "number",
total_bytes: "number?",
@@ -361,8 +378,8 @@ const restore = async (
include?: string[];
exclude?: string[];
excludeXattr?: string[];
path?: string;
delete?: boolean;
overwrite?: OverwriteMode;
},
) => {
const repoUrl = buildRepoUrl(config);
@@ -370,8 +387,8 @@ const restore = async (
const args: string[] = ["--repo", repoUrl, "restore", snapshotId, "--target", target];
if (options?.path) {
args[args.length - 4] = `${snapshotId}:${options.path}`;
if (options?.overwrite) {
args.push("--overwrite", options.overwrite);
}
if (options?.delete) {
@@ -396,15 +413,15 @@ const restore = async (
}
}
addRepoSpecificArgs(args, config, env);
args.push("--json");
addCommonArgs(args, env);
logger.debug(`Executing: restic ${args.join(" ")}`);
const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env);
if (res.exitCode !== 0) {
logger.error(`Restic restore failed: ${res.stderr}`);
throw new Error(`Restic restore failed: ${res.stderr}`);
throw new ResticError(res.exitCode, res.stderr.toString());
}
const stdout = res.text();
@@ -459,8 +476,7 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] }
}
}
addRepoSpecificArgs(args, config, env);
args.push("--json");
addCommonArgs(args, env);
const res = await $`restic ${args}`.env(env).nothrow().quiet();
await cleanupTemporaryKeys(config, env);
@@ -509,15 +525,14 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
}
args.push("--prune");
addRepoSpecificArgs(args, config, env);
args.push("--json");
addCommonArgs(args, env);
const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env);
if (res.exitCode !== 0) {
logger.error(`Restic forget failed: ${res.stderr}`);
throw new Error(`Restic forget failed: ${res.stderr}`);
throw new ResticError(res.exitCode, res.stderr.toString());
}
return { success: true };
@@ -528,14 +543,14 @@ const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
const env = await buildEnv(config);
const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"];
addRepoSpecificArgs(args, config, env);
addCommonArgs(args, env);
const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env);
if (res.exitCode !== 0) {
logger.error(`Restic snapshot deletion failed: ${res.stderr}`);
throw new Error(`Failed to delete snapshot: ${res.stderr}`);
throw new ResticError(res.exitCode, res.stderr.toString());
}
return { success: true };
@@ -572,20 +587,20 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config);
const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--json", "--long"];
const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--long"];
if (path) {
args.push(path);
}
addRepoSpecificArgs(args, config, env);
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(config, env);
if (res.exitCode !== 0) {
logger.error(`Restic ls failed: ${res.stderr}`);
throw new Error(`Restic ls failed: ${res.stderr}`);
throw new ResticError(res.exitCode, res.stderr.toString());
}
// The output is a stream of JSON objects, first is snapshot info, rest are file/dir nodes
@@ -628,15 +643,15 @@ const unlock = async (config: RepositoryConfig) => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config);
const args = ["unlock", "--repo", repoUrl, "--remove-all", "--json"];
addRepoSpecificArgs(args, config, env);
const args = ["unlock", "--repo", repoUrl, "--remove-all"];
addCommonArgs(args, env);
const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env);
if (res.exitCode !== 0) {
logger.error(`Restic unlock failed: ${res.stderr}`);
throw new Error(`Restic unlock failed: ${res.stderr}`);
throw new ResticError(res.exitCode, res.stderr.toString());
}
logger.info(`Restic unlock succeeded for repository: ${repoUrl}`);
@@ -653,7 +668,7 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
args.push("--read-data");
}
addRepoSpecificArgs(args, config, env);
addCommonArgs(args, env);
const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env);
@@ -687,7 +702,7 @@ const repairIndex = async (config: RepositoryConfig) => {
const env = await buildEnv(config);
const args = ["repair", "index", "--repo", repoUrl];
addRepoSpecificArgs(args, config, env);
addCommonArgs(args, env);
const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env);
@@ -697,7 +712,7 @@ const repairIndex = async (config: RepositoryConfig) => {
if (res.exitCode !== 0) {
logger.error(`Restic repair index failed: ${stderr}`);
throw new Error(`Restic repair index failed: ${stderr}`);
throw new ResticError(res.exitCode, stderr);
}
logger.info(`Restic repair index completed for repository: ${repoUrl}`);
@@ -708,10 +723,65 @@ const repairIndex = async (config: RepositoryConfig) => {
};
};
const addRepoSpecificArgs = (args: string[], config: RepositoryConfig, env: Record<string, string>) => {
if (config.backend === "sftp" && env._SFTP_SSH_ARGS) {
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);
const copy = async (
sourceConfig: RepositoryConfig,
destConfig: RepositoryConfig,
options: {
tag?: string;
snapshotId?: string;
},
) => {
const sourceRepoUrl = buildRepoUrl(sourceConfig);
const destRepoUrl = buildRepoUrl(destConfig);
const sourceEnv = await buildEnv(sourceConfig);
const destEnv = await buildEnv(destConfig);
const env: Record<string, string> = {
...sourceEnv,
...destEnv,
RESTIC_FROM_PASSWORD_FILE: sourceEnv.RESTIC_PASSWORD_FILE,
};
const args: string[] = ["--repo", destRepoUrl, "copy", "--from-repo", sourceRepoUrl];
if (options.tag) {
args.push("--tag", options.tag);
}
if (options.snapshotId) {
args.push(options.snapshotId);
} else {
args.push("latest");
}
addCommonArgs(args, env);
if (sourceConfig.backend === "sftp" && sourceEnv._SFTP_SSH_ARGS) {
args.push("-o", `sftp.args=${sourceEnv._SFTP_SSH_ARGS}`);
}
logger.info(`Copying snapshots from ${sourceRepoUrl} to ${destRepoUrl}...`);
logger.debug(`Executing: restic ${args.join(" ")}`);
const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(sourceConfig, sourceEnv);
await cleanupTemporaryKeys(destConfig, destEnv);
const stdout = res.text();
const stderr = res.stderr.toString();
if (res.exitCode !== 0) {
logger.error(`Restic copy failed: ${stderr}`);
throw new ResticError(res.exitCode, stderr);
}
logger.info(`Restic copy completed from ${sourceRepoUrl} to ${destRepoUrl}`);
return {
success: true,
output: stdout,
};
};
const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record<string, string>) => {
@@ -724,6 +794,14 @@ const cleanupTemporaryKeys = async (config: RepositoryConfig, env: Record<string
}
};
const addCommonArgs = (args: string[], env: Record<string, string>) => {
args.push("--retry-lock", "1m", "--json");
if (env._SFTP_SSH_ARGS) {
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);
}
};
export const restic = {
ensurePassfile,
init,
@@ -736,4 +814,5 @@ export const restic = {
ls,
check,
repairIndex,
copy,
};

Some files were not shown because too many files have changed in this diff Show More