Compare commits

...

49 Commits

Author SHA1 Message Date
Nicolas Meienberger
da489fab24 Merge branch 'tvarohohlavy-volumes-secrets-encryption' 2025-12-06 10:08:20 +01:00
Nicolas Meienberger
e4b8076351 refactor: remove trim on password
The password should be taken as-is. It could potentially contain a space
2025-12-06 10:08:01 +01:00
Nicolas Meienberger
70c72f0f9a refactor: no need to print safe args as it's already sanitized 2025-12-06 10:06:03 +01:00
Nicolas Meienberger
c45b760abc ci: fix docker cache args on wrong step 2025-12-06 09:49:59 +01:00
Nicolas Meienberger
9ba26b7599 feat: add DOCKER_HOST support 2025-12-06 09:49:59 +01:00
Nicolas Meienberger
01127ee9d6 fix: volume data not refreshing when changing selection 2025-12-06 09:49:59 +01:00
Nicolas Meienberger
77f5886110 fix: remove debug logs in production 2025-12-06 09:49:59 +01:00
Nico
6b6338291b feat: custom include patterns (#104)
* feat: add custom include patterns

* feat: add exclude-if-present option
2025-12-06 09:49:59 +01:00
Nico
2c11b7c7de feat: naming backup schedules (#103)
* docs: add agents instructions

* feat: naming backup schedules

* fix: wrong table for filtering
2025-12-06 09:49:59 +01:00
Nicolas Meienberger
a0fa043207 fix: broken migration 2025-12-06 09:49:59 +01:00
Nicolas Meienberger
143701820a chore: update dependencies 2025-12-06 09:49:59 +01:00
Nico
aff875c62f feat: mirror repositories (#95)
* feat: mirror repositories

feat: mirror backup repositories

* chore: pr feedbacks
2025-12-06 09:49:59 +01:00
Nicolas Meienberger
e52c25d87b ci: fix docker cache args on wrong step 2025-12-06 09:39:10 +01:00
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
Jakub Trávník
9fec6883f6 cryptoUtils.decrypt alligned with encrypt in regards of handling extra space in password file 2025-12-04 08:43:17 +01:00
Jakub Trávník
f4df9e935d crypto.Utils comments updated to reflect behaviour 2025-12-04 08:39:51 +01:00
Jakub Trávník
f326f41599 avoid logging secrets in smb backend 2025-12-04 08:33:55 +01:00
Jakub Trávník
f6b8e7e5a2 feat: implement encryption for sensitive fields in volume backends 2025-12-04 00:04:26 +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
111 changed files with 11450 additions and 2116 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.

View File

@@ -62,8 +62,6 @@ jobs:
type=semver,pattern={{major}}.{{minor}}.{{patch}},prefix=v,enable=${{ needs.determine-release-type.outputs.release_type == 'release' }} type=semver,pattern={{major}}.{{minor}}.{{patch}},prefix=v,enable=${{ needs.determine-release-type.outputs.release_type == 'release' }}
flavor: | flavor: |
latest=${{ needs.determine-release-type.outputs.release_type == 'release' }} latest=${{ needs.determine-release-type.outputs.release_type == 'release' }}
cache-from: type=registry,ref=ghcr.io/nicotsx/zerobyte:buildcache
cache-to: type=registry,ref=ghcr.io/nicotsx/zerobyte:buildcache,mode=max
- name: Build and push images - name: Build and push images
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
@@ -76,6 +74,8 @@ jobs:
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: | build-args: |
APP_VERSION=${{ needs.determine-release-type.outputs.tagname }} APP_VERSION=${{ needs.determine-release-type.outputs.tagname }}
cache-from: type=registry,ref=ghcr.io/nicotsx/zerobyte:buildcache
cache-to: type=registry,ref=ghcr.io/nicotsx/zerobyte:buildcache,mode=max
publish-release: publish-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest

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 FROM oven/bun:${BUN_VERSION}-alpine AS base
@@ -14,7 +14,7 @@ WORKDIR /deps
ARG TARGETARCH ARG TARGETARCH
ARG RESTIC_VERSION="0.18.1" ARG RESTIC_VERSION="0.18.1"
ARG SHOUTRRR_VERSION="0.12.0" ARG SHOUTRRR_VERSION="0.12.1"
ENV TARGETARCH=${TARGETARCH} ENV TARGETARCH=${TARGETARCH}
RUN apk add --no-cache curl bzip2 unzip tar RUN apk add --no-cache curl bzip2 unzip tar

View File

@@ -18,6 +18,10 @@
> [!WARNING] > [!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 > 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 ## 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. 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 ```yaml
services: services:
zerobyte: zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.13 image: ghcr.io/nicotsx/zerobyte:v0.15
container_name: zerobyte container_name: zerobyte
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@@ -45,6 +49,8 @@ services:
- "4096:4096" - "4096:4096"
devices: devices:
- /dev/fuse:/dev/fuse - /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris # Set your timezone here
volumes: volumes:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte - /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 ```diff
services: services:
zerobyte: zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.13 image: ghcr.io/nicotsx/zerobyte:v0.15
container_name: zerobyte container_name: zerobyte
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@@ -81,6 +87,8 @@ services:
- "4096:4096" - "4096:4096"
devices: devices:
- /dev/fuse:/dev/fuse - /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris
volumes: volumes:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte - /var/lib/zerobyte:/var/lib/zerobyte
@@ -138,7 +146,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
```diff ```diff
services: services:
zerobyte: zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.13 image: ghcr.io/nicotsx/zerobyte:v0.15
container_name: zerobyte container_name: zerobyte
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@@ -147,6 +155,8 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov
- "4096:4096" - "4096:4096"
devices: devices:
- /dev/fuse:/dev/fuse - /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris
volumes: volumes:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte - /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 ```diff
services: services:
zerobyte: zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.13 image: ghcr.io/nicotsx/zerobyte:v0.15
container_name: zerobyte container_name: zerobyte
restart: unless-stopped restart: unless-stopped
ports: ports:
- "4096:4096" - "4096:4096"
devices: devices:
- /dev/fuse:/dev/fuse - /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris
volumes: volumes:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- - /var/lib/zerobyte:/var/lib/zerobyte - - /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 ```diff
services: services:
zerobyte: zerobyte:
image: ghcr.io/nicotsx/zerobyte:v0.13 image: ghcr.io/nicotsx/zerobyte:v0.15
container_name: zerobyte container_name: zerobyte
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
@@ -233,6 +245,8 @@ services:
- "4096:4096" - "4096:4096"
devices: devices:
- /dev/fuse:/dev/fuse - /dev/fuse:/dev/fuse
environment:
- TZ=Europe/Paris
volumes: volumes:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- - /var/lib/zerobyte:/var/lib/zerobyte - - /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: Your Zerobyte volumes will now be available as Docker volumes that you can mount into other containers using the `--volume` flag:
```bash ```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: Or using Docker Compose:
@@ -261,13 +275,13 @@ services:
myservice: myservice:
image: nginx:latest image: nginx:latest
volumes: volumes:
- im-nfs:/path/in/container - zb-abc12:/path/in/container
volumes: volumes:
im-nfs: zb-abc12:
external: true 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 ```bash
docker volume ls docker volume ls

View File

@@ -3,8 +3,8 @@
import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen'; 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 { 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, 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 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 * Register a new user
@@ -87,12 +87,10 @@ const createQueryKey = <TOptions extends Options>(id: string, options?: TOptions
if (options?.query) { if (options?.query) {
params.query = options.query; params.query = options.query;
} }
return [ return [params];
params
];
}; };
export const getMeQueryKey = (options?: Options<GetMeData>) => createQueryKey("getMe", options); export const getMeQueryKey = (options?: Options<GetMeData>) => createQueryKey('getMe', options);
/** /**
* Get current authenticated user * Get current authenticated user
@@ -110,7 +108,7 @@ export const getMeOptions = (options?: Options<GetMeData>) => queryOptions<GetMe
queryKey: getMeQueryKey(options) 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 * Get authentication system status
@@ -145,7 +143,7 @@ export const changePasswordMutation = (options?: Partial<Options<ChangePasswordD
return mutationOptions; return mutationOptions;
}; };
export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey("listVolumes", options); export const listVolumesQueryKey = (options?: Options<ListVolumesData>) => createQueryKey('listVolumes', options);
/** /**
* List all volumes * List all volumes
@@ -214,7 +212,7 @@ export const deleteVolumeMutation = (options?: Partial<Options<DeleteVolumeData>
return mutationOptions; 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 * Get a volume by name
@@ -249,7 +247,7 @@ export const updateVolumeMutation = (options?: Partial<Options<UpdateVolumeData>
return mutationOptions; 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 * Get containers using a volume by name
@@ -318,7 +316,7 @@ export const healthCheckVolumeMutation = (options?: Partial<Options<HealthCheckV
return mutationOptions; 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 * List files in a volume directory
@@ -336,7 +334,7 @@ export const listFilesOptions = (options: Options<ListFilesData>) => queryOption
queryKey: listFilesQueryKey(options) 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 * Browse directories on the host filesystem
@@ -354,7 +352,7 @@ export const browseFilesystemOptions = (options?: Options<BrowseFilesystemData>)
queryKey: browseFilesystemQueryKey(options) queryKey: browseFilesystemQueryKey(options)
}); });
export const listRepositoriesQueryKey = (options?: Options<ListRepositoriesData>) => createQueryKey("listRepositories", options); export const listRepositoriesQueryKey = (options?: Options<ListRepositoriesData>) => createQueryKey('listRepositories', options);
/** /**
* List all repositories * List all repositories
@@ -389,7 +387,7 @@ export const createRepositoryMutation = (options?: Partial<Options<CreateReposit
return mutationOptions; 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 * List all configured rclone remotes on the host system
@@ -424,7 +422,7 @@ export const deleteRepositoryMutation = (options?: Partial<Options<DeleteReposit
return mutationOptions; 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 * Get a single repository by name
@@ -442,7 +440,24 @@ export const getRepositoryOptions = (options: Options<GetRepositoryData>) => que
queryKey: getRepositoryQueryKey(options) 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 * List all snapshots in a repository
@@ -477,7 +492,7 @@ export const deleteSnapshotMutation = (options?: Partial<Options<DeleteSnapshotD
return mutationOptions; 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 * Get details of a specific snapshot
@@ -495,7 +510,7 @@ export const getSnapshotDetailsOptions = (options: Options<GetSnapshotDetailsDat
queryKey: getSnapshotDetailsQueryKey(options) 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 * List files and directories in a snapshot
@@ -547,7 +562,7 @@ export const doctorRepositoryMutation = (options?: Partial<Options<DoctorReposit
return mutationOptions; return mutationOptions;
}; };
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) => createQueryKey("listBackupSchedules", options); export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) => createQueryKey('listBackupSchedules', options);
/** /**
* List all backup schedules * List all backup schedules
@@ -599,7 +614,7 @@ export const deleteBackupScheduleMutation = (options?: Partial<Options<DeleteBac
return mutationOptions; 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 * Get a backup schedule by ID
@@ -634,7 +649,7 @@ export const updateBackupScheduleMutation = (options?: Partial<Options<UpdateBac
return mutationOptions; 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 * Get a backup schedule for a specific volume
@@ -703,7 +718,7 @@ export const runForgetMutation = (options?: Partial<Options<RunForgetData>>): Us
return mutationOptions; 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 * Get notification assignments for a backup schedule
@@ -738,7 +753,60 @@ export const updateScheduleNotificationsMutation = (options?: Partial<Options<Up
return mutationOptions; 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 * List all notification destinations
@@ -790,7 +858,7 @@ export const deleteNotificationDestinationMutation = (options?: Partial<Options<
return mutationOptions; 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 * Get a notification destination by ID
@@ -842,7 +910,7 @@ export const testNotificationDestinationMutation = (options?: Partial<Options<Te
return mutationOptions; 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 * 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 type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
export const client = createClient(createConfig<ClientOptions2>({ export const client = createClient(createConfig<ClientOptions2>({ baseUrl: 'http://192.168.2.42:4096' }));
baseUrl: 'http://192.168.2.42:4096'
}));

View File

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

View File

@@ -189,6 +189,7 @@ export type ListVolumesResponses = {
lastError: string | null; lastError: string | null;
lastHealthCheck: number; lastHealthCheck: number;
name: string; name: string;
shortId: string;
status: 'error' | 'mounted' | 'unmounted'; status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav'; type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number; updatedAt: number;
@@ -279,6 +280,7 @@ export type CreateVolumeResponses = {
lastError: string | null; lastError: string | null;
lastHealthCheck: number; lastHealthCheck: number;
name: string; name: string;
shortId: string;
status: 'error' | 'mounted' | 'unmounted'; status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav'; type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number; updatedAt: number;
@@ -422,6 +424,7 @@ export type GetVolumeResponses = {
lastError: string | null; lastError: string | null;
lastHealthCheck: number; lastHealthCheck: number;
name: string; name: string;
shortId: string;
status: 'error' | 'mounted' | 'unmounted'; status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav'; type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number; updatedAt: number;
@@ -465,6 +468,7 @@ export type UpdateVolumeData = {
ssl?: boolean; ssl?: boolean;
username?: string; username?: string;
}; };
name?: string;
}; };
path: { path: {
name: string; name: string;
@@ -522,6 +526,7 @@ export type UpdateVolumeResponses = {
lastError: string | null; lastError: string | null;
lastHealthCheck: number; lastHealthCheck: number;
name: string; name: string;
shortId: string;
status: 'error' | 'mounted' | 'unmounted'; status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav'; type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number; updatedAt: number;
@@ -704,7 +709,7 @@ export type ListRepositoriesResponses = {
* List of repositories * List of repositories
*/ */
200: Array<{ 200: Array<{
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null; compressionMode: 'auto' | 'max' | 'off' | null;
config: { config: {
accessKeyId: string; accessKeyId: string;
backend: 'r2'; backend: 'r2';
@@ -771,6 +776,7 @@ export type ListRepositoriesResponses = {
lastChecked: number | null; lastChecked: number | null;
lastError: string | null; lastError: string | null;
name: string; name: string;
shortId: string;
status: 'error' | 'healthy' | 'unknown' | null; status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
updatedAt: number; updatedAt: number;
@@ -843,7 +849,7 @@ export type CreateRepositoryData = {
isExistingRepository?: boolean; isExistingRepository?: boolean;
}; };
name: string; name: string;
compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off'; compressionMode?: 'auto' | 'max' | 'off';
}; };
path?: never; path?: never;
query?: never; query?: never;
@@ -918,7 +924,7 @@ export type GetRepositoryResponses = {
* Repository details * Repository details
*/ */
200: { 200: {
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null; compressionMode: 'auto' | 'max' | 'off' | null;
config: { config: {
accessKeyId: string; accessKeyId: string;
backend: 'r2'; backend: 'r2';
@@ -985,6 +991,7 @@ export type GetRepositoryResponses = {
lastChecked: number | null; lastChecked: number | null;
lastError: string | null; lastError: string | null;
name: string; name: string;
shortId: string;
status: 'error' | 'healthy' | 'unknown' | null; status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
updatedAt: number; updatedAt: number;
@@ -993,6 +1000,110 @@ export type GetRepositoryResponses = {
export type GetRepositoryResponse = GetRepositoryResponses[keyof 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 = { export type ListSnapshotsData = {
body?: never; body?: never;
path: { path: {
@@ -1013,6 +1124,7 @@ export type ListSnapshotsResponses = {
paths: Array<string>; paths: Array<string>;
short_id: string; short_id: string;
size: number; size: number;
tags: Array<string>;
time: number; time: number;
}>; }>;
}; };
@@ -1059,6 +1171,7 @@ export type GetSnapshotDetailsResponses = {
paths: Array<string>; paths: Array<string>;
short_id: string; short_id: string;
size: number; size: number;
tags: Array<string>;
time: number; time: number;
}; };
}; };
@@ -1113,6 +1226,8 @@ export type RestoreSnapshotData = {
exclude?: Array<string>; exclude?: Array<string>;
excludeXattr?: Array<string>; excludeXattr?: Array<string>;
include?: Array<string>; include?: Array<string>;
overwrite?: 'always' | 'if-changed' | 'if-newer' | 'never';
targetPath?: string;
}; };
path: { path: {
name: string; name: string;
@@ -1176,15 +1291,17 @@ export type ListBackupSchedulesResponses = {
createdAt: number; createdAt: number;
cronExpression: string; cronExpression: string;
enabled: boolean; enabled: boolean;
excludeIfPresent: Array<string> | null;
excludePatterns: Array<string> | null; excludePatterns: Array<string> | null;
id: number; id: number;
includePatterns: Array<string> | null; includePatterns: Array<string> | null;
lastBackupAt: number | null; lastBackupAt: number | null;
lastBackupError: string | null; lastBackupError: string | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
name: string;
nextBackupAt: number | null; nextBackupAt: number | null;
repository: { repository: {
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null; compressionMode: 'auto' | 'max' | 'off' | null;
config: { config: {
accessKeyId: string; accessKeyId: string;
backend: 'r2'; backend: 'r2';
@@ -1251,6 +1368,7 @@ export type ListBackupSchedulesResponses = {
lastChecked: number | null; lastChecked: number | null;
lastError: string | null; lastError: string | null;
name: string; name: string;
shortId: string;
status: 'error' | 'healthy' | 'unknown' | null; status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
updatedAt: number; updatedAt: number;
@@ -1304,6 +1422,7 @@ export type ListBackupSchedulesResponses = {
lastError: string | null; lastError: string | null;
lastHealthCheck: number; lastHealthCheck: number;
name: string; name: string;
shortId: string;
status: 'error' | 'mounted' | 'unmounted'; status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav'; type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number; updatedAt: number;
@@ -1318,8 +1437,10 @@ export type CreateBackupScheduleData = {
body?: { body?: {
cronExpression: string; cronExpression: string;
enabled: boolean; enabled: boolean;
name: string;
repositoryId: string; repositoryId: string;
volumeId: number; volumeId: number;
excludeIfPresent?: Array<string>;
excludePatterns?: Array<string>; excludePatterns?: Array<string>;
includePatterns?: Array<string>; includePatterns?: Array<string>;
retentionPolicy?: { retentionPolicy?: {
@@ -1346,12 +1467,14 @@ export type CreateBackupScheduleResponses = {
createdAt: number; createdAt: number;
cronExpression: string; cronExpression: string;
enabled: boolean; enabled: boolean;
excludeIfPresent: Array<string> | null;
excludePatterns: Array<string> | null; excludePatterns: Array<string> | null;
id: number; id: number;
includePatterns: Array<string> | null; includePatterns: Array<string> | null;
lastBackupAt: number | null; lastBackupAt: number | null;
lastBackupError: string | null; lastBackupError: string | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
name: string;
nextBackupAt: number | null; nextBackupAt: number | null;
repositoryId: string; repositoryId: string;
retentionPolicy: { retentionPolicy: {
@@ -1407,15 +1530,17 @@ export type GetBackupScheduleResponses = {
createdAt: number; createdAt: number;
cronExpression: string; cronExpression: string;
enabled: boolean; enabled: boolean;
excludeIfPresent: Array<string> | null;
excludePatterns: Array<string> | null; excludePatterns: Array<string> | null;
id: number; id: number;
includePatterns: Array<string> | null; includePatterns: Array<string> | null;
lastBackupAt: number | null; lastBackupAt: number | null;
lastBackupError: string | null; lastBackupError: string | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
name: string;
nextBackupAt: number | null; nextBackupAt: number | null;
repository: { repository: {
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null; compressionMode: 'auto' | 'max' | 'off' | null;
config: { config: {
accessKeyId: string; accessKeyId: string;
backend: 'r2'; backend: 'r2';
@@ -1482,6 +1607,7 @@ export type GetBackupScheduleResponses = {
lastChecked: number | null; lastChecked: number | null;
lastError: string | null; lastError: string | null;
name: string; name: string;
shortId: string;
status: 'error' | 'healthy' | 'unknown' | null; status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
updatedAt: number; updatedAt: number;
@@ -1535,6 +1661,7 @@ export type GetBackupScheduleResponses = {
lastError: string | null; lastError: string | null;
lastHealthCheck: number; lastHealthCheck: number;
name: string; name: string;
shortId: string;
status: 'error' | 'mounted' | 'unmounted'; status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav'; type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number; updatedAt: number;
@@ -1550,8 +1677,10 @@ export type UpdateBackupScheduleData = {
cronExpression: string; cronExpression: string;
repositoryId: string; repositoryId: string;
enabled?: boolean; enabled?: boolean;
excludeIfPresent?: Array<string>;
excludePatterns?: Array<string>; excludePatterns?: Array<string>;
includePatterns?: Array<string>; includePatterns?: Array<string>;
name?: string;
retentionPolicy?: { retentionPolicy?: {
keepDaily?: number; keepDaily?: number;
keepHourly?: number; keepHourly?: number;
@@ -1578,12 +1707,14 @@ export type UpdateBackupScheduleResponses = {
createdAt: number; createdAt: number;
cronExpression: string; cronExpression: string;
enabled: boolean; enabled: boolean;
excludeIfPresent: Array<string> | null;
excludePatterns: Array<string> | null; excludePatterns: Array<string> | null;
id: number; id: number;
includePatterns: Array<string> | null; includePatterns: Array<string> | null;
lastBackupAt: number | null; lastBackupAt: number | null;
lastBackupError: string | null; lastBackupError: string | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
name: string;
nextBackupAt: number | null; nextBackupAt: number | null;
repositoryId: string; repositoryId: string;
retentionPolicy: { retentionPolicy: {
@@ -1619,15 +1750,17 @@ export type GetBackupScheduleForVolumeResponses = {
createdAt: number; createdAt: number;
cronExpression: string; cronExpression: string;
enabled: boolean; enabled: boolean;
excludeIfPresent: Array<string> | null;
excludePatterns: Array<string> | null; excludePatterns: Array<string> | null;
id: number; id: number;
includePatterns: Array<string> | null; includePatterns: Array<string> | null;
lastBackupAt: number | null; lastBackupAt: number | null;
lastBackupError: string | null; lastBackupError: string | null;
lastBackupStatus: 'error' | 'in_progress' | 'success' | null; lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null;
name: string;
nextBackupAt: number | null; nextBackupAt: number | null;
repository: { repository: {
compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null; compressionMode: 'auto' | 'max' | 'off' | null;
config: { config: {
accessKeyId: string; accessKeyId: string;
backend: 'r2'; backend: 'r2';
@@ -1694,6 +1827,7 @@ export type GetBackupScheduleForVolumeResponses = {
lastChecked: number | null; lastChecked: number | null;
lastError: string | null; lastError: string | null;
name: string; name: string;
shortId: string;
status: 'error' | 'healthy' | 'unknown' | null; status: 'error' | 'healthy' | 'unknown' | null;
type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp';
updatedAt: number; updatedAt: number;
@@ -1747,6 +1881,7 @@ export type GetBackupScheduleForVolumeResponses = {
lastError: string | null; lastError: string | null;
lastHealthCheck: number; lastHealthCheck: number;
name: string; name: string;
shortId: string;
status: 'error' | 'mounted' | 'unmounted'; status: 'error' | 'mounted' | 'unmounted';
type: 'directory' | 'nfs' | 'smb' | 'webdav'; type: 'directory' | 'nfs' | 'smb' | 'webdav';
updatedAt: number; updatedAt: number;
@@ -1846,6 +1981,10 @@ export type GetScheduleNotificationsResponses = {
type: 'pushover'; type: 'pushover';
userKey: string; userKey: string;
devices?: string; devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | { } | {
from: string; from: string;
password: string; password: string;
@@ -1888,7 +2027,7 @@ export type GetScheduleNotificationsResponses = {
enabled: boolean; enabled: boolean;
id: number; id: number;
name: string; name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack'; type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number; updatedAt: number;
}; };
destinationId: number; destinationId: number;
@@ -1930,6 +2069,10 @@ export type UpdateScheduleNotificationsResponses = {
type: 'pushover'; type: 'pushover';
userKey: string; userKey: string;
devices?: string; devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | { } | {
from: string; from: string;
password: string; password: string;
@@ -1972,7 +2115,7 @@ export type UpdateScheduleNotificationsResponses = {
enabled: boolean; enabled: boolean;
id: number; id: number;
name: string; name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack'; type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number; updatedAt: number;
}; };
destinationId: number; destinationId: number;
@@ -1985,6 +2128,231 @@ export type UpdateScheduleNotificationsResponses = {
export type UpdateScheduleNotificationsResponse = UpdateScheduleNotificationsResponses[keyof 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 = { export type ListNotificationDestinationsData = {
body?: never; body?: never;
path?: never; path?: never;
@@ -2003,6 +2371,10 @@ export type ListNotificationDestinationsResponses = {
type: 'pushover'; type: 'pushover';
userKey: string; userKey: string;
devices?: string; devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | { } | {
from: string; from: string;
password: string; password: string;
@@ -2045,7 +2417,7 @@ export type ListNotificationDestinationsResponses = {
enabled: boolean; enabled: boolean;
id: number; id: number;
name: string; name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack'; type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number; updatedAt: number;
}>; }>;
}; };
@@ -2060,6 +2432,10 @@ export type CreateNotificationDestinationData = {
type: 'pushover'; type: 'pushover';
userKey: string; userKey: string;
devices?: string; devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | { } | {
from: string; from: string;
password: string; password: string;
@@ -2116,6 +2492,10 @@ export type CreateNotificationDestinationResponses = {
type: 'pushover'; type: 'pushover';
userKey: string; userKey: string;
devices?: string; devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | { } | {
from: string; from: string;
password: string; password: string;
@@ -2158,7 +2538,7 @@ export type CreateNotificationDestinationResponses = {
enabled: boolean; enabled: boolean;
id: number; id: number;
name: string; name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack'; type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number; updatedAt: number;
}; };
}; };
@@ -2219,6 +2599,10 @@ export type GetNotificationDestinationResponses = {
type: 'pushover'; type: 'pushover';
userKey: string; userKey: string;
devices?: string; devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | { } | {
from: string; from: string;
password: string; password: string;
@@ -2261,7 +2645,7 @@ export type GetNotificationDestinationResponses = {
enabled: boolean; enabled: boolean;
id: number; id: number;
name: string; name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack'; type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number; updatedAt: number;
}; };
}; };
@@ -2276,6 +2660,10 @@ export type UpdateNotificationDestinationData = {
type: 'pushover'; type: 'pushover';
userKey: string; userKey: string;
devices?: string; devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | { } | {
from: string; from: string;
password: string; password: string;
@@ -2342,6 +2730,10 @@ export type UpdateNotificationDestinationResponses = {
type: 'pushover'; type: 'pushover';
userKey: string; userKey: string;
devices?: string; devices?: string;
} | {
botToken: string;
chatId: string;
type: 'telegram';
} | { } | {
from: string; from: string;
password: string; password: string;
@@ -2384,7 +2776,7 @@ export type UpdateNotificationDestinationResponses = {
enabled: boolean; enabled: boolean;
id: number; id: number;
name: string; name: string;
type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack'; type: 'custom' | 'discord' | 'email' | 'gotify' | 'ntfy' | 'pushover' | 'slack' | 'telegram';
updatedAt: number; updatedAt: number;
}; };
}; };

View File

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

View File

@@ -104,8 +104,6 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
onChange={(e) => field.onChange(slugify(e.target.value))} onChange={(e) => field.onChange(slugify(e.target.value))}
max={32} max={32}
min={1} min={1}
disabled={mode === "update"}
className={mode === "update" ? "bg-gray-50" : ""}
/> />
</FormControl> </FormControl>
<FormDescription>Unique identifier for the volume.</FormDescription> <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 { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Calendar, Clock, Database, FolderTree, HardDrive, Trash2 } from "lucide-react"; 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 { toast } from "sonner";
import { ByteSize } from "~/client/components/bytes-size"; import { ByteSize } from "~/client/components/bytes-size";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
@@ -18,18 +18,17 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "~/client/components/ui/alert-dialog"; } from "~/client/components/ui/alert-dialog";
import { formatDuration } from "~/utils/utils"; import { formatDuration } from "~/utils/utils";
import type { ListSnapshotsResponse } from "../api-client";
import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { parseError } from "~/client/lib/errors"; import { parseError } from "~/client/lib/errors";
import type { BackupSchedule, Snapshot } from "../lib/types";
type Snapshot = ListSnapshotsResponse[number];
type Props = { type Props = {
snapshots: Snapshot[]; snapshots: Snapshot[];
backups: BackupSchedule[];
repositoryName: string; repositoryName: string;
}; };
export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => { export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) => {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -76,6 +75,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
<TableHeader className="bg-card-header"> <TableHeader className="bg-card-header">
<TableRow> <TableRow>
<TableHead className="uppercase">Snapshot ID</TableHead> <TableHead className="uppercase">Snapshot ID</TableHead>
<TableHead className="uppercase">Schedule</TableHead>
<TableHead className="uppercase">Date & Time</TableHead> <TableHead className="uppercase">Date & Time</TableHead>
<TableHead className="uppercase">Size</TableHead> <TableHead className="uppercase">Size</TableHead>
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead> <TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
@@ -84,7 +84,11 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{snapshots.map((snapshot) => ( {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 <TableRow
key={snapshot.short_id} key={snapshot.short_id}
className="hover:bg-accent/50 cursor-pointer" className="hover:bg-accent/50 cursor-pointer"
@@ -96,6 +100,21 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
<span className="text-strong-accent">{snapshot.short_id}</span> <span className="text-strong-accent">{snapshot.short_id}</span>
</div> </div>
</TableCell> </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> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" /> <Calendar className="h-4 w-4 text-muted-foreground" />
@@ -148,7 +167,8 @@ export const SnapshotsTable = ({ snapshots, repositoryName }: Props) => {
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} );
})}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>

View File

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

View File

@@ -9,7 +9,9 @@ type ServerEventType =
| "backup:completed" | "backup:completed"
| "volume:mounted" | "volume:mounted"
| "volume:unmounted" | "volume:unmounted"
| "volume:updated"; | "volume:updated"
| "mirror:started"
| "mirror:completed";
export interface BackupEvent { export interface BackupEvent {
scheduleId: number; scheduleId: number;
@@ -35,6 +37,14 @@ export interface VolumeEvent {
volumeName: string; volumeName: string;
} }
export interface MirrorEvent {
scheduleId: number;
repositoryId: string;
repositoryName: string;
status?: "success" | "error";
error?: string;
}
type EventHandler = (data: unknown) => void; 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) => { eventSource.onerror = (error) => {
console.error("[SSE] Connection error:", 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"; import { deepClean } from "~/utils/object";
const internalFormSchema = type({ const internalFormSchema = type({
name: "1 <= string <= 32",
repositoryId: "string", repositoryId: "string",
excludePatternsText: "string?", excludePatternsText: "string?",
excludeIfPresentText: "string?",
includePatternsText: "string?",
includePatterns: "string[]?", includePatterns: "string[]?",
frequency: "string", frequency: "string",
dailyTime: "string?", dailyTime: "string?",
@@ -50,8 +53,12 @@ export const weeklyDays = [
type InternalFormValues = typeof internalFormSchema.infer; type InternalFormValues = typeof internalFormSchema.infer;
export type BackupScheduleFormValues = Omit<InternalFormValues, "excludePatternsText"> & { export type BackupScheduleFormValues = Omit<
InternalFormValues,
"excludePatternsText" | "excludeIfPresentText" | "includePatternsText"
> & {
excludePatterns?: string[]; excludePatterns?: string[];
excludeIfPresent?: string[];
}; };
type Props = { type Props = {
@@ -79,13 +86,21 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValu
const weeklyDay = frequency === "weekly" ? dayOfWeekPart : undefined; 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 { return {
name: schedule.name,
repositoryId: schedule.repositoryId, repositoryId: schedule.repositoryId,
frequency, frequency,
dailyTime, dailyTime,
weeklyDay, 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, excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined,
...schedule.retentionPolicy, ...schedule.retentionPolicy,
}; };
}; };
@@ -98,18 +113,40 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
const handleSubmit = useCallback( const handleSubmit = useCallback(
(data: InternalFormValues) => { (data: InternalFormValues) => {
// Convert excludePatternsText string to excludePatterns array const {
const { excludePatternsText, ...rest } = data; excludePatternsText,
excludeIfPresentText,
includePatternsText,
includePatterns: fileBrowserPatterns,
...rest
} = data;
const excludePatterns = excludePatternsText const excludePatterns = excludePatternsText
? excludePatternsText ? excludePatternsText
.split("\n") .split("\n")
.map((p) => p.trim()) .map((p) => p.trim())
.filter(Boolean) .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({ onSubmit({
...rest, ...rest,
includePatterns: includePatterns.length > 0 ? includePatterns : [],
excludePatterns, excludePatterns,
excludeIfPresent,
}); });
}, },
[onSubmit], [onSubmit],
@@ -148,6 +185,21 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="grid gap-6 md:grid-cols-2"> <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 <FormField
control={form.control} control={form.control}
name="repositoryId" name="repositoryId"
@@ -260,6 +312,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<VolumeFileBrowser <VolumeFileBrowser
key={volume.id}
volumeName={volume.name} volumeName={volume.name}
selectedPaths={selectedPaths} selectedPaths={selectedPaths}
onSelectionChange={handleSelectionChange} onSelectionChange={handleSelectionChange}
@@ -279,6 +332,27 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
</div> </div>
</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> </CardContent>
</Card> </Card>
@@ -320,6 +394,28 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
</FormItem> </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> </CardContent>
</Card> </Card>
@@ -482,18 +578,27 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
{repositoriesData?.find((r) => r.id === formValues.repositoryId)?.name || "—"} {repositoriesData?.find((r) => r.id === formValues.repositoryId)?.name || "—"}
</p> </p>
</div> </div>
{formValues.includePatterns && formValues.includePatterns.length > 0 && ( {(formValues.includePatterns && formValues.includePatterns.length > 0) ||
formValues.includePatternsText ? (
<div> <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"> <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"> <span key={path} className="text-xs font-mono bg-accent px-1.5 py-0.5 rounded">
{path} {path}
</span> </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>
</div> </div>
)} ) : null}
{formValues.excludePatternsText && ( {formValues.excludePatternsText && (
<div> <div>
<p className="text-xs uppercase text-muted-foreground">Exclude patterns</p> <p className="text-xs uppercase text-muted-foreground">Exclude patterns</p>
@@ -509,6 +614,21 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
</div> </div>
</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> <div>
<p className="text-xs uppercase text-muted-foreground">Retention</p> <p className="text-xs uppercase text-muted-foreground">Retention</p>
<p className="font-medium"> <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 { useMemo, useState } from "react";
import { OnOff } from "~/client/components/onoff"; import { OnOff } from "~/client/components/onoff";
import { Button } from "~/client/components/ui/button"; 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 { useMutation } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { parseError } from "~/client/lib/errors"; import { parseError } from "~/client/lib/errors";
import { Link } from "react-router";
type Props = { type Props = {
schedule: BackupSchedule; schedule: BackupSchedule;
@@ -82,10 +83,17 @@ export const ScheduleSummary = (props: Props) => {
<CardHeader className="space-y-4"> <CardHeader className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<CardTitle>Backup schedule</CardTitle> <CardTitle>{schedule.name}</CardTitle>
<CardDescription> <CardDescription className="mt-1">
Automated backup configuration for volume&nbsp; <Link to={`/volumes/${schedule.volume.name}`} className="hover:underline">
<strong className="text-strong-accent">{schedule.volume.name}</strong> <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> </CardDescription>
</div> </div>
<div className="flex items-center gap-2 justify-between sm:justify-start"> <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 === "success" && "✓ Success"}
{schedule.lastBackupStatus === "error" && "✗ Error"} {schedule.lastBackupStatus === "error" && "✗ Error"}
{schedule.lastBackupStatus === "in_progress" && "⟳ in progress..."} {schedule.lastBackupStatus === "in_progress" && "⟳ in progress..."}
{schedule.lastBackupStatus === "warning" && "! Warning"}
{!schedule.lastBackupStatus && "—"} {!schedule.lastBackupStatus && "—"}
</p> </p>
</div> </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 && ( {schedule.lastBackupError && (
<div className="md:col-span-2 lg:col-span-4"> <div className="md:col-span-2 lg:col-span-4">
<p className="text-xs uppercase text-muted-foreground">Error Details</p> <p className="text-xs uppercase text-muted-foreground">Error Details</p>

View File

@@ -1,47 +1,26 @@
import { useCallback, useState } from "react"; import { useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, FileIcon } from "lucide-react"; import { FileIcon } from "lucide-react";
import { Link } from "react-router";
import { FileTree } from "~/client/components/file-tree"; import { FileTree } from "~/client/components/file-tree";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Button } from "~/client/components/ui/button"; import { Button, buttonVariants } from "~/client/components/ui/button";
import { Checkbox } from "~/client/components/ui/checkbox"; import type { Snapshot } from "~/client/lib/types";
import { Label } from "~/client/components/ui/label"; import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
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 { useFileBrowser } from "~/client/hooks/use-file-browser"; import { useFileBrowser } from "~/client/hooks/use-file-browser";
interface Props { interface Props {
snapshot: Snapshot; snapshot: Snapshot;
repositoryName: string; repositoryName: string;
volume?: Volume; backupId?: string;
onDeleteSnapshot?: (snapshotId: string) => void; onDeleteSnapshot?: (snapshotId: string) => void;
isDeletingSnapshot?: boolean; isDeletingSnapshot?: boolean;
} }
export const SnapshotFileBrowser = (props: Props) => { export const SnapshotFileBrowser = (props: Props) => {
const { snapshot, repositoryName, volume, onDeleteSnapshot, isDeletingSnapshot } = props; const { snapshot, repositoryName, backupId, onDeleteSnapshot, isDeletingSnapshot } = props;
const isReadOnly = volume?.config && "readOnly" in volume.config && volume.config.readOnly === true;
const queryClient = useQueryClient(); 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] || "/"; const volumeBasePath = snapshot.paths[0]?.match(/^(.*?_data)(\/|$)/)?.[1] || "/";
@@ -67,7 +46,7 @@ export const SnapshotFileBrowser = (props: Props) => {
const addBasePath = useCallback( const addBasePath = useCallback(
(displayPath: string): string => { (displayPath: string): string => {
let vbp = volumeBasePath === "/" ? "" : volumeBasePath; const vbp = volumeBasePath === "/" ? "" : volumeBasePath;
if (!vbp) return displayPath; if (!vbp) return displayPath;
if (displayPath === "/") return vbp; 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card className="h-[600px] flex flex-col"> <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> <CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{selectedPaths.size > 0 && ( <Link
<Tooltip> to={
<TooltipTrigger asChild> backupId
<span tabIndex={isReadOnly ? 0 : undefined}> ? `/backups/${backupId}/${snapshot.short_id}/restore`
<Button : `/repositories/${repositoryName}/${snapshot.short_id}/restore`
onClick={handleRestoreClick} }
variant="primary" className={buttonVariants({ variant: "primary", size: "sm" })}
size="sm"
disabled={isRestoring || isReadOnly}
> >
{isRestoring Restore
? "Restoring..." </Link>
: `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>
)}
{onDeleteSnapshot && ( {onDeleteSnapshot && (
<Button <Button
variant="destructive" variant="destructive"
@@ -211,73 +137,11 @@ export const SnapshotFileBrowser = (props: Props) => {
expandedFolders={fileBrowser.expandedFolders} expandedFolders={fileBrowser.expandedFolders}
loadingFolders={fileBrowser.loadingFolders} loadingFolders={fileBrowser.loadingFolders}
className="px-2 py-2" className="px-2 py-2"
withCheckboxes={true}
selectedPaths={selectedPaths}
onSelectionChange={setSelectedPaths}
/> />
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </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> </div>
); );
}; };

View File

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

View File

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

View File

@@ -83,6 +83,7 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) {
createSchedule.mutate({ createSchedule.mutate({
body: { body: {
name: formValues.name,
volumeId: selectedVolumeId, volumeId: selectedVolumeId,
repositoryId: formValues.repositoryId, repositoryId: formValues.repositoryId,
enabled: true, enabled: true,
@@ -90,6 +91,7 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) {
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined, retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
includePatterns: formValues.includePatterns, includePatterns: formValues.includePatterns,
excludePatterns: formValues.excludePatterns, 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"; mode?: "create" | "update";
initialValues?: Partial<NotificationFormValues>; initialValues?: Partial<NotificationFormValues>;
formId?: string; formId?: string;
loading?: boolean;
className?: string; className?: string;
}; };
@@ -70,6 +69,11 @@ const defaultValuesForType = {
apiToken: "", apiToken: "",
priority: 0 as const, priority: 0 as const,
}, },
telegram: {
type: "telegram" as const,
botToken: "",
chatId: "",
},
custom: { custom: {
type: "custom" as const, type: "custom" as const,
shoutrrrUrl: "", shoutrrrUrl: "",
@@ -114,8 +118,6 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue
onChange={(e) => field.onChange(slugify(e.target.value))} onChange={(e) => field.onChange(slugify(e.target.value))}
max={32} max={32}
min={2} min={2}
disabled={mode === "update"}
className={mode === "update" ? "bg-gray-50" : ""}
/> />
</FormControl> </FormControl>
<FormDescription>Unique identifier for this notification destination.</FormDescription> <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="gotify">Gotify</SelectItem>
<SelectItem value="ntfy">Ntfy</SelectItem> <SelectItem value="ntfy">Ntfy</SelectItem>
<SelectItem value="pushover">Pushover</SelectItem> <SelectItem value="pushover">Pushover</SelectItem>
<SelectItem value="telegram">Telegram</SelectItem>
<SelectItem value="custom">Custom (Shoutrrr URL)</SelectItem> <SelectItem value="custom">Custom (Shoutrrr URL)</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -615,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" && ( {watchedType === "custom" && (
<FormField <FormField
control={form.control} control={form.control}

View File

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

View File

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

View File

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

View File

@@ -64,8 +64,6 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
const { data } = useQuery({ const { data } = useQuery({
...getRepositoryOptions({ path: { name: loaderData.name } }), ...getRepositoryOptions({ path: { name: loaderData.name } }),
initialData: loaderData, initialData: loaderData,
refetchInterval: 10000,
refetchOnWindowFocus: true,
}); });
useEffect(() => { 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 { redirect, useParams } from "react-router";
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; 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 { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
import { getSnapshotDetails } from "~/client/api-client"; import { getSnapshotDetails } from "~/client/api-client";
import type { Route } from "./+types/snapshot-details"; 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> <h1 className="text-2xl font-bold">{name}</h1>
<p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p> <p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p>
</div> </div>
<RestoreSnapshotDialog name={name} snapshotId={snapshotId} />
</div> </div>
<SnapshotFileBrowser repositoryName={name} snapshot={loaderData} /> <SnapshotFileBrowser repositoryName={name} snapshot={loaderData} />

View File

@@ -1,29 +1,114 @@
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 { 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 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 = { type Props = {
repository: Repository; repository: Repository;
}; };
export const RepositoryInfoTabContent = ({ repository }: Props) => { export const RepositoryInfoTabContent = ({ repository }: Props) => {
const navigate = useNavigate();
const [name, setName] = useState(repository.name);
const [compressionMode, setCompressionMode] = useState<CompressionMode>(
(repository.compressionMode as CompressionMode) || "off",
);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
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 ( return (
<>
<Card className="p-6"> <Card className="p-6">
<div className="space-y-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> <div>
<h3 className="text-lg font-semibold mb-4">Repository Information</h3> <h3 className="text-lg font-semibold mb-4">Repository Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <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>
<div className="text-sm font-medium text-muted-foreground">Backend</div> <div className="text-sm font-medium text-muted-foreground">Backend</div>
<p className="mt-1 text-sm">{repository.type}</p> <p className="mt-1 text-sm">{repository.type}</p>
</div> </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>
<div className="text-sm font-medium text-muted-foreground">Status</div> <div className="text-sm font-medium text-muted-foreground">Status</div>
<p className="mt-1 text-sm">{repository.status || "unknown"}</p> <p className="mt-1 text-sm">{repository.status || "unknown"}</p>
@@ -40,24 +125,45 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
</div> </div>
</div> </div>
</div> </div>
{repository.lastError && ( {repository.lastError && (
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-red-500">Last Error</h3> <h3 className="text-lg font-semibold text-red-500">Last Error</h3>
</div> </div>
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4"> <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> <p className="text-sm text-red-500">{repository.lastError}</p>
</div> </div>
</div> </div>
)} )}
<div> <div>
<h3 className="text-lg font-semibold mb-4">Configuration</h3> <h3 className="text-lg font-semibold mb-4">Configuration</h3>
<div className="bg-muted/50 rounded-md p-4"> <div className="bg-muted/50 rounded-md p-4">
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre> <pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
</div> </div>
</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> </div>
</form>
</Card> </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 { useQuery } from "@tanstack/react-query";
import { Database } from "lucide-react"; import { Database } from "lucide-react";
import { useState } from "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 { SnapshotsTable } from "~/client/components/snapshots-table";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; 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({ const { data, isFetching, failureReason } = useQuery({
...listSnapshotsOptions({ path: { name: repository.name } }), ...listSnapshotsOptions({ path: { name: repository.name } }),
refetchInterval: 10000,
refetchOnWindowFocus: true,
initialData: [], initialData: [],
}); });
const schedules = useQuery({
...listBackupSchedulesOptions(),
});
const filteredSnapshots = data.filter((snapshot: Snapshot) => { const filteredSnapshots = data.filter((snapshot: Snapshot) => {
if (!searchQuery) return true; if (!searchQuery) return true;
const searchLower = searchQuery.toLowerCase(); const searchLower = searchQuery.toLowerCase();
@@ -134,7 +136,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
</TableBody> </TableBody>
</Table> </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"> <div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-between border-t">
<span> <span>

View File

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

View File

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

View File

@@ -16,17 +16,17 @@ export const DockerTabContent = ({ volume }: Props) => {
services: { services: {
nginx: { nginx: {
image: "nginx:latest", image: "nginx:latest",
volumes: [`im-${volume.name}:/path/in/container`], volumes: [`zb-${volume.shortId}:/path/in/container`],
}, },
}, },
volumes: { volumes: {
[`im-${volume.name}`]: { [`zb-${volume.shortId}`]: {
external: true, 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 { const {
data: containersData, data: containersData,

View File

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

View File

@@ -60,12 +60,8 @@
"name": "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", "tableFrom": "backup_schedule_notifications_table",
"tableTo": "backup_schedules_table", "tableTo": "backup_schedules_table",
"columnsFrom": [ "columnsFrom": ["schedule_id"],
"schedule_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -73,22 +69,15 @@
"name": "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", "tableFrom": "backup_schedule_notifications_table",
"tableTo": "notification_destinations_table", "tableTo": "notification_destinations_table",
"columnsFrom": [ "columnsFrom": ["destination_id"],
"destination_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
}, },
"compositePrimaryKeys": { "compositePrimaryKeys": {
"backup_schedule_notifications_table_schedule_id_destination_id_pk": { "backup_schedule_notifications_table_schedule_id_destination_id_pk": {
"columns": [ "columns": ["schedule_id", "destination_id"],
"schedule_id",
"destination_id"
],
"name": "backup_schedule_notifications_table_schedule_id_destination_id_pk" "name": "backup_schedule_notifications_table_schedule_id_destination_id_pk"
} }
}, },
@@ -208,12 +197,8 @@
"name": "backup_schedules_table_volume_id_volumes_table_id_fk", "name": "backup_schedules_table_volume_id_volumes_table_id_fk",
"tableFrom": "backup_schedules_table", "tableFrom": "backup_schedules_table",
"tableTo": "volumes_table", "tableTo": "volumes_table",
"columnsFrom": [ "columnsFrom": ["volume_id"],
"volume_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@@ -221,12 +206,8 @@
"name": "backup_schedules_table_repository_id_repositories_table_id_fk", "name": "backup_schedules_table_repository_id_repositories_table_id_fk",
"tableFrom": "backup_schedules_table", "tableFrom": "backup_schedules_table",
"tableTo": "repositories_table", "tableTo": "repositories_table",
"columnsFrom": [ "columnsFrom": ["repository_id"],
"repository_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -294,9 +275,7 @@
"indexes": { "indexes": {
"notification_destinations_table_name_unique": { "notification_destinations_table_name_unique": {
"name": "notification_destinations_table_name_unique", "name": "notification_destinations_table_name_unique",
"columns": [ "columns": ["name"],
"name"
],
"isUnique": true "isUnique": true
} }
}, },
@@ -386,9 +365,7 @@
"indexes": { "indexes": {
"repositories_table_name_unique": { "repositories_table_name_unique": {
"name": "repositories_table_name_unique", "name": "repositories_table_name_unique",
"columns": [ "columns": ["name"],
"name"
],
"isUnique": true "isUnique": true
} }
}, },
@@ -436,12 +413,8 @@
"name": "sessions_table_user_id_users_table_id_fk", "name": "sessions_table_user_id_users_table_id_fk",
"tableFrom": "sessions_table", "tableFrom": "sessions_table",
"tableTo": "users_table", "tableTo": "users_table",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@@ -502,9 +475,7 @@
"indexes": { "indexes": {
"users_table_username_unique": { "users_table_username_unique": {
"name": "users_table_username_unique", "name": "users_table_username_unique",
"columns": [ "columns": ["username"],
"username"
],
"isUnique": true "isUnique": true
} }
}, },
@@ -595,9 +566,7 @@
"indexes": { "indexes": {
"volumes_table_name_unique": { "volumes_table_name_unique": {
"name": "volumes_table_name_unique", "name": "volumes_table_name_unique",
"columns": [ "columns": ["name"],
"name"
],
"isUnique": true "isUnique": true
} }
}, },

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, "when": 1763644043601,
"tag": "0011_familiar_stone_men", "tag": "0011_familiar_stone_men",
"breakpoints": true "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", "./client/modules/backups/routes/backups.tsx"),
route("backups/create", "./client/modules/backups/routes/create-backup.tsx"), route("backups/create", "./client/modules/backups/routes/create-backup.tsx"),
route("backups/:id", "./client/modules/backups/routes/backup-details.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", "./client/modules/repositories/routes/repositories.tsx"),
route("repositories/create", "./client/modules/repositories/routes/create-repository.tsx"), route("repositories/create", "./client/modules/repositories/routes/create-repository.tsx"),
route("repositories/:name", "./client/modules/repositories/routes/repository-details.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", "./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", "./client/modules/notifications/routes/notifications.tsx"),
route("notifications/create", "./client/modules/notifications/routes/create-notification.tsx"), route("notifications/create", "./client/modules/notifications/routes/create-notification.tsx"),
route("notifications/:id", "./client/modules/notifications/routes/notification-details.tsx"), route("notifications/:id", "./client/modules/notifications/routes/notification-details.tsx"),

View File

@@ -7,6 +7,7 @@ export const NOTIFICATION_TYPES = {
gotify: "gotify", gotify: "gotify",
ntfy: "ntfy", ntfy: "ntfy",
pushover: "pushover", pushover: "pushover",
telegram: "telegram",
custom: "custom", custom: "custom",
} as const; } as const;
@@ -64,6 +65,12 @@ export const pushoverNotificationConfigSchema = type({
priority: "-1 | 0 | 1", priority: "-1 | 0 | 1",
}); });
export const telegramNotificationConfigSchema = type({
type: "'telegram'",
botToken: "string",
chatId: "string",
});
export const customNotificationConfigSchema = type({ export const customNotificationConfigSchema = type({
type: "'custom'", type: "'custom'",
shoutrrrUrl: "string", shoutrrrUrl: "string",
@@ -75,6 +82,7 @@ export const notificationConfigSchema = emailNotificationConfigSchema
.or(gotifyNotificationConfigSchema) .or(gotifyNotificationConfigSchema)
.or(ntfyNotificationConfigSchema) .or(ntfyNotificationConfigSchema)
.or(pushoverNotificationConfigSchema) .or(pushoverNotificationConfigSchema)
.or(telegramNotificationConfigSchema)
.or(customNotificationConfigSchema); .or(customNotificationConfigSchema);
export type NotificationConfig = typeof notificationConfigSchema.infer; export type NotificationConfig = typeof notificationConfigSchema.infer;
@@ -83,6 +91,7 @@ export const NOTIFICATION_EVENTS = {
start: "start", start: "start",
success: "success", success: "success",
failure: "failure", failure: "failure",
warning: "warning",
} as const; } as const;
export type NotificationEvent = keyof typeof NOTIFICATION_EVENTS; export type NotificationEvent = keyof typeof NOTIFICATION_EVENTS;

View File

@@ -93,8 +93,6 @@ export type RepositoryConfig = typeof repositoryConfigSchema.infer;
export const COMPRESSION_MODES = { export const COMPRESSION_MODES = {
off: "off", off: "off",
auto: "auto", auto: "auto",
fastest: "fastest",
better: "better",
max: "max", max: "max",
} as const; } as const;
@@ -107,3 +105,12 @@ export const REPOSITORY_STATUS = {
} as const; } as const;
export type RepositoryStatus = keyof typeof REPOSITORY_STATUS; 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: * Checks if Docker is available by:
* 1. Checking if /var/run/docker.sock exists and is accessible * 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> { async function detectDocker(): Promise<boolean> {
try { try {
await fs.access("/var/run/docker.sock"); const docker = new Docker(parseDockerHost(process.env.DOCKER_HOST));
const docker = new Docker();
await docker.ping(); await docker.ping();
logger.info("Docker capability: enabled"); logger.info("Docker capability: enabled");

View File

@@ -2,12 +2,10 @@ import { type } from "arktype";
import "dotenv/config"; import "dotenv/config";
const envSchema = type({ const envSchema = type({
NODE_ENV: type.enumerated("development", "production", "test").default("development"), NODE_ENV: type.enumerated("development", "production", "test").default("production"),
SESSION_SECRET: "string?",
}).pipe((s) => ({ }).pipe((s) => ({
__prod__: s.NODE_ENV === "production", __prod__: s.NODE_ENV === "production",
environment: s.NODE_ENV, environment: s.NODE_ENV,
sessionSecret: s.SESSION_SECRET || "change-me-in-production-please",
})); }));
const parseConfig = (env: unknown) => { 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 DATABASE_URL = "/var/lib/zerobyte/data/ironmount.db";
export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass"; export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass";
export const SOCKET_PATH = "/run/docker/plugins/zerobyte.sock"; 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; scheduleId: number;
volumeName: string; volumeName: string;
repositoryName: 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; }) => void;
"volume:mounted": (data: { volumeName: string }) => void; "volume:mounted": (data: { volumeName: string }) => void;
"volume:unmounted": (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 { DATABASE_URL } from "../core/constants";
import * as schema from "./schema"; import * as schema from "./schema";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import { config } from "../core/config";
await fs.mkdir(path.dirname(DATABASE_URL), { recursive: true }); await fs.mkdir(path.dirname(DATABASE_URL), { recursive: true });
const sqlite = new Database(DATABASE_URL); const sqlite = new Database(DATABASE_URL);
sqlite.run("PRAGMA foreign_keys = ON;");
export const db = drizzle({ client: sqlite, schema }); export const db = drizzle({ client: sqlite, schema });
export const runDbMigrations = () => { export const runDbMigrations = () => {
let migrationsFolder = path.join("/app", "assets", "migrations"); let migrationsFolder = path.join("/app", "assets", "migrations");
const { NODE_ENV } = process.env; if (!config.__prod__) {
if (NODE_ENV !== "production") {
migrationsFolder = path.join("/app", "app", "drizzle"); migrationsFolder = path.join("/app", "app", "drizzle");
} }
migrate(db, { migrationsFolder }); migrate(db, { migrationsFolder });
sqlite.run("PRAGMA foreign_keys = ON;");
}; };

View File

@@ -1,5 +1,5 @@
import { relations, sql } from "drizzle-orm"; 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 { CompressionMode, RepositoryBackend, repositoryConfigSchema, RepositoryStatus } from "~/schemas/restic";
import type { BackendStatus, BackendType, volumeConfigSchema } from "~/schemas/volumes"; import type { BackendStatus, BackendType, volumeConfigSchema } from "~/schemas/volumes";
import type { NotificationType, notificationConfigSchema } from "~/schemas/notifications"; import type { NotificationType, notificationConfigSchema } from "~/schemas/notifications";
@@ -9,13 +9,14 @@ import type { NotificationType, notificationConfigSchema } from "~/schemas/notif
*/ */
export const volumesTable = sqliteTable("volumes_table", { export const volumesTable = sqliteTable("volumes_table", {
id: int().primaryKey({ autoIncrement: true }), id: int().primaryKey({ autoIncrement: true }),
shortId: text("short_id").notNull().unique(),
name: text().notNull().unique(), name: text().notNull().unique(),
type: text().$type<BackendType>().notNull(), type: text().$type<BackendType>().notNull(),
status: text().$type<BackendStatus>().notNull().default("unmounted"), status: text().$type<BackendStatus>().notNull().default("unmounted"),
lastError: text("last_error"), lastError: text("last_error"),
lastHealthCheck: integer("last_health_check", { 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())`), createdAt: integer("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
updatedAt: integer("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`), updatedAt: integer("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(), config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(),
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true), autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
}); });
@@ -29,8 +30,8 @@ export const usersTable = sqliteTable("users_table", {
username: text().notNull().unique(), username: text().notNull().unique(),
passwordHash: text("password_hash").notNull(), passwordHash: text("password_hash").notNull(),
hasDownloadedResticPassword: int("has_downloaded_restic_password", { mode: "boolean" }).notNull().default(false), hasDownloadedResticPassword: int("has_downloaded_restic_password", { mode: "boolean" }).notNull().default(false),
createdAt: int("created_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())`), updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
}); });
export type User = typeof usersTable.$inferSelect; export type User = typeof usersTable.$inferSelect;
export const sessionsTable = sqliteTable("sessions_table", { export const sessionsTable = sqliteTable("sessions_table", {
@@ -39,7 +40,7 @@ export const sessionsTable = sqliteTable("sessions_table", {
.notNull() .notNull()
.references(() => usersTable.id, { onDelete: "cascade" }), .references(() => usersTable.id, { onDelete: "cascade" }),
expiresAt: int("expires_at", { mode: "number" }).notNull(), 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; export type Session = typeof sessionsTable.$inferSelect;
@@ -48,6 +49,7 @@ export type Session = typeof sessionsTable.$inferSelect;
*/ */
export const repositoriesTable = sqliteTable("repositories_table", { export const repositoriesTable = sqliteTable("repositories_table", {
id: text().primaryKey(), id: text().primaryKey(),
shortId: text("short_id").notNull().unique(),
name: text().notNull().unique(), name: text().notNull().unique(),
type: text().$type<RepositoryBackend>().notNull(), type: text().$type<RepositoryBackend>().notNull(),
config: text("config", { mode: "json" }).$type<typeof repositoryConfigSchema.inferOut>().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"), status: text().$type<RepositoryStatus>().default("unknown"),
lastChecked: int("last_checked", { mode: "number" }), lastChecked: int("last_checked", { mode: "number" }),
lastError: text("last_error"), lastError: text("last_error"),
createdAt: int("created_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())`), updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
}); });
export type Repository = typeof repositoriesTable.$inferSelect; export type Repository = typeof repositoriesTable.$inferSelect;
@@ -65,6 +67,7 @@ export type Repository = typeof repositoriesTable.$inferSelect;
*/ */
export const backupSchedulesTable = sqliteTable("backup_schedules_table", { export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
id: int().primaryKey({ autoIncrement: true }), id: int().primaryKey({ autoIncrement: true }),
name: text().notNull().unique(),
volumeId: int("volume_id") volumeId: int("volume_id")
.notNull() .notNull()
.references(() => volumesTable.id, { onDelete: "cascade" }), .references(() => volumesTable.id, { onDelete: "cascade" }),
@@ -83,14 +86,16 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
keepWithinDuration?: string; keepWithinDuration?: string;
}>(), }>(),
excludePatterns: text("exclude_patterns", { mode: "json" }).$type<string[]>().default([]), 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([]), includePatterns: text("include_patterns", { mode: "json" }).$type<string[]>().default([]),
lastBackupAt: int("last_backup_at", { mode: "number" }), 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"), lastBackupError: text("last_backup_error"),
nextBackupAt: int("next_backup_at", { mode: "number" }), nextBackupAt: int("next_backup_at", { mode: "number" }),
createdAt: int("created_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())`), updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
}); });
export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, many }) => ({ export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, many }) => ({
volume: one(volumesTable, { volume: one(volumesTable, {
fields: [backupSchedulesTable.volumeId], fields: [backupSchedulesTable.volumeId],
@@ -101,6 +106,7 @@ export const backupScheduleRelations = relations(backupSchedulesTable, ({ one, m
references: [repositoriesTable.id], references: [repositoriesTable.id],
}), }),
notifications: many(backupScheduleNotificationsTable), notifications: many(backupScheduleNotificationsTable),
mirrors: many(backupScheduleMirrorsTable),
})); }));
export type BackupSchedule = typeof backupSchedulesTable.$inferSelect; export type BackupSchedule = typeof backupSchedulesTable.$inferSelect;
@@ -113,8 +119,8 @@ export const notificationDestinationsTable = sqliteTable("notification_destinati
enabled: int("enabled", { mode: "boolean" }).notNull().default(true), enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
type: text().$type<NotificationType>().notNull(), type: text().$type<NotificationType>().notNull(),
config: text("config", { mode: "json" }).$type<typeof notificationConfigSchema.inferOut>().notNull(), config: text("config", { mode: "json" }).$type<typeof notificationConfigSchema.inferOut>().notNull(),
createdAt: int("created_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())`), updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`),
}); });
export const notificationDestinationRelations = relations(notificationDestinationsTable, ({ many }) => ({ export const notificationDestinationRelations = relations(notificationDestinationsTable, ({ many }) => ({
schedules: many(backupScheduleNotificationsTable), schedules: many(backupScheduleNotificationsTable),
@@ -136,7 +142,7 @@ export const backupScheduleNotificationsTable = sqliteTable(
notifyOnStart: int("notify_on_start", { mode: "boolean" }).notNull().default(false), notifyOnStart: int("notify_on_start", { mode: "boolean" }).notNull().default(false),
notifyOnSuccess: int("notify_on_success", { 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), 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] })], (table) => [primaryKey({ columns: [table.scheduleId, table.destinationId] })],
); );
@@ -151,3 +157,50 @@ export const backupScheduleNotificationRelations = relations(backupScheduleNotif
}), }),
})); }));
export type BackupScheduleNotification = typeof backupScheduleNotificationsTable.$inferSelect; 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 { requireAuth } from "./modules/auth/auth.middleware";
import { driverController } from "./modules/driver/driver.controller"; import { driverController } from "./modules/driver/driver.controller";
import { startup } from "./modules/lifecycle/startup"; import { startup } from "./modules/lifecycle/startup";
import { migrateToShortIds } from "./modules/lifecycle/migration";
import { repositoriesController } from "./modules/repositories/repositories.controller"; import { repositoriesController } from "./modules/repositories/repositories.controller";
import { systemController } from "./modules/system/system.controller"; import { systemController } from "./modules/system/system.controller";
import { volumeController } from "./modules/volumes/volume.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 { handleServiceError } from "./utils/errors";
import { logger } from "./utils/logger"; import { logger } from "./utils/logger";
import { shutdown } from "./modules/lifecycle/shutdown"; 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) => export const generalDescriptor = (app: Hono) =>
openAPIRouteHandler(app, { openAPIRouteHandler(app, {
@@ -68,6 +70,9 @@ app.onError((err, c) => {
runDbMigrations(); runDbMigrations();
await migrateToShortIds();
await validateRequiredMigrations(REQUIRED_MIGRATIONS);
const { docker } = await getCapabilities(); const { docker } = await getCapabilities();
if (docker) { if (docker) {

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as os from "node:os"; import * as os from "node:os";
import { OPERATION_TIMEOUT } from "../../../core/constants"; import { OPERATION_TIMEOUT } from "../../../core/constants";
import { cryptoUtils } from "../../../utils/crypto";
import { toMessage } from "../../../utils/errors"; import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger"; import { logger } from "../../../utils/logger";
import { getMountForPath } from "../../../utils/mountinfo"; import { getMountForPath } from "../../../utils/mountinfo";
@@ -33,10 +34,12 @@ const mount = async (config: BackendConfig, path: string) => {
const run = async () => { const run = async () => {
await fs.mkdir(path, { recursive: true }); await fs.mkdir(path, { recursive: true });
const password = await cryptoUtils.decrypt(config.password);
const source = `//${config.server}/${config.share}`; const source = `//${config.server}/${config.share}`;
const options = [ const options = [
`user=${config.username}`, `user=${config.username}`,
`pass=${config.password}`, `pass=${password}`,
`vers=${config.vers}`, `vers=${config.vers}`,
`port=${config.port}`, `port=${config.port}`,
"uid=1000", "uid=1000",

View File

@@ -3,6 +3,7 @@ import * as fs from "node:fs/promises";
import * as os from "node:os"; import * as os from "node:os";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { OPERATION_TIMEOUT } from "../../../core/constants"; import { OPERATION_TIMEOUT } from "../../../core/constants";
import { cryptoUtils } from "../../../utils/crypto";
import { toMessage } from "../../../utils/errors"; import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger"; import { logger } from "../../../utils/logger";
import { getMountForPath } from "../../../utils/mountinfo"; import { getMountForPath } from "../../../utils/mountinfo";
@@ -49,8 +50,9 @@ const mount = async (config: BackendConfig, path: string) => {
: ["uid=1000", "gid=1000", "file_mode=0664", "dir_mode=0775"]; : ["uid=1000", "gid=1000", "file_mode=0664", "dir_mode=0775"];
if (config.username && config.password) { if (config.username && config.password) {
const password = await cryptoUtils.decrypt(config.password);
const secretsFile = "/etc/davfs2/secrets"; const secretsFile = "/etc/davfs2/secrets";
const secretsContent = `${source} ${config.username} ${config.password}\n`; const secretsContent = `${source} ${config.username} ${password}\n`;
await fs.appendFile(secretsFile, secretsContent, { mode: 0o600 }); await fs.appendFile(secretsFile, secretsContent, { mode: 0o600 });
} }

View File

@@ -12,6 +12,10 @@ import {
stopBackupDto, stopBackupDto,
updateBackupScheduleDto, updateBackupScheduleDto,
updateBackupScheduleBody, updateBackupScheduleBody,
getScheduleMirrorsDto,
updateScheduleMirrorsDto,
updateScheduleMirrorsBody,
getMirrorCompatibilityDto,
type CreateBackupScheduleDto, type CreateBackupScheduleDto,
type DeleteBackupScheduleDto, type DeleteBackupScheduleDto,
type GetBackupScheduleDto, type GetBackupScheduleDto,
@@ -21,6 +25,9 @@ import {
type RunForgetDto, type RunForgetDto,
type StopBackupDto, type StopBackupDto,
type UpdateBackupScheduleDto, type UpdateBackupScheduleDto,
type GetScheduleMirrorsDto,
type UpdateScheduleMirrorsDto,
type GetMirrorCompatibilityDto,
} from "./backups.dto"; } from "./backups.dto";
import { backupsService } from "./backups.service"; import { backupsService } from "./backups.service";
import { import {
@@ -113,4 +120,23 @@ export const backupScheduleController = new Hono()
return c.json<UpdateScheduleNotificationsDto>(assignments, 200); 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({ const backupScheduleSchema = type({
id: "number", id: "number",
name: "string",
volumeId: "number", volumeId: "number",
repositoryId: "string", repositoryId: "string",
enabled: "boolean", enabled: "boolean",
cronExpression: "string", cronExpression: "string",
retentionPolicy: retentionPolicySchema.or("null"), retentionPolicy: retentionPolicySchema.or("null"),
excludePatterns: "string[] | null", excludePatterns: "string[] | null",
excludeIfPresent: "string[] | null",
includePatterns: "string[] | null", includePatterns: "string[] | null",
lastBackupAt: "number | null", lastBackupAt: "number | null",
lastBackupStatus: "'success' | 'error' | 'in_progress' | null", lastBackupStatus: "'success' | 'error' | 'in_progress' | 'warning' | null",
lastBackupError: "string | null", lastBackupError: "string | null",
nextBackupAt: "number | null", nextBackupAt: "number | null",
createdAt: "number", 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 * List all backup schedules
*/ */
@@ -107,12 +122,14 @@ export const getBackupScheduleForVolumeDto = describeRoute({
* Create a new backup schedule * Create a new backup schedule
*/ */
export const createBackupScheduleBody = type({ export const createBackupScheduleBody = type({
name: "1 <= string <= 32",
volumeId: "number", volumeId: "number",
repositoryId: "string", repositoryId: "string",
enabled: "boolean", enabled: "boolean",
cronExpression: "string", cronExpression: "string",
retentionPolicy: retentionPolicySchema.optional(), retentionPolicy: retentionPolicySchema.optional(),
excludePatterns: "string[]?", excludePatterns: "string[]?",
excludeIfPresent: "string[]?",
includePatterns: "string[]?", includePatterns: "string[]?",
tags: "string[]?", tags: "string[]?",
}); });
@@ -143,11 +160,13 @@ export const createBackupScheduleDto = describeRoute({
* Update a backup schedule * Update a backup schedule
*/ */
export const updateBackupScheduleBody = type({ export const updateBackupScheduleBody = type({
name: "(1 <= string <= 32)?",
repositoryId: "string", repositoryId: "string",
enabled: "boolean?", enabled: "boolean?",
cronExpression: "string", cronExpression: "string",
retentionPolicy: retentionPolicySchema.optional(), retentionPolicy: retentionPolicySchema.optional(),
excludePatterns: "string[]?", excludePatterns: "string[]?",
excludeIfPresent: "string[]?",
includePatterns: "string[]?", includePatterns: "string[]?",
tags: "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 cron from "node-cron";
import { CronExpressionParser } from "cron-parser"; import { CronExpressionParser } from "cron-parser";
import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced"; import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced";
import { db } from "../../db/db"; 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 { restic } from "../../utils/restic";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { getVolumePath } from "../volumes/helpers"; 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 { toMessage } from "../../utils/errors";
import { serverEvents } from "../../core/events"; import { serverEvents } from "../../core/events";
import { notificationsService } from "../notifications/notifications.service"; 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>(); const runningBackups = new Map<number, AbortController>();
@@ -42,7 +44,7 @@ const listSchedules = async () => {
const getSchedule = async (scheduleId: number) => { const getSchedule = async (scheduleId: number) => {
const schedule = await db.query.backupSchedulesTable.findFirst({ const schedule = await db.query.backupSchedulesTable.findFirst({
where: eq(volumesTable.id, scheduleId), where: eq(backupSchedulesTable.id, scheduleId),
with: { with: {
volume: true, volume: true,
repository: true, repository: true,
@@ -61,6 +63,14 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
throw new BadRequestError("Invalid cron expression"); 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({ const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.id, data.volumeId), where: eq(volumesTable.id, data.volumeId),
}); });
@@ -82,12 +92,14 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
const [newSchedule] = await db const [newSchedule] = await db
.insert(backupSchedulesTable) .insert(backupSchedulesTable)
.values({ .values({
name: data.name,
volumeId: data.volumeId, volumeId: data.volumeId,
repositoryId: data.repositoryId, repositoryId: data.repositoryId,
enabled: data.enabled, enabled: data.enabled,
cronExpression: data.cronExpression, cronExpression: data.cronExpression,
retentionPolicy: data.retentionPolicy ?? null, retentionPolicy: data.retentionPolicy ?? null,
excludePatterns: data.excludePatterns ?? [], excludePatterns: data.excludePatterns ?? [],
excludeIfPresent: data.excludeIfPresent ?? [],
includePatterns: data.includePatterns ?? [], includePatterns: data.includePatterns ?? [],
nextBackupAt: nextBackupAt, nextBackupAt: nextBackupAt,
}) })
@@ -113,6 +125,16 @@ const updateSchedule = async (scheduleId: number, data: UpdateBackupScheduleBody
throw new BadRequestError("Invalid cron expression"); 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({ const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.id, data.repositoryId), where: eq(repositoriesTable.id, data.repositoryId),
}); });
@@ -209,7 +231,12 @@ const executeBackup = async (scheduleId: number, manual = false) => {
await db await db
.update(backupSchedulesTable) .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)); .where(eq(backupSchedulesTable.id, scheduleId));
const abortController = new AbortController(); const abortController = new AbortController();
@@ -220,6 +247,7 @@ const executeBackup = async (scheduleId: number, manual = false) => {
const backupOptions: { const backupOptions: {
exclude?: string[]; exclude?: string[];
excludeIfPresent?: string[];
include?: string[]; include?: string[];
tags?: string[]; tags?: string[];
signal?: AbortSignal; signal?: AbortSignal;
@@ -232,11 +260,18 @@ const executeBackup = async (scheduleId: number, manual = false) => {
backupOptions.exclude = schedule.excludePatterns; backupOptions.exclude = schedule.excludePatterns;
} }
if (schedule.excludeIfPresent && schedule.excludeIfPresent.length > 0) {
backupOptions.excludeIfPresent = schedule.excludeIfPresent;
}
if (schedule.includePatterns && schedule.includePatterns.length > 0) { if (schedule.includePatterns && schedule.includePatterns.length > 0) {
backupOptions.include = schedule.includePatterns; backupOptions.include = schedule.includePatterns;
} }
await restic.backup(repository.config, volumePath, { const releaseBackupLock = await repoMutex.acquireShared(repository.id, `backup:${volume.name}`);
let exitCode: number;
try {
const result = await restic.backup(repository.config, volumePath, {
...backupOptions, ...backupOptions,
compressionMode: repository.compressionMode ?? "auto", compressionMode: repository.compressionMode ?? "auto",
onProgress: (progress) => { onProgress: (progress) => {
@@ -248,34 +283,48 @@ const executeBackup = async (scheduleId: number, manual = false) => {
}); });
}, },
}); });
exitCode = result.exitCode;
} finally {
releaseBackupLock();
}
if (schedule.retentionPolicy) { 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); const nextBackupAt = calculateNextRun(schedule.cronExpression);
await db await db
.update(backupSchedulesTable) .update(backupSchedulesTable)
.set({ .set({
lastBackupAt: Date.now(), lastBackupAt: Date.now(),
lastBackupStatus: "success", lastBackupStatus: finalStatus,
lastBackupError: null, lastBackupError: null,
nextBackupAt: nextBackupAt, nextBackupAt: nextBackupAt,
updatedAt: Date.now(), updatedAt: Date.now(),
}) })
.where(eq(backupSchedulesTable.id, scheduleId)); .where(eq(backupSchedulesTable.id, scheduleId));
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}`); logger.info(`Backup completed successfully for volume ${volume.name} to repository ${repository.name}`);
}
serverEvents.emit("backup:completed", { serverEvents.emit("backup:completed", {
scheduleId, scheduleId,
volumeName: volume.name, volumeName: volume.name,
repositoryName: repository.name, repositoryName: repository.name,
status: "success", status: finalStatus,
}); });
notificationsService notificationsService
.sendBackupNotification(scheduleId, "success", { .sendBackupNotification(scheduleId, finalStatus === "success" ? "success" : "warning", {
volumeName: volume.name, volumeName: volume.name,
repositoryName: repository.name, repositoryName: repository.name,
}) })
@@ -393,11 +442,185 @@ const runForget = async (scheduleId: number) => {
throw new NotFoundError("Repository not found"); throw new NotFoundError("Repository not found");
} }
logger.info(`Manually running retention policy (forget) for schedule ${scheduleId}`); 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() }); await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.id.toString() });
} finally {
releaseLock();
}
logger.info(`Retention policy applied successfully for schedule ${scheduleId}`); 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 = { export const backupsService = {
listSchedules, listSchedules,
getSchedule, getSchedule,
@@ -409,4 +632,7 @@ export const backupsService = {
getScheduleForVolume, getScheduleForVolume,
stopBackup, stopBackup,
runForget, runForget,
getMirrors,
updateMirrors,
getMirrorCompatibility,
}; };

View File

@@ -1,6 +1,9 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { volumeService } from "../volumes/volume.service"; import { volumeService } from "../volumes/volume.service";
import { getVolumePath } from "../volumes/helpers"; 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() export const driverController = new Hono()
.post("/VolumeDriver.Capabilities", (c) => { .post("/VolumeDriver.Capabilities", (c) => {
@@ -30,10 +33,18 @@ export const driverController = new Hono()
return c.json({ Err: "Volume name is required" }, 400); 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({ return c.json({
Mountpoint: getVolumePath(volumeName), Mountpoint: getVolumePath(volume),
}); });
}) })
.post("/VolumeDriver.Unmount", (c) => { .post("/VolumeDriver.Unmount", (c) => {
@@ -48,7 +59,15 @@ export const driverController = new Hono()
return c.json({ Err: "Volume name is required" }, 400); 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({ return c.json({
Mountpoint: getVolumePath(volume), Mountpoint: getVolumePath(volume),
@@ -61,11 +80,19 @@ export const driverController = new Hono()
return c.json({ Err: "Volume name is required" }, 400); 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({ return c.json({
Volume: { Volume: {
Name: `zb-${volume.name}`, Name: `zb-${volume.shortId}`,
Mountpoint: getVolumePath(volume), Mountpoint: getVolumePath(volume),
Status: {}, Status: {},
}, },
@@ -76,7 +103,7 @@ export const driverController = new Hono()
const volumes = await volumeService.listVolumes(); const volumes = await volumeService.listVolumes();
const res = volumes.map((volume) => ({ const res = volumes.map((volume) => ({
Name: `zb-${volume.name}`, Name: `zb-${volume.shortId}`,
Mountpoint: getVolumePath(volume), Mountpoint: getVolumePath(volume),
Status: {}, Status: {},
})); }));

View File

@@ -41,7 +41,7 @@ export const eventsController = new Hono().get("/", (c) => {
scheduleId: number; scheduleId: number;
volumeName: string; volumeName: string;
repositoryName: string; repositoryName: string;
status: "success" | "error" | "stopped"; status: "success" | "error" | "stopped" | "warning";
}) => { }) => {
stream.writeSSE({ stream.writeSSE({
data: JSON.stringify(data), 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:started", onBackupStarted);
serverEvents.on("backup:progress", onBackupProgress); serverEvents.on("backup:progress", onBackupProgress);
serverEvents.on("backup:completed", onBackupCompleted); serverEvents.on("backup:completed", onBackupCompleted);
serverEvents.on("volume:mounted", onVolumeMounted); serverEvents.on("volume:mounted", onVolumeMounted);
serverEvents.on("volume:unmounted", onVolumeUnmounted); serverEvents.on("volume:unmounted", onVolumeUnmounted);
serverEvents.on("volume:updated", onVolumeUpdated); serverEvents.on("volume:updated", onVolumeUpdated);
serverEvents.on("mirror:started", onMirrorStarted);
serverEvents.on("mirror:completed", onMirrorCompleted);
let keepAlive = true; let keepAlive = true;
@@ -88,6 +110,8 @@ export const eventsController = new Hono().get("/", (c) => {
serverEvents.off("volume:mounted", onVolumeMounted); serverEvents.off("volume:mounted", onVolumeMounted);
serverEvents.off("volume:unmounted", onVolumeUnmounted); serverEvents.off("volume:unmounted", onVolumeUnmounted);
serverEvents.off("volume:updated", onVolumeUpdated); serverEvents.off("volume:updated", onVolumeUpdated);
serverEvents.off("mirror:started", onMirrorStarted);
serverEvents.off("mirror:completed", onMirrorCompleted);
}); });
while (keepAlive) { 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(CleanupDanglingMountsJob).schedule("0 * * * *");
Scheduler.build(VolumeHealthCheckJob).schedule("*/30 * * * *"); Scheduler.build(VolumeHealthCheckJob).schedule("*/30 * * * *");
Scheduler.build(RepositoryHealthCheckJob).schedule("0 * * * *"); Scheduler.build(RepositoryHealthCheckJob).schedule("50 12 * * *");
Scheduler.build(BackupExecutionJob).schedule("* * * * *"); Scheduler.build(BackupExecutionJob).schedule("* * * * *");
Scheduler.build(CleanupSessionsJob).schedule("0 0 * * *"); Scheduler.build(CleanupSessionsJob).schedule("0 0 * * *");
}; };

View File

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

View File

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

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 { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
import slugify from "slugify"; import slugify from "slugify";
import { db } from "../../db/db"; import { db } from "../../db/db";
@@ -65,6 +65,11 @@ async function encryptSensitiveFields(config: NotificationConfig): Promise<Notif
...config, ...config,
apiToken: await cryptoUtils.encrypt(config.apiToken), apiToken: await cryptoUtils.encrypt(config.apiToken),
}; };
case "telegram":
return {
...config,
botToken: await cryptoUtils.encrypt(config.botToken),
};
case "custom": case "custom":
return { return {
...config, ...config,
@@ -107,6 +112,11 @@ async function decryptSensitiveFields(config: NotificationConfig): Promise<Notif
...config, ...config,
apiToken: await cryptoUtils.decrypt(config.apiToken), apiToken: await cryptoUtils.decrypt(config.apiToken),
}; };
case "telegram":
return {
...config,
botToken: await cryptoUtils.decrypt(config.botToken),
};
case "custom": case "custom":
return { return {
...config, ...config,
@@ -157,17 +167,17 @@ const updateDestination = async (
} }
const updateData: Partial<NotificationDestination> = { const updateData: Partial<NotificationDestination> = {
updatedAt: Math.floor(Date.now() / 1000), updatedAt: Date.now(),
}; };
if (updates.name !== undefined) { if (updates.name !== undefined) {
const slug = slugify(updates.name, { lower: true, strict: true }); const slug = slugify(updates.name, { lower: true, strict: true });
const conflict = await db.query.notificationDestinationsTable.findFirst({ 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"); throw new ConflictError("Notification destination with this name already exists");
} }
updateData.name = slug; updateData.name = slug;
@@ -291,6 +301,7 @@ const sendBackupNotification = async (
case "success": case "success":
return assignment.notifyOnSuccess; return assignment.notifyOnSuccess;
case "failure": case "failure":
case "warning":
return assignment.notifyOnFailure; return assignment.notifyOnFailure;
default: default:
return false; return false;
@@ -367,7 +378,7 @@ function buildNotificationMessage(
case "success": case "success":
return { return {
title: "✅ Backup Completed Successfully", title: "✅ Backup Completed successfully",
body: [ body: [
`Volume: ${context.volumeName}`, `Volume: ${context.volumeName}`,
`Repository: ${context.repositoryName}`, `Repository: ${context.repositoryName}`,
@@ -381,9 +392,26 @@ function buildNotificationMessage(
.join("\n"), .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": case "failure":
return { return {
title: "❌ Backup Failed", title: "❌ Backup failed",
body: [ body: [
`Volume: ${context.volumeName}`, `Volume: ${context.volumeName}`,
`Repository: ${context.repositoryName}`, `Repository: ${context.repositoryName}`,

View File

@@ -16,6 +16,8 @@ import {
listSnapshotsFilters, listSnapshotsFilters,
restoreSnapshotBody, restoreSnapshotBody,
restoreSnapshotDto, restoreSnapshotDto,
updateRepositoryBody,
updateRepositoryDto,
type DeleteRepositoryDto, type DeleteRepositoryDto,
type DeleteSnapshotDto, type DeleteSnapshotDto,
type DoctorRepositoryDto, type DoctorRepositoryDto,
@@ -25,6 +27,7 @@ import {
type ListSnapshotFilesDto, type ListSnapshotFilesDto,
type ListSnapshotsDto, type ListSnapshotsDto,
type RestoreSnapshotDto, type RestoreSnapshotDto,
type UpdateRepositoryDto,
} from "./repositories.dto"; } from "./repositories.dto";
import { repositoriesService } from "./repositories.service"; import { repositoriesService } from "./repositories.service";
import { getRcloneRemoteInfo, listRcloneRemotes } from "../../utils/rclone"; import { getRcloneRemoteInfo, listRcloneRemotes } from "../../utils/rclone";
@@ -87,6 +90,7 @@ export const repositoriesController = new Hono()
short_id: snapshot.short_id, short_id: snapshot.short_id,
duration, duration,
paths: snapshot.paths, paths: snapshot.paths,
tags: snapshot.tags ?? [],
size: summary?.total_bytes_processed || 0, size: summary?.total_bytes_processed || 0,
time: new Date(snapshot.time).getTime(), time: new Date(snapshot.time).getTime(),
}; };
@@ -110,6 +114,7 @@ export const repositoriesController = new Hono()
time: new Date(snapshot.time).getTime(), time: new Date(snapshot.time).getTime(),
paths: snapshot.paths, paths: snapshot.paths,
size: snapshot.summary?.total_bytes_processed || 0, size: snapshot.summary?.total_bytes_processed || 0,
tags: snapshot.tags ?? [],
summary: snapshot.summary, summary: snapshot.summary,
}; };
@@ -152,4 +157,12 @@ export const repositoriesController = new Hono()
await repositoriesService.deleteSnapshot(name, snapshotId); await repositoriesService.deleteSnapshot(name, snapshotId);
return c.json<DeleteSnapshotDto>({ message: "Snapshot deleted" }, 200); 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 { type } from "arktype";
import { describeRoute, resolver } from "hono-openapi"; 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({ export const repositorySchema = type({
id: "string", id: "string",
shortId: "string",
name: "string", name: "string",
type: type.valueOf(REPOSITORY_BACKENDS), type: type.valueOf(REPOSITORY_BACKENDS),
config: repositoryConfigSchema, 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 * List snapshots in a repository
*/ */
@@ -132,6 +174,7 @@ export const snapshotSchema = type({
paths: "string[]", paths: "string[]",
size: "number", size: "number",
duration: "number", duration: "number",
tags: "string[]",
}); });
const listSnapshotsResponse = snapshotSchema.array(); const listSnapshotsResponse = snapshotSchema.array();
@@ -233,12 +276,16 @@ export const listSnapshotFilesDto = describeRoute({
/** /**
* Restore a snapshot * Restore a snapshot
*/ */
export const overwriteModeSchema = type.valueOf(OVERWRITE_MODES);
export const restoreSnapshotBody = type({ export const restoreSnapshotBody = type({
snapshotId: "string", snapshotId: "string",
include: "string[]?", include: "string[]?",
exclude: "string[]?", exclude: "string[]?",
excludeXattr: "string[]?", excludeXattr: "string[]?",
delete: "boolean?", delete: "boolean?",
targetPath: "string?",
overwrite: overwriteModeSchema.optional(),
}); });
export type RestoreSnapshotBody = typeof restoreSnapshotBody.infer; export type RestoreSnapshotBody = typeof restoreSnapshotBody.infer;

View File

@@ -1,13 +1,15 @@
import crypto from "node:crypto"; 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 { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
import slugify from "slugify"; import slugify from "slugify";
import { db } from "../../db/db"; import { db } from "../../db/db";
import { repositoriesTable } from "../../db/schema"; import { repositoriesTable } from "../../db/schema";
import { toMessage } from "../../utils/errors"; import { toMessage } from "../../utils/errors";
import { generateShortId } from "../../utils/id";
import { restic } from "../../utils/restic"; import { restic } from "../../utils/restic";
import { cryptoUtils } from "../../utils/crypto"; 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 listRepositories = async () => {
const repositories = await db.query.repositoriesTable.findMany({}); const repositories = await db.query.repositoriesTable.findMany({});
@@ -61,13 +63,20 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
} }
const id = crypto.randomUUID(); 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 const [created] = await db
.insert(repositoriesTable) .insert(repositoriesTable)
.values({ .values({
id, id,
shortId,
name: slug, name: slug,
type: config.backend, type: config.backend,
config: encryptedConfig, config: encryptedConfig,
@@ -152,6 +161,8 @@ const listSnapshots = async (name: string, backupId?: string) => {
throw new NotFoundError("Repository not found"); throw new NotFoundError("Repository not found");
} }
const releaseLock = await repoMutex.acquireShared(repository.id, "snapshots");
try {
let snapshots = []; let snapshots = [];
if (backupId) { if (backupId) {
@@ -161,6 +172,9 @@ const listSnapshots = async (name: string, backupId?: string) => {
} }
return snapshots; return snapshots;
} finally {
releaseLock();
}
}; };
const listSnapshotFiles = async (name: string, snapshotId: string, path?: string) => { const listSnapshotFiles = async (name: string, snapshotId: string, path?: string) => {
@@ -172,6 +186,8 @@ const listSnapshotFiles = async (name: string, snapshotId: string, path?: string
throw new NotFoundError("Repository not found"); throw new NotFoundError("Repository not found");
} }
const releaseLock = await repoMutex.acquireShared(repository.id, `ls:${snapshotId}`);
try {
const result = await restic.ls(repository.config, snapshotId, path); const result = await restic.ls(repository.config, snapshotId, path);
if (!result.snapshot) { if (!result.snapshot) {
@@ -188,12 +204,22 @@ const listSnapshotFiles = async (name: string, snapshotId: string, path?: string
}, },
files: result.nodes, files: result.nodes,
}; };
} finally {
releaseLock();
}
}; };
const restoreSnapshot = async ( const restoreSnapshot = async (
name: string, name: string,
snapshotId: 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({ const repository = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.name, name), where: eq(repositoriesTable.name, name),
@@ -203,7 +229,11 @@ const restoreSnapshot = async (
throw new NotFoundError("Repository not found"); throw new NotFoundError("Repository not found");
} }
const result = await restic.restore(repository.config, snapshotId, "/", options); const target = options?.targetPath || "/";
const releaseLock = await repoMutex.acquireShared(repository.id, `restore:${snapshotId}`);
try {
const result = await restic.restore(repository.config, snapshotId, target, options);
return { return {
success: true, success: true,
@@ -211,6 +241,9 @@ const restoreSnapshot = async (
filesRestored: result.files_restored, filesRestored: result.files_restored,
filesSkipped: result.files_skipped, filesSkipped: result.files_skipped,
}; };
} finally {
releaseLock();
}
}; };
const getSnapshotDetails = async (name: string, snapshotId: string) => { const getSnapshotDetails = async (name: string, snapshotId: string) => {
@@ -222,6 +255,8 @@ const getSnapshotDetails = async (name: string, snapshotId: string) => {
throw new NotFoundError("Repository not found"); throw new NotFoundError("Repository not found");
} }
const releaseLock = await repoMutex.acquireShared(repository.id, `snapshot_details:${snapshotId}`);
try {
const snapshots = await restic.snapshots(repository.config); const snapshots = await restic.snapshots(repository.config);
const snapshot = snapshots.find((snap) => snap.id === snapshotId || snap.short_id === snapshotId); const snapshot = snapshots.find((snap) => snap.id === snapshotId || snap.short_id === snapshotId);
@@ -230,6 +265,9 @@ const getSnapshotDetails = async (name: string, snapshotId: string) => {
} }
return snapshot; return snapshot;
} finally {
releaseLock();
}
}; };
const checkHealth = async (repositoryId: string) => { const checkHealth = async (repositoryId: string) => {
@@ -241,21 +279,23 @@ const checkHealth = async (repositoryId: string) => {
throw new NotFoundError("Repository not found"); throw new NotFoundError("Repository not found");
} }
const { error, status } = await restic const releaseLock = await repoMutex.acquireExclusive(repository.id, "check");
.snapshots(repository.config) try {
.then(() => ({ error: null, status: "healthy" as const })) const { hasErrors, error } = await restic.check(repository.config);
.catch((error) => ({ error: toMessage(error), status: "error" as const }));
await db await db
.update(repositoriesTable) .update(repositoriesTable)
.set({ .set({
status, status: hasErrors ? "error" : "healthy",
lastChecked: Date.now(), lastChecked: Date.now(),
lastError: error, lastError: error,
}) })
.where(eq(repositoriesTable.id, repository.id)); .where(eq(repositoriesTable.id, repository.id));
return { status, lastError: error }; return { lastError: error };
} finally {
releaseLock();
}
}; };
const doctorRepository = async (name: string) => { const doctorRepository = async (name: string) => {
@@ -281,6 +321,8 @@ const doctorRepository = async (name: string) => {
error: unlockResult.error, error: unlockResult.error,
}); });
const releaseLock = await repoMutex.acquireExclusive(repository.id, "doctor");
try {
const checkResult = await restic.check(repository.config, { readData: false }).then( const checkResult = await restic.check(repository.config, { readData: false }).then(
(result) => result, (result) => result,
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }), (error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
@@ -318,11 +360,12 @@ const doctorRepository = async (name: string) => {
error: recheckResult.error, error: recheckResult.error,
}); });
} }
} finally {
releaseLock();
}
const allSuccessful = steps.every((s) => s.success); const allSuccessful = steps.every((s) => s.success);
console.log("Doctor steps:", steps);
await db await db
.update(repositoriesTable) .update(repositoriesTable)
.set({ .set({
@@ -347,7 +390,62 @@ const deleteSnapshot = async (name: string, snapshotId: string) => {
throw new NotFoundError("Repository not found"); throw new NotFoundError("Repository not found");
} }
const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:${snapshotId}`);
try {
await restic.deleteSnapshot(repository.config, snapshotId); 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 = { export const repositoriesService = {
@@ -355,6 +453,7 @@ export const repositoriesService = {
createRepository, createRepository,
getRepository, getRepository,
deleteRepository, deleteRepository,
updateRepository,
listSnapshots, listSnapshots,
listSnapshotFiles, listSnapshotFiles,
restoreSnapshot, restoreSnapshot,

View File

@@ -6,5 +6,5 @@ export const getVolumePath = (volume: Volume) => {
return volume.config.path; 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({ export const volumeSchema = type({
id: "number", id: "number",
shortId: "string",
name: "string", name: "string",
type: type.valueOf(BACKEND_TYPES), type: type.valueOf(BACKEND_TYPES),
status: type.valueOf(BACKEND_STATUS), status: type.valueOf(BACKEND_STATUS),
@@ -128,6 +129,7 @@ export const getVolumeDto = describeRoute({
* Update a volume * Update a volume
*/ */
export const updateVolumeBody = type({ export const updateVolumeBody = type({
name: "string?",
autoRemount: "boolean?", autoRemount: "boolean?",
config: volumeConfigSchema.optional(), config: volumeConfigSchema.optional(),
}); });

View File

@@ -2,13 +2,15 @@ import * as fs from "node:fs/promises";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import Docker from "dockerode"; 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 { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
import slugify from "slugify"; import slugify from "slugify";
import { getCapabilities } from "../../core/capabilities"; import { getCapabilities, parseDockerHost } from "../../core/capabilities";
import { db } from "../../db/db"; import { db } from "../../db/db";
import { volumesTable } from "../../db/schema"; import { volumesTable } from "../../db/schema";
import { cryptoUtils } from "../../utils/crypto";
import { toMessage } from "../../utils/errors"; import { toMessage } from "../../utils/errors";
import { generateShortId } from "../../utils/id";
import { getStatFs, type StatFs } from "../../utils/mountinfo"; import { getStatFs, type StatFs } from "../../utils/mountinfo";
import { withTimeout } from "../../utils/timeout"; import { withTimeout } from "../../utils/timeout";
import { createVolumeBackend } from "../backends/backend"; import { createVolumeBackend } from "../backends/backend";
@@ -18,6 +20,23 @@ import { logger } from "../../utils/logger";
import { serverEvents } from "../../core/events"; import { serverEvents } from "../../core/events";
import type { BackendConfig } from "~/schemas/volumes"; import type { BackendConfig } from "~/schemas/volumes";
async function encryptSensitiveFields(config: BackendConfig): Promise<BackendConfig> {
switch (config.backend) {
case "smb":
return {
...config,
password: await cryptoUtils.encrypt(config.password),
};
case "webdav":
return {
...config,
password: config.password ? await cryptoUtils.encrypt(config.password) : undefined,
};
default:
return config;
}
}
const listVolumes = async () => { const listVolumes = async () => {
const volumes = await db.query.volumesTable.findMany({}); const volumes = await db.query.volumesTable.findMany({});
@@ -35,11 +54,15 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
throw new ConflictError("Volume already exists"); throw new ConflictError("Volume already exists");
} }
const shortId = generateShortId();
const encryptedConfig = await encryptSensitiveFields(backendConfig);
const [created] = await db const [created] = await db
.insert(volumesTable) .insert(volumesTable)
.values({ .values({
shortId,
name: slug, name: slug,
config: backendConfig, config: encryptedConfig,
type: backendConfig.backend, type: backendConfig.backend,
}) })
.returning(); .returning();
@@ -147,6 +170,21 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
throw new NotFoundError("Volume not found"); 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 = const configChanged =
JSON.stringify(existing.config) !== JSON.stringify(volumeData.config) && volumeData.config !== undefined; JSON.stringify(existing.config) !== JSON.stringify(volumeData.config) && volumeData.config !== undefined;
@@ -156,15 +194,18 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
await backend.unmount(); await backend.unmount();
} }
const encryptedConfig = volumeData.config ? await encryptSensitiveFields(volumeData.config) : undefined;
const [updated] = await db const [updated] = await db
.update(volumesTable) .update(volumesTable)
.set({ .set({
config: volumeData.config, name: newName,
config: encryptedConfig,
type: volumeData.config?.backend, type: volumeData.config?.backend,
autoRemount: volumeData.autoRemount, autoRemount: volumeData.autoRemount,
updatedAt: Date.now(), updatedAt: Date.now(),
}) })
.where(eq(volumesTable.name, name)) .where(eq(volumesTable.id, existing.id))
.returning(); .returning();
if (!updated) { if (!updated) {
@@ -177,9 +218,9 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
await db await db
.update(volumesTable) .update(volumesTable)
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() }) .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 }; return { volume: updated };
@@ -190,6 +231,7 @@ const testConnection = async (backendConfig: BackendConfig) => {
const mockVolume = { const mockVolume = {
id: 0, id: 0,
shortId: "test",
name: "test-connection", name: "test-connection",
path: tempDir, path: tempDir,
config: backendConfig, config: backendConfig,
@@ -256,7 +298,8 @@ const getContainersUsingVolume = async (name: string) => {
} }
try { try {
const docker = new Docker(); const docker = new Docker(parseDockerHost(process.env.DOCKER_HOST));
const containers = await docker.listContainers({ all: true }); const containers = await docker.listContainers({ all: true });
const usingContainers = []; const usingContainers = [];
@@ -264,7 +307,7 @@ const getContainersUsingVolume = async (name: string) => {
const container = docker.getContainer(info.Id); const container = docker.getContainer(info.Id);
const inspect = await container.inspect(); const inspect = await container.inspect();
const mounts = inspect.Mounts || []; 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) { if (usesVolume) {
usingContainers.push({ usingContainers.push({
id: inspect.Id, 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

@@ -6,18 +6,26 @@ const keyLength = 32;
const encryptionPrefix = "encv1"; const encryptionPrefix = "encv1";
/** /**
* Given a string, encrypts it using a randomly generated salt * Checks if a given string is encrypted by looking for the encryption prefix.
*/
const isEncrypted = (val?: string): boolean => {
return typeof val === "string" && val.startsWith(encryptionPrefix);
};
/**
* Given a string, encrypts it using a randomly generated salt.
* Returns the input unchanged if it's empty or already encrypted.
*/ */
const encrypt = async (data: string) => { const encrypt = async (data: string) => {
if (!data) { if (!data) {
return data; return data;
} }
if (data.startsWith(encryptionPrefix)) { if (isEncrypted(data)) {
return data; return data;
} }
const secret = (await Bun.file(RESTIC_PASS_FILE).text()).trim(); const secret = await Bun.file(RESTIC_PASS_FILE).text();
const salt = crypto.randomBytes(16); const salt = crypto.randomBytes(16);
const key = crypto.pbkdf2Sync(secret, salt, 100000, keyLength, "sha256"); const key = crypto.pbkdf2Sync(secret, salt, 100000, keyLength, "sha256");
@@ -31,10 +39,15 @@ const encrypt = async (data: string) => {
}; };
/** /**
* Given an encrypted string, decrypts it using the salt stored in the string * Given an encrypted string, decrypts it using the salt stored in the string.
* Returns the input unchanged if it's not encrypted (for backward compatibility).
*/ */
const decrypt = async (encryptedData: string) => { const decrypt = async (encryptedData: string) => {
const secret = await Bun.file(RESTIC_PASS_FILE).text(); if (!isEncrypted(encryptedData)) {
return encryptedData;
}
const secret = (await Bun.file(RESTIC_PASS_FILE).text()).trim();
const parts = encryptedData.split(":").slice(1); // Remove prefix const parts = encryptedData.split(":").slice(1); // Remove prefix
const saltHex = parts.shift() as string; const saltHex = parts.shift() as string;
@@ -58,4 +71,5 @@ const decrypt = async (encryptedData: string) => {
export const cryptoUtils = { export const cryptoUtils = {
encrypt, encrypt,
decrypt, decrypt,
isEncrypted,
}; };

View File

@@ -17,3 +17,25 @@ export const toMessage = (err: unknown): string => {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
return sanitizeSensitiveData(message); 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 { createLogger, format, transports } from "winston";
import { sanitizeSensitiveData } from "./sanitize"; import { sanitizeSensitiveData } from "./sanitize";
import { config } from "../core/config";
const { printf, combine, colorize } = format; const { printf, combine, colorize } = format;
const printConsole = printf((info) => `${info.level} > ${info.message}`); const printConsole = printf((info) => `${info.level} > ${info.message}`);
const consoleFormat = combine(colorize(), printConsole); const consoleFormat = combine(colorize(), printConsole);
const defaultLevel = config.__prod__ ? "info" : "debug";
const winstonLogger = createLogger({ const winstonLogger = createLogger({
level: "debug", level: process.env.LOG_LEVEL || defaultLevel,
format: format.json(), 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[]) => { const log = (level: "info" | "warn" | "error" | "debug", messages: unknown[]) => {

View File

@@ -1,6 +1,7 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import os from "node:os";
import { throttle } from "es-toolkit"; import { throttle } from "es-toolkit";
import { type } from "arktype"; import { type } from "arktype";
import { $ } from "bun"; import { $ } from "bun";
@@ -9,7 +10,8 @@ import { logger } from "./logger";
import { cryptoUtils } from "./crypto"; import { cryptoUtils } from "./crypto";
import type { RetentionPolicy } from "../modules/backups/backups.dto"; import type { RetentionPolicy } from "../modules/backups/backups.dto";
import { safeSpawn } from "./spawn"; 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({ const backupOutputSchema = type({
message_type: "'summary'", message_type: "'summary'",
@@ -39,6 +41,7 @@ const snapshotInfoSchema = type({
time: "string", time: "string",
uid: "number?", uid: "number?",
username: "string", username: "string",
tags: "string[]?",
summary: type({ summary: type({
backup_end: "string", backup_end: "string",
backup_start: "string", backup_start: "string",
@@ -199,8 +202,8 @@ const init = async (config: RepositoryConfig) => {
const env = await buildEnv(config); const env = await buildEnv(config);
const args = ["init", "--repo", repoUrl, "--json"]; const args = ["init", "--repo", repoUrl];
addRepoSpecificArgs(args, config, env); addCommonArgs(args, env);
const res = await $`restic ${args}`.env(env).nothrow(); const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env); await cleanupTemporaryKeys(config, env);
@@ -232,6 +235,7 @@ const backup = async (
source: string, source: string,
options?: { options?: {
exclude?: string[]; exclude?: string[];
excludeIfPresent?: string[];
include?: string[]; include?: string[];
tags?: string[]; tags?: string[];
compressionMode?: CompressionMode; compressionMode?: CompressionMode;
@@ -259,8 +263,9 @@ const backup = async (
let includeFile: string | null = null; let includeFile: string | null = null;
if (options?.include && options.include.length > 0) { 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`); includeFile = path.join(tmp, `include.txt`);
const includePaths = options.include.map((p) => path.join(source, p)); const includePaths = options.include.map((p) => path.join(source, p));
await fs.writeFile(includeFile, includePaths.join("\n"), "utf-8"); await fs.writeFile(includeFile, includePaths.join("\n"), "utf-8");
@@ -276,8 +281,13 @@ const backup = async (
} }
} }
addRepoSpecificArgs(args, config, env); if (options?.excludeIfPresent && options.excludeIfPresent.length > 0) {
args.push("--json"); for (const filename of options.excludeIfPresent) {
args.push("--exclude-if-present", filename);
}
}
addCommonArgs(args, env);
const logData = throttle((data: string) => { const logData = throttle((data: string) => {
logger.info(data.trim()); logger.info(data.trim());
@@ -313,39 +323,46 @@ const backup = async (
streamProgress(data); streamProgress(data);
} }
}, },
onStderr: (error) => {
logger.error(error.trim());
},
finally: async () => { finally: async () => {
includeFile && (await fs.unlink(includeFile).catch(() => {})); includeFile && (await fs.unlink(includeFile).catch(() => {}));
await cleanupTemporaryKeys(config, env); await cleanupTemporaryKeys(config, env);
}, },
}); });
if (res.exitCode !== 0) { if (res.exitCode === 3) {
logger.error(`Restic backup failed: ${res.stderr}`); 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(" ")}`); 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 lastLine = stdout.trim();
let summaryLine = "";
try {
const resSummary = JSON.parse(lastLine ?? "{}"); 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) { if (result instanceof type.errors) {
logger.error(`Restic backup output validation failed: ${result}`); logger.error(`Restic backup output validation failed: ${result}`);
return { result: null, exitCode: res.exitCode };
throw new Error(`Restic backup output validation failed: ${result}`);
} }
return result; return { result, exitCode: res.exitCode };
}; };
const restoreOutputSchema = type({ const restoreOutputSchema = type({
message_type: "'summary'", message_type: "'summary'",
total_files: "number", total_files: "number?",
files_restored: "number", files_restored: "number",
files_skipped: "number", files_skipped: "number",
total_bytes: "number?", total_bytes: "number?",
@@ -361,8 +378,8 @@ const restore = async (
include?: string[]; include?: string[];
exclude?: string[]; exclude?: string[];
excludeXattr?: string[]; excludeXattr?: string[];
path?: string;
delete?: boolean; delete?: boolean;
overwrite?: OverwriteMode;
}, },
) => { ) => {
const repoUrl = buildRepoUrl(config); const repoUrl = buildRepoUrl(config);
@@ -370,8 +387,8 @@ const restore = async (
const args: string[] = ["--repo", repoUrl, "restore", snapshotId, "--target", target]; const args: string[] = ["--repo", repoUrl, "restore", snapshotId, "--target", target];
if (options?.path) { if (options?.overwrite) {
args[args.length - 4] = `${snapshotId}:${options.path}`; args.push("--overwrite", options.overwrite);
} }
if (options?.delete) { if (options?.delete) {
@@ -396,15 +413,15 @@ const restore = async (
} }
} }
addRepoSpecificArgs(args, config, env); addCommonArgs(args, env);
args.push("--json");
logger.debug(`Executing: restic ${args.join(" ")}`);
const res = await $`restic ${args}`.env(env).nothrow(); const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env); await cleanupTemporaryKeys(config, env);
if (res.exitCode !== 0) { if (res.exitCode !== 0) {
logger.error(`Restic restore failed: ${res.stderr}`); 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(); const stdout = res.text();
@@ -459,8 +476,7 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] }
} }
} }
addRepoSpecificArgs(args, config, env); addCommonArgs(args, env);
args.push("--json");
const res = await $`restic ${args}`.env(env).nothrow().quiet(); const res = await $`restic ${args}`.env(env).nothrow().quiet();
await cleanupTemporaryKeys(config, env); await cleanupTemporaryKeys(config, env);
@@ -509,15 +525,14 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
} }
args.push("--prune"); args.push("--prune");
addRepoSpecificArgs(args, config, env); addCommonArgs(args, env);
args.push("--json");
const res = await $`restic ${args}`.env(env).nothrow(); const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env); await cleanupTemporaryKeys(config, env);
if (res.exitCode !== 0) { if (res.exitCode !== 0) {
logger.error(`Restic forget failed: ${res.stderr}`); 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 }; return { success: true };
@@ -528,14 +543,14 @@ const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
const env = await buildEnv(config); const env = await buildEnv(config);
const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"]; const args: string[] = ["--repo", repoUrl, "forget", snapshotId, "--prune"];
addRepoSpecificArgs(args, config, env); addCommonArgs(args, env);
const res = await $`restic ${args}`.env(env).nothrow(); const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env); await cleanupTemporaryKeys(config, env);
if (res.exitCode !== 0) { if (res.exitCode !== 0) {
logger.error(`Restic snapshot deletion failed: ${res.stderr}`); 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 }; return { success: true };
@@ -572,20 +587,20 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
const repoUrl = buildRepoUrl(config); const repoUrl = buildRepoUrl(config);
const env = await buildEnv(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) { if (path) {
args.push(path); args.push(path);
} }
addRepoSpecificArgs(args, config, env); addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env }); const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(config, env); await cleanupTemporaryKeys(config, env);
if (res.exitCode !== 0) { if (res.exitCode !== 0) {
logger.error(`Restic ls failed: ${res.stderr}`); 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 // 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 repoUrl = buildRepoUrl(config);
const env = await buildEnv(config); const env = await buildEnv(config);
const args = ["unlock", "--repo", repoUrl, "--remove-all", "--json"]; const args = ["unlock", "--repo", repoUrl, "--remove-all"];
addRepoSpecificArgs(args, config, env); addCommonArgs(args, env);
const res = await $`restic ${args}`.env(env).nothrow(); const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env); await cleanupTemporaryKeys(config, env);
if (res.exitCode !== 0) { if (res.exitCode !== 0) {
logger.error(`Restic unlock failed: ${res.stderr}`); 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}`); logger.info(`Restic unlock succeeded for repository: ${repoUrl}`);
@@ -653,7 +668,7 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
args.push("--read-data"); args.push("--read-data");
} }
addRepoSpecificArgs(args, config, env); addCommonArgs(args, env);
const res = await $`restic ${args}`.env(env).nothrow(); const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env); await cleanupTemporaryKeys(config, env);
@@ -687,7 +702,7 @@ const repairIndex = async (config: RepositoryConfig) => {
const env = await buildEnv(config); const env = await buildEnv(config);
const args = ["repair", "index", "--repo", repoUrl]; const args = ["repair", "index", "--repo", repoUrl];
addRepoSpecificArgs(args, config, env); addCommonArgs(args, env);
const res = await $`restic ${args}`.env(env).nothrow(); const res = await $`restic ${args}`.env(env).nothrow();
await cleanupTemporaryKeys(config, env); await cleanupTemporaryKeys(config, env);
@@ -697,7 +712,7 @@ const repairIndex = async (config: RepositoryConfig) => {
if (res.exitCode !== 0) { if (res.exitCode !== 0) {
logger.error(`Restic repair index failed: ${stderr}`); 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}`); 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>) => { const copy = async (
if (config.backend === "sftp" && env._SFTP_SSH_ARGS) { sourceConfig: RepositoryConfig,
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`); 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>) => { 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 = { export const restic = {
ensurePassfile, ensurePassfile,
init, init,
@@ -736,4 +814,5 @@ export const restic = {
ls, ls,
check, check,
repairIndex, repairIndex,
copy,
}; };

View File

@@ -3,6 +3,10 @@
* This removes passwords and credentials from logs and error messages * This removes passwords and credentials from logs and error messages
*/ */
export const sanitizeSensitiveData = (text: string): string => { export const sanitizeSensitiveData = (text: string): string => {
if (process.env.NODE_ENV === "development") {
return text;
}
let sanitized = text.replace(/\b(pass|password)=([^\s,]+)/gi, "$1=***"); let sanitized = text.replace(/\b(pass|password)=([^\s,]+)/gi, "$1=***");
sanitized = sanitized.replace(/\/\/([^:@\s]+):([^@\s]+)@/g, "//$1:***@"); sanitized = sanitized.replace(/\/\/([^:@\s]+):([^@\s]+)@/g, "//$1:***@");

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